Compare commits
45
Commits
70b891c2af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c377405696 | ||
|
|
83cc9d504b | ||
|
|
36d8b1e464 | ||
|
|
dd40008be8 | ||
|
|
2d0f63517b | ||
|
|
1434cfd72b | ||
|
|
af07ebd462 | ||
|
|
8f78aad19b | ||
|
|
7acf13583b | ||
|
|
26dd84abaa | ||
|
|
3caff9fd8e | ||
|
|
be35fbf546 | ||
|
|
5e995dee28 | ||
|
|
0d7af99a78 | ||
|
|
aefa2a5b20 | ||
|
|
a8a33e87d2 | ||
|
|
66081e93d5 | ||
|
|
8481bc3ac8 | ||
|
|
21efc10536 | ||
|
|
0feb6132d6 | ||
|
|
1ada58af02 | ||
|
|
faf43293e5 | ||
|
|
dfce843b50 | ||
|
|
87c072f5d4 | ||
|
|
c92d06642f | ||
|
|
8c07e64e87 | ||
|
|
4a29bcb249 | ||
|
|
fac9234e4d | ||
|
|
1bec91cc5e | ||
|
|
49d4af27ca | ||
|
|
63538ec5b7 | ||
|
|
454371368c | ||
|
|
4e948f65d1 | ||
|
|
f63c28e10e | ||
|
|
d154aef807 | ||
|
|
3f23610e59 | ||
|
|
bed1e223cc | ||
|
|
da054517c0 | ||
|
|
fa9cdf913f | ||
|
|
6074cfceca | ||
|
|
163df0c5b0 | ||
|
|
32dfe3b6de | ||
|
|
d39f8a7201 | ||
|
|
4c49f6a3b5 | ||
|
|
8d932a50f3 |
+639
@@ -2,6 +2,645 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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`,仅在“已配置服务端且存在启用规则”时显示,并补充判定函数单元测试
|
||||
|
||||
### 变更
|
||||
|
||||
- 版本号提升到 1.2.20;`web/package.json` 与 `web/package-lock.json` 同步对齐
|
||||
- 本轮不涉及 Go 与 agent 行为,`AgentVersion` 仍为 0.12.7
|
||||
|
||||
## [v1.2.19] - 2026-09-27
|
||||
|
||||
### 新增
|
||||
|
||||
+19
@@ -11,6 +11,7 @@ ARG VERSION_FILE=VERSION
|
||||
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
|
||||
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
|
||||
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')" \
|
||||
-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
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -112,6 +112,12 @@ docker compose up -d --build
|
||||
|
||||
</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 初始密码**
|
||||
@@ -149,12 +155,13 @@ docker compose up -d --build
|
||||
| ----------------------- | ------------------------- | ------------------------------------------ |
|
||||
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
||||
| `FONU_SESSION_SECRET` | `change-me-in-production` | 会话加密密钥,生产环境请自行修改 |
|
||||
| `FONU_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
|
||||
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
||||
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
||||
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
|
||||
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
|
||||
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
|
||||
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
+13
-2
@@ -7,13 +7,15 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/fonu/fonu/internal/agent"
|
||||
)
|
||||
|
||||
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")
|
||||
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")
|
||||
@@ -30,7 +32,7 @@ func main() {
|
||||
log.Fatal("FONU_AGENT_TOKEN or -token is required")
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -49,3 +51,12 @@ func envOr(key, fallback string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
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}
|
||||
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}
|
||||
image: fonu:${FONU_VERSION:-local}
|
||||
# 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是),
|
||||
@@ -32,6 +33,8 @@ services:
|
||||
pull_policy: build
|
||||
container_name: fonu
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- updater
|
||||
ports:
|
||||
- "6893:6893"
|
||||
- "18080:80"
|
||||
@@ -41,14 +44,49 @@ services:
|
||||
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
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:
|
||||
fonu-data:
|
||||
fonu-updater-sock:
|
||||
|
||||
@@ -13,10 +13,13 @@ services:
|
||||
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}
|
||||
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}
|
||||
image: fonu:${FONU_VERSION:-local}
|
||||
container_name: fonu
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- updater
|
||||
ports:
|
||||
- "6893:6893"
|
||||
- "18080:80"
|
||||
@@ -26,14 +29,46 @@ services:
|
||||
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
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:
|
||||
fonu-data:
|
||||
fonu-updater-sock:
|
||||
|
||||
+10
-1
@@ -1,12 +1,21 @@
|
||||
# Fonu 文档索引
|
||||
|
||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||
> 最近更新:2026-09-24(v1.2.7)
|
||||
> 最近更新:2026-10-01(v1.9.0)
|
||||
|
||||
## 方案与设计(本目录)
|
||||
|
||||
| 文件 | 主题 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
|
||||
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
|
||||
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
|
||||
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
|
||||
| `cloudflared-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 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 实施中 |
|
||||
| `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 三项不做 |
|
||||
|
||||
@@ -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 的安全目标。
|
||||
@@ -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 隧道配置。
|
||||
- 漂移比较。
|
||||
@@ -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 才能在官方能力、参考项目功能与自身产品体系之间取得平衡,而不是停留在「进程开关 + 配置预览」。
|
||||
@@ -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 定位筛选后的实施计划,**实施以本文为准**。
|
||||
@@ -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 管理“我的服务如何安全、稳定地访问”。
|
||||
@@ -1,8 +1,8 @@
|
||||
# Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案
|
||||
|
||||
> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12)
|
||||
> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的)。
|
||||
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。详见 §五、§六。
|
||||
> 日期:2026-09-25,v2 修订 2026-09-28 对象:Fonu 1.5.4(VERSION 1.5.4)
|
||||
> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的);v2 逐文件核对了 `F:\WWW\cf\Tunnel-Cloudflared-linux`(Tunnel-Cloudflared-Manager 1.0.1)源码。
|
||||
> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。v2 修订了部署位置、模式生效侧、metrics / 健康与二进制校验四处关键细节,详见 §五、§六、§十二;实施以 §十二 为准。
|
||||
|
||||
---
|
||||
|
||||
@@ -57,7 +57,7 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
|
||||
|
||||
**结论:第一轮只支持 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 更省事)
|
||||
|
||||
@@ -112,6 +112,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
|
||||
|
||||
## 六、推荐落地路径(分阶段)
|
||||
|
||||
> 本节阶段划分是 v1 口径;v2 修订见 §12.10,实施以 §12.10 为准。
|
||||
|
||||
### 阶段 1:只读与引导(最低风险,先验证需求)
|
||||
|
||||
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`)
|
||||
|
||||
> 本节是 2026-09-25 的 v1 分析。2026-09-28 已对照 1.0.1 Linux 源码逐文件复核,补充与修正见 §十二。
|
||||
|
||||
> 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。
|
||||
> 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。
|
||||
|
||||
@@ -254,6 +258,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
|
||||
| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 |
|
||||
| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 |
|
||||
|
||||
> metrics 端口发现、健康判定与网络调优默认值见 §12.5 / §12.7;本节只讨论「本机 → CF 边缘」的优选。
|
||||
|
||||
### 必须写进功能说明的三句实话
|
||||
|
||||
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`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。
|
||||
|
||||
@@ -172,8 +172,10 @@ FONU_VERSION=1.2.18 docker compose up -d
|
||||
| `FONU_LISTEN` | `:6893` | 面板监听地址 |
|
||||
| `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
|
||||
| `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` | 通知与日志显示用的时区 |
|
||||
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
|
||||
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,983 @@
|
||||
# Fonu 项目详细分析(v1.2.19)
|
||||
|
||||
> 分析日期:2026-09-27
|
||||
> 分析对象:`F:\Fonu-main\Fonu-1.0.2`
|
||||
> 代码版本:`VERSION = 1.2.19`,前端 `package.json = 1.2.19`,内置 Agent `0.12.7`
|
||||
> 分析方式:源码静态阅读、目录与依赖统计、迁移与路由核对、构建级验证
|
||||
> 验证结果:`go test ./... -count=1`、`go vet ./...`、`npm run typecheck`、`npm test` 均通过
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
Fonu 是面向飞牛 fnOS 和家庭 NAS 场景的「公网访问管理面板」。它把四类原本分散的运维能力收敛到一个 Go 单体和 Vue 管理后台中:
|
||||
|
||||
1. 本机 Nginx 反向代理与配置版本管理;
|
||||
2. 多家 DNS 服务商的 DDNS 自动同步;
|
||||
3. ACME 证书申请、续期、导入与下载;
|
||||
4. 基于 FRP 的内网穿透,以及部署在公网 VPS 上的 `fonu-agent` 远程管理。
|
||||
|
||||
项目当前形态不是单纯 CRUD 面板,而是已经形成「本地控制面 + 公网 Agent + 定时任务 + SQLite 状态库 + Nginx 配置生成器」的完整运维系统。
|
||||
|
||||
主要优点:
|
||||
|
||||
- **模块边界较清楚**:`internal` 下按 `proxy`、`nginx`、`ddns`、`acme`、`frp`、`agent`、`monitor`、`backup` 等领域拆分,应用装配集中在 `internal/app/app.go`。
|
||||
- **交付链路完整**:同一套源码可构建 Docker 镜像、Linux amd64/arm64 原生安装包,并内嵌双架构 `fonu-agent`。
|
||||
- **关键配置具备回滚能力**:本机 Nginx 主配置、规则自定义配置,以及公网 Agent 上的 vhost 都有版本列表、差异和回滚入口。
|
||||
- **自动化程度较高**:DDNS、证书续期、中国 IP 段更新、健康巡检、指标采样、自动备份均由调度器统一驱动。
|
||||
- **测试基础设施已建立**:后端 90 个 Go 测试文件;前端已有 Vitest、TypeScript 严格模式与 CI 验证流程。
|
||||
|
||||
需要重点关注:
|
||||
|
||||
- **FRP 是系统复杂度中心**:`internal/frp` 约 6049 行,`internal/agent` 约 2079 行,二者共同承担 VPS 配置、进程、Nginx、证书、frps、诊断、版本与漂移检测,修改风险高于其他模块。
|
||||
- **前端巨型组件明显**:`ProxyView.vue` 约 3150 行,`SettingsView.vue` 约 2144 行,`AgentView.vue` 约 1807 行,维护和样式隔离成本较高。
|
||||
- **仍存在历史遗留接口引用**:前端保留 `/api/frp` 旧方法,`DdnsView.vue` 仍调用它,但后端当前只注册 `/api/frpmulti/*`;该调用落入 `catch`,会造成 DDNS 页的 FRP 提示状态始终为关闭。
|
||||
- **初始管理员密码会进入系统日志**:`auth.EnsureDefaultAdmin` 将初始密码写入应用日志,日志中心的系统日志又会读取该文件,安全边界需要收紧。
|
||||
- **仓库工作区有未提交改动**:当前有归档目录删除、内置 Agent 二进制更新、品牌图片更新等本地改动;分析时没有修改或回退这些内容。
|
||||
|
||||
---
|
||||
|
||||
## 二、仓库定位与目录边界
|
||||
|
||||
外层目录 `F:\Fonu-main` 不是项目源码仓库,而是工作区容器:
|
||||
|
||||
```text
|
||||
F:\Fonu-main
|
||||
├─ .breezell/ 工作区辅助目录
|
||||
├─ .gocache-agent/ Agent 构建缓存
|
||||
├─ docs/ 外层临时文档
|
||||
├─ Fonu-1.0.2/ 真正的 Git 仓库与源码根目录
|
||||
├─ agent-tsc.log
|
||||
├─ fonu-image-build4.log
|
||||
└─ frps-m1-tsc.log
|
||||
```
|
||||
|
||||
真正的 Git 仓库位于 `F:\Fonu-main\Fonu-1.0.2`:
|
||||
|
||||
- 当前分支:`main`
|
||||
- 远端:`origin/main`
|
||||
- 最近提交:`70b891c feat(scripts): 新增 upgrade-hub.sh,仅编排文件的部署可一条命令升级`
|
||||
- 当前未提交内容:
|
||||
- 删除 `internal/frp-1.0.2/*`;
|
||||
- 删除 `web/src/views/FrpView-1.0.2.vue`;
|
||||
- 修改 `internal/frp/agentbin/fonu-agent-linux-amd64`;
|
||||
- 修改 `internal/frp/agentbin/fonu-agent-linux-arm64`;
|
||||
- 修改 `web/public/favicon.png`、`web/src/assets/brand/logo.png`;
|
||||
- 新增 `web/public/111favicon.png`、`web/src/assets/brand/logo00.png`。
|
||||
|
||||
项目主体目录:
|
||||
|
||||
| 目录 | 作用 |
|
||||
| --- | --- |
|
||||
| `cmd/fonu` | 本地控制面主程序入口,嵌入前端静态资源 |
|
||||
| `cmd/fonu-agent` | 公网 VPS Agent 入口 |
|
||||
| `internal` | 全部业务实现,共 29 个领域包 |
|
||||
| `migrations` | SQLite 迁移,共 27 个 SQL 文件 |
|
||||
| `web` | Vue 3 + Vite 管理后台 |
|
||||
| `deploy` | Nginx MIME 类型等部署资源 |
|
||||
| `scripts` | 原生安装、升级、Agent 安装和 Linux 打包脚本 |
|
||||
| `docs` | 设计、审查、部署与 FRP 文档 |
|
||||
| `.github/workflows` | CI、Docker 发布、GitHub Release |
|
||||
|
||||
---
|
||||
|
||||
## 三、总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[浏览器管理后台] -->|HTTP :6893 / Cookie / Bearer Token| App[fonu 控制面]
|
||||
Public[公网访客] -->|HTTP/HTTPS| Nginx[本机 Nginx]
|
||||
Nginx -->|反向代理| NAS[NAS 内网服务]
|
||||
|
||||
App --> DB[(SQLite /data/fonu.db)]
|
||||
App --> FS[数据目录 /data]
|
||||
App --> NginxCtl[Nginx 配置生成与重载]
|
||||
App --> DDNS[Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎]
|
||||
App --> ACME[ACME CA]
|
||||
App --> Scheduler[后台调度器]
|
||||
|
||||
App -->|frpc.toml / 进程管理| FRPC[本机 frpc]
|
||||
FRPC -->|隧道| FRPS[公网 VPS frps]
|
||||
FRPS -->|vhost| AgentNginx[VPS Nginx]
|
||||
AgentNginx -->|隧道回源| Nginx
|
||||
|
||||
App -->|Bearer Token API :7700| Agent[fonu-agent]
|
||||
Agent --> AgentNginx
|
||||
Agent --> FRPS
|
||||
Agent --> CertStore[VPS 证书目录]
|
||||
```
|
||||
|
||||
运行角色:
|
||||
|
||||
| 角色 | 运行位置 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `fonu` | NAS / Docker / 原生 Linux | 管理后台、本机 Nginx、DDNS、证书、frpc、调度器、数据持久化 |
|
||||
| `fonu-agent` | 公网 VPS | 管理 VPS 上的 Nginx vhost、证书副本、frps 进程与配置,并提供诊断只读接口 |
|
||||
| Nginx | NAS 与 VPS 两端 | NAS 侧负责反代与流量入口;VPS 侧负责公网反代和 frps vhost 回源 |
|
||||
| frpc / frps | NAS / VPS | 在无公网 IP 时建立 TCP、HTTP、HTTPS 等隧道 |
|
||||
| SQLite | NAS 数据目录 | 保存账号、会话、代理规则、DDNS、证书、FRP、指标、健康、备份元数据等 |
|
||||
|
||||
---
|
||||
|
||||
## 四、技术栈与构建交付
|
||||
|
||||
### 4.1 后端
|
||||
|
||||
`go.mod` 声明 Go 1.23,模块路径为 `github.com/fonu/fonu`。本次验证环境使用 Go 1.26.2,可正常构建与测试。
|
||||
|
||||
关键依赖:
|
||||
|
||||
| 能力 | 依赖 |
|
||||
| --- | --- |
|
||||
| HTTP 服务 | Go 标准库 `net/http` 与 `http.ServeMux` |
|
||||
| SQLite | `modernc.org/sqlite`,纯 Go,不依赖 CGO |
|
||||
| ACME | `github.com/go-acme/lego/v4` |
|
||||
| DNSPod | `github.com/nrdcg/dnspod-go` |
|
||||
| 腾讯云 | `tencentcloud-sdk-go` |
|
||||
| 火山引擎 | `volcengine-go-sdk` |
|
||||
| SSH | `golang.org/x/crypto/ssh` |
|
||||
| 密码哈希 | `golang.org/x/crypto/bcrypt` |
|
||||
| 日志轮转 | `gopkg.in/natefinch/lumberjack.v2` |
|
||||
|
||||
后端有两个入口:
|
||||
|
||||
- `cmd/fonu/main.go`:加载配置、解析迁移目录、嵌入 `web/dist`、启动 `app.New`、监听退出信号。
|
||||
- `cmd/fonu-agent/main.go`:解析监听地址、Token、Nginx 目录、证书目录、frps 路径,启动 Agent HTTP 服务。
|
||||
|
||||
### 4.2 前端
|
||||
|
||||
前端使用 Vue 3.5、TypeScript 5.7、Vite 6:
|
||||
|
||||
| 能力 | 依赖 |
|
||||
| --- | --- |
|
||||
| UI 组件 | Naive UI |
|
||||
| 路由 | Vue Router |
|
||||
| 图表 | ECharts + vue-echarts |
|
||||
| 配置编辑器 | CodeMirror 6 |
|
||||
| 拖拽排序 | SortableJS |
|
||||
| 图标 | `@vicons/ionicons5` |
|
||||
| 测试 | Vitest |
|
||||
|
||||
`web/src` 共 52 个 TypeScript / Vue 源文件。前端测试目前有 2 个 spec、13 条测试,主要集中在格式化与状态映射纯函数。
|
||||
|
||||
### 4.3 Docker 与原生安装
|
||||
|
||||
项目提供三种 Compose:
|
||||
|
||||
| 文件 | 场景 | 网络 |
|
||||
| --- | --- | --- |
|
||||
| `docker-compose.yml` | 普通 Docker、本地源码构建 | bridge,映射 6893/18080/9443 |
|
||||
| `docker-compose.host.yml` | 飞牛 fnOS 等已占用 80/443 的环境 | host 网络,默认反代端口 18080/9443 |
|
||||
| `docker-compose.hub.yml` | 干净机器只保留一个编排文件 | BuildKit 直接从 Git 仓库拉源码构建 |
|
||||
|
||||
Dockerfile 的主要流程:
|
||||
|
||||
1. 从根目录 `VERSION` 读取版本;
|
||||
2. 构建前端;
|
||||
3. 交叉编译 Linux amd64 / arm64 `fonu-agent`;
|
||||
4. 将前端 `dist` 复制到 `cmd/fonu/web/dist` 供 `go:embed`;
|
||||
5. 编译 `fonu` 主程序;
|
||||
6. 运行时镜像安装 Nginx、CA 证书、tzdata;
|
||||
7. 默认监听 6893,暴露 80/443,数据目录为 `/data`。
|
||||
|
||||
注意:当前 Dockerfile 不预装 `frpc`,需要用户在「应用配置」中按需下载。旧版 README 或部署文档中若仍表述为“镜像内置 frpc”,应按当前实现校正。
|
||||
|
||||
### 4.4 版本一致性
|
||||
|
||||
当前版本事实:
|
||||
|
||||
| 位置 | 值 |
|
||||
| --- | --- |
|
||||
| `VERSION` | `1.2.19` |
|
||||
| `web/package.json` | `1.2.19` |
|
||||
| `AgentVersion` | `0.12.7` |
|
||||
| `internal/version.Version` | 构建时通过 `-ldflags` 注入,默认 `dev` |
|
||||
|
||||
前端构建时优先读取 `VITE_APP_VERSION`,否则回落到 `package.json`。Dockerfile 将 `VERSION` 注入前端和后端。
|
||||
|
||||
---
|
||||
|
||||
## 五、后端模块分析
|
||||
|
||||
### 5.1 规模概览
|
||||
|
||||
后端共有 235 个 `.go` 文件,其中 90 个是 `_test.go`。按非测试源码粗略统计,主要模块规模如下:
|
||||
|
||||
| 包 | 文件数 | 约行数 | 职责 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `frp` | 23 | 6049 | FRP 多服务端、frpc 运行、Agent 调用、诊断、健康、漂移、版本 |
|
||||
| `api` | 22 | 3241 | HTTP 路由、处理器、中间件、响应封装 |
|
||||
| `ddns` | 14 | 2379 | 多服务商 DDNS、记录同步、凭证、Zone 解析 |
|
||||
| `nginx` | 23 | 2186 | Nginx 配置生成、校验、启动、重载、版本与回滚 |
|
||||
| `agent` | 4 | 2079 | 公网 VPS Agent 的全部接口与配置写入 |
|
||||
| `notify` | 7 | 1418 | 通知配置、加密存储、邮件、Webhook、Telegram 与国内 IM |
|
||||
| `acme` | 8 | 1370 | ACME 申请、续期、导入、导出、任务日志 |
|
||||
| `proxy` | 3 | 1015 | 反代规则模型、SQLite Store 与端口冲突校验 |
|
||||
| `service` | 3 | 595 | 反代与 Nginx 配置的业务服务层 |
|
||||
| `monitor` | 1 | 559 | 资源、隧道、服务端、Agent、配置漂移巡检 |
|
||||
| `logstore` | 4 | 685 | 系统日志、访问日志、错误日志读取与显示转换 |
|
||||
| `traffic` | 3 | 421 | Nginx access log 跟踪、流量聚合、在线连接 |
|
||||
| `backup` | 3 | 401 | 导出、恢复、校验、自动备份与保留策略 |
|
||||
| `certificate` | 2 | 353 | 证书元数据 Store 与导入 |
|
||||
| `auth` | 2 | 334 | 管理员、会话、API Token 验证 |
|
||||
| `chinacidr` | 1 | 301 | 中国 IP 段下载、生成与 Nginx 联动 |
|
||||
| `metrics` | 1 | 231 | 指标历史写入、查询与分桶 |
|
||||
| `config` | 1 | 201 | 环境变量、数据目录、会话密钥 |
|
||||
| `settings` | 1 | 193 | 设置项读写、默认值与类型转换 |
|
||||
| `fsutil` | 1 | 164 | 原子写、备份、版本列表与回滚基础 |
|
||||
|
||||
### 5.2 应用装配:`internal/app/app.go`
|
||||
|
||||
`app.New` 是理解整个系统的入口,按顺序完成:
|
||||
|
||||
1. 创建日志目录与 JSON 日志;
|
||||
2. 创建数据、Nginx、日志、证书、FRP 目录;
|
||||
3. 解析或自动生成会话密钥;
|
||||
4. 打开 SQLite 并执行迁移;
|
||||
5. 创建 AES-GCM 凭据加密盒;
|
||||
6. 初始化管理员账号;
|
||||
7. 创建 proxy、settings、ddns、certificate、nginx 等 Store / Service;
|
||||
8. 创建 FRP 多服务端服务;
|
||||
9. 创建 HTTP 路由;
|
||||
10. 创建调度器与流量采集器;
|
||||
11. 启动 DDNS、ACME、中国 IP、巡检、自动备份任务;
|
||||
12. 初始化 Nginx 配置并恢复 frpc 运行状态;
|
||||
13. 启动 HTTP 服务,退出时关闭 Nginx、frpc 和数据库。
|
||||
|
||||
这种集中装配方式让依赖关系清晰,但也使 `app.go` 成为高耦合入口。新增模块时应继续通过构造参数或小型接口注入,避免在 `app.New` 中堆积具体职责。
|
||||
|
||||
### 5.3 配置与数据目录:`internal/config`
|
||||
|
||||
关键环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||
| `FONU_DATA_DIR` | `/data` 或当前目录 `.data` | 数据根目录 |
|
||||
| `FONU_SESSION_SECRET` | 空 | 会话签名与凭据加密密钥;空时自动生成 |
|
||||
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件 |
|
||||
| `FONU_NGINX_PID` | `<DataDir>/nginx/nginx.pid` | Nginx PID |
|
||||
| `FONU_NGINX_MIME_TYPES` | `/etc/nginx/mime.types` 或仓库资源 | MIME 类型文件 |
|
||||
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代默认端口 |
|
||||
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代默认端口 |
|
||||
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件 |
|
||||
| `FONU_FRPC_PID` | `<DataDir>/frp/frpc.pid` | frpc PID 文件 |
|
||||
|
||||
会话密钥策略值得肯定:
|
||||
|
||||
- 显式配置时优先使用;
|
||||
- 未配置时生成 32 字节随机值;
|
||||
- 持久化为 `<DataDir>/session_secret`,权限 0600;
|
||||
- 使用临时文件加改名写入,避免半文件;
|
||||
- 已经使用历史默认值时只提示,不自动替换,避免历史密文全部无法解密。
|
||||
|
||||
数据目录典型结构:
|
||||
|
||||
```text
|
||||
<DataDir>/
|
||||
├─ fonu.db
|
||||
├─ session_secret
|
||||
├─ ssh_known_hosts
|
||||
├─ nginx/
|
||||
│ ├─ nginx.conf
|
||||
│ ├─ rules/
|
||||
│ ├─ custom/
|
||||
│ ├─ backups/
|
||||
│ ├─ errors/
|
||||
│ └─ china_cidr.conf
|
||||
├─ certs/
|
||||
├─ frp/
|
||||
│ ├─ frpc.toml
|
||||
│ └─ frpc.pid
|
||||
├─ logs/
|
||||
│ ├─ app.log
|
||||
│ ├─ access.log
|
||||
│ ├─ error.log
|
||||
│ └─ frpc.log
|
||||
└─ backups/
|
||||
```
|
||||
|
||||
### 5.4 数据库:`internal/db`
|
||||
|
||||
SQLite 初始化参数:
|
||||
|
||||
- `foreign_keys(1)`:启用外键;
|
||||
- `busy_timeout(5000)`:锁等待 5 秒;
|
||||
- `SetMaxOpenConns(1)`:单连接串行访问。
|
||||
|
||||
单连接是保守且适合 NAS 单机场景的选择,能避免多连接写冲突,但所有 API、调度任务和日志查询都共享一个数据库连接。当历史指标、健康事件和流量查询增大时,响应延迟可能互相影响。
|
||||
|
||||
迁移系统特点:
|
||||
|
||||
- 按文件名前缀数字排序;
|
||||
- 每个迁移在独立事务中执行;
|
||||
- 成功后写入 `schema_migrations`;
|
||||
- 已应用版本跳过;
|
||||
- 迁移文件不嵌入二进制,运行时通过 `ResolveMigrationsDir` 在多个候选路径查找。
|
||||
|
||||
### 5.5 认证与凭据
|
||||
|
||||
认证能力分三类:
|
||||
|
||||
1. 管理员账号:bcrypt 哈希;
|
||||
2. 浏览器会话:32 字节随机 Token,数据库保存,默认 7 天;
|
||||
3. API Token:随机明文只返回一次,数据库保存 SHA-256,支持 `read` / `write` 范围。
|
||||
|
||||
登录保护:
|
||||
|
||||
- 同一 IP 在 5 分钟内失败 5 次后返回 429;
|
||||
- 登录失败会进入通知统计;
|
||||
- 修改密码后删除该账号的其他会话,保留当前会话;
|
||||
- Cookie 使用 `HttpOnly`、`SameSite=Lax`,并根据直连 TLS 或 `X-Forwarded-Proto` 设置 `Secure`。
|
||||
|
||||
凭据加密:
|
||||
|
||||
- 使用会话密钥经 SHA-256 派生 AES-256-GCM;
|
||||
- 加密 DDNS、FRP、通知、SSH、Dashboard 和 OIDC 等敏感字段;
|
||||
- 部分凭据 API 只返回 `has_token` / `has_password` 等状态,不回传明文。
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库模型
|
||||
|
||||
### 6.1 表清单
|
||||
|
||||
| 表 | 迁移 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `schema_migrations` | 001 | 迁移版本 |
|
||||
| `admins` | 001 | 管理员账号 |
|
||||
| `sessions` | 001 | 浏览器会话 |
|
||||
| `api_tokens` | 025 | API 脚本令牌 |
|
||||
| `proxy_rules` | 001 / 006 / 010-014 | 本机反向代理规则 |
|
||||
| `proxy_hosts` | 006 / 010 | 规则域名与监听端口 |
|
||||
| `proxy_custom_endpoints` | 024 | 自定义 Nginx 规则解析出的域名端口占用 |
|
||||
| `proxy_traffic` | 009 | 规则累计流量 |
|
||||
| `ddns_configs` | 002-008 / 022 | DDNS 任务、记录、凭证、自定义 IP |
|
||||
| `certificates` | 002 / 004 / 005 | 证书元数据、CA、多域名 |
|
||||
| `settings` | 001 | 键值设置 |
|
||||
| `frp_servers` | 015-016 / 020 / 023 | FRP 服务端、认证、Dashboard、Agent、SSH |
|
||||
| `frp_proxies` | 015 / 018 / 021 | FRP 穿透规则与扩展选项 |
|
||||
| `frp_runtime_status` | 015 / 019 | frpc 期望状态、进程状态、连接状态 |
|
||||
| `frp_server_diagnostics` | 017 | 服务端延迟与可用性采样 |
|
||||
| `route_health_events` | 026 | 公网规则状态变更事件 |
|
||||
| `metrics_samples` | 027 | 本机与规则指标历史 |
|
||||
|
||||
### 6.2 数据设计亮点
|
||||
|
||||
- `proxy_hosts(hostname, listen_port)` 唯一,避免同一域名与端口被多条规则占用。
|
||||
- `proxy_custom_endpoints` 单独记录自定义 Nginx 配置的监听端点,保证自定义模式也能参与冲突校验。
|
||||
- `frp_proxies.type` 在 021 迁移中扩展到 8 种类型,并为 TCP / UDP 远程端口分别建立唯一索引。
|
||||
- `frp_runtime_status` 将“用户期望状态”和“实际进程/连接状态”分开,有助于区分手动停止和异常掉线。
|
||||
- `route_health_events` 只记录状态变化,不保存每轮原始采样,降低长期数据量。
|
||||
- `metrics_samples` 采用 `(scope, metric, value, at)` 纵向结构,增加指标不需要改表。
|
||||
|
||||
### 6.3 数据生命周期
|
||||
|
||||
| 数据 | 清理方式 |
|
||||
| --- | --- |
|
||||
| 会话 | 校验过期时删除;修改密码时删除其他会话 |
|
||||
| 健康事件 | 巡检按保留期清理 |
|
||||
| 指标采样 | 巡检按保留期清理 |
|
||||
| 备份归档 | 按设置中的保留份数剪枝 |
|
||||
| Nginx 配置版本 | 默认保留最近 10 个有效变化 |
|
||||
| 日志 | 由 `logstore` 与轮转策略处理 |
|
||||
|
||||
---
|
||||
|
||||
## 七、HTTP API 与认证面
|
||||
|
||||
### 7.1 路由规模
|
||||
|
||||
当前 `internal/api/router.go` 中:
|
||||
|
||||
- 受保护路由约 113 条:使用 `SessionMiddleware + RequireAuth`;
|
||||
- 匿名路由 5 条:版本、登录状态、登录、退出、公开状态页;
|
||||
- 静态资源与 SPA fallback:非 `/api/` 路由全部交给前端。
|
||||
|
||||
匿名接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/version` | 版本与默认反代端口 |
|
||||
| GET | `/api/auth/status` | 是否初始化、是否已登录 |
|
||||
| POST | `/api/auth/login` | 登录 |
|
||||
| POST | `/api/auth/logout` | 退出 |
|
||||
| GET | `/api/status-page` | 默认关闭的只读状态页 |
|
||||
|
||||
### 7.2 API 分组
|
||||
|
||||
| 分组 | 代表路径 | 能力 |
|
||||
| --- | --- | --- |
|
||||
| 认证 | `/api/auth/*` | 登录、退出、改密 |
|
||||
| API Token | `/api/settings/tokens` | 创建、列出、撤销 |
|
||||
| 仪表盘 | `/api/status` | 公网 IP、DDNS、证书、反代、资源和请求趋势 |
|
||||
| 本机反代 | `/api/proxies*` | 规则 CRUD、排序、流量、连接、实时日志 |
|
||||
| Nginx 配置 | `/api/proxies/{id}/nginx`、`/api/settings/nginx/*` | 自动/自定义配置、校验、回滚、版本 |
|
||||
| DDNS | `/api/ddns*` | 任务、测试、全部更新、单任务更新 |
|
||||
| 证书 | `/api/certificates*` | CA 选项、申请、SSE 日志、导入、续期、下载、删除 |
|
||||
| 设置 | `/api/settings` | 常规、通知、安全、高级设置 |
|
||||
| 日志 | `/api/logs/*` | 访问、错误、系统、SSE |
|
||||
| 指标 | `/api/metrics/history` | 分桶历史趋势 |
|
||||
| 备份 | `/api/backup*` | 导出、恢复、归档、下载、删除 |
|
||||
| 服务发现 | `/api/discovery/scan` | 扫描常见 NAS 服务端口 |
|
||||
| FRP 多服务端 | `/api/frpmulti/*` | 服务端、frpc、规则、诊断、版本下载 |
|
||||
| 公网 Agent | `/api/frpmulti/servers/{id}/agent/*` | 状态、Nginx、frps、证书、路由、漂移、指标 |
|
||||
|
||||
### 7.3 Agent API
|
||||
|
||||
`fonu-agent` 当前提供 20 个 `/api/v1/*` 接口,全部使用 Bearer Token:
|
||||
|
||||
| 类别 | 接口 |
|
||||
| --- | --- |
|
||||
| 基础 | `ping`、`status` |
|
||||
| 路由 | 列表、写入、详情、删除、版本、回滚 |
|
||||
| Nginx | 安装、重载、日志 |
|
||||
| frps | 配置读取与写入、安装、启停重启 |
|
||||
| 证书 | 写入与列表 |
|
||||
| 中国 IP | `PUT /china-cidr` |
|
||||
| 指标 | `GET /metrics` |
|
||||
|
||||
Agent 侧的重要安全措施:
|
||||
|
||||
- SSH 安装使用 TOFU 主机密钥校验,首次记录,后续变更拒绝;
|
||||
- frps 下载只允许官方地址和固定加速前缀;
|
||||
- frps 安装包下载后校验 SHA-256;
|
||||
- Basic Auth 使用纯 Go apr1,不依赖 VPS 上的 `openssl`;
|
||||
- 写 vhost 前保留版本,`nginx -t` 失败时恢复上一个可用配置。
|
||||
|
||||
---
|
||||
|
||||
## 八、前端结构
|
||||
|
||||
### 8.1 路由与页面
|
||||
|
||||
前端共 11 条路由定义:
|
||||
|
||||
| 路径 | 名称 | 是否公开 | 页面 |
|
||||
| --- | --- | --- | --- |
|
||||
| `/setup` | - | 重定向 | 跳转登录 |
|
||||
| `/login` | `login` | 是 | 登录 |
|
||||
| `/status` | `status` | 是 | 公开状态页 |
|
||||
| `/dashboard` | `dashboard` | 否 | 仪表盘 |
|
||||
| `/ddns` | `ddns` | 否 | DDNS |
|
||||
| `/certificates` | `certificates` | 否 | 证书管理 |
|
||||
| `/proxies` | `proxies` | 否 | 本机反向代理 |
|
||||
| `/frp` | `frp` | 否 | 内网穿透 |
|
||||
| `/frp-agent` | `frp-agent` | 否 | 公网服务端 |
|
||||
| `/public-proxy` | `public-proxy` | 否 | 公网反代 |
|
||||
| `/logs` | `logs` | 否 | 日志中心 |
|
||||
| `/settings` | `settings` | 否 | 系统设置 |
|
||||
|
||||
路由守卫在每次受保护页面跳转前请求 `/api/auth/status`。公开状态页不做登录检查,避免匿名访问时产生无意义请求。
|
||||
|
||||
### 8.2 页面规模
|
||||
|
||||
| 文件 | 约行数 | 主要职责 |
|
||||
| --- | ---: | --- |
|
||||
| `ProxyView.vue` | 3150 | 本机反代规则、详情、日志、Nginx 配置 |
|
||||
| `SettingsView.vue` | 2144 | 常规、通知、安全、高级、备份、Token |
|
||||
| `AgentView.vue` | 1807 | 公网服务端、Agent、frps 生命周期、诊断 |
|
||||
| `CertificatesView.vue` | 1685 | 证书列表、申请、续期、下载、导入 |
|
||||
| `DashboardView.vue` | 1679 | 状态卡、趋势、健康、流量 |
|
||||
| `DdnsView.vue` | 1568 | DDNS 任务、记录、服务商配置 |
|
||||
| `FrpView.vue` | 1406 | frpc 服务端和穿透规则 |
|
||||
| `ProxyRemoteView.vue` | 1150 | 公网反代部署、漂移、健康、版本 |
|
||||
| `LogsView.vue` | 860 | 系统、访问、错误、FRP 日志 |
|
||||
|
||||
这些页面都由 Composition API 直接承载大量状态和逻辑。当前结构适合快速迭代,但需要继续拆分:
|
||||
|
||||
- `ProxyView.vue`:拆规则表单、Nginx 面板、客户端面板、日志面板;
|
||||
- `AgentView.vue`:拆服务端表单、日志弹窗、详情弹窗、延迟图;
|
||||
- `SettingsView.vue`:拆通知、安全、备份、Token、状态页卡片。
|
||||
|
||||
### 8.3 API 客户端
|
||||
|
||||
`web/src/api/client.ts` 统一处理:
|
||||
|
||||
- `credentials: include`;
|
||||
- JSON 请求体;
|
||||
- 30 秒普通请求超时;
|
||||
- 10 分钟证书操作超时;
|
||||
- 错误信息归一化;
|
||||
- Blob 下载与文件名解析。
|
||||
|
||||
`web/src/api/types.ts` 约 923 行,集中定义后端响应结构。类型覆盖较全,但仍保留旧 FRP 单体接口类型,形成历史冗余。
|
||||
|
||||
### 8.4 前端测试
|
||||
|
||||
已有:
|
||||
|
||||
- `web/src/utils/status.spec.ts`:状态值到 UI 状态的映射回归;
|
||||
- `web/src/utils/format.spec.ts`:格式化函数。
|
||||
|
||||
缺口:
|
||||
|
||||
- API 客户端无测试;
|
||||
- 页面组件无测试;
|
||||
- 轮询与可见性行为无测试;
|
||||
- 配置差异、回滚、表单校验等高风险交互仍只能依赖类型检查与人工验证。
|
||||
|
||||
---
|
||||
|
||||
## 九、核心业务链路
|
||||
|
||||
### 9.1 本机反向代理
|
||||
|
||||
数据流:
|
||||
|
||||
```text
|
||||
前端保存规则
|
||||
→ proxy.Store 校验域名与端口唯一性
|
||||
→ 写入 proxy_rules / proxy_hosts
|
||||
→ 生成 Nginx 配置
|
||||
→ nginx -t 校验
|
||||
→ 热重载或重启
|
||||
→ 写入流量采集与日志
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 规则支持多域名、多端口、IPv4/IPv6 监听、HTTPS 与 HTTP 跳转;
|
||||
- 安全设置保存在 `security_json`,包括 IP 黑白名单、仅中国大陆、Basic Auth、限流、连接数限制、安全响应头等;
|
||||
- 自动模式由模板生成,自定义模式允许编辑但会解析监听端点参与冲突校验;
|
||||
- 配置写入前保留 `.bak` 与 `.versions`,失败不覆盖当前可用配置;
|
||||
- Nginx 配置版本支持列表、读取、差异对比和回滚。
|
||||
|
||||
### 9.2 DDNS
|
||||
|
||||
支持的服务商:
|
||||
|
||||
- Cloudflare;
|
||||
- DNSPod;
|
||||
- 阿里云 DNS;
|
||||
- 腾讯云 DNS;
|
||||
- 火山引擎 DNS。
|
||||
|
||||
能力:
|
||||
|
||||
- 单任务多根域名 / 多记录;
|
||||
- 最长后缀 Zone 匹配;
|
||||
- IPv4 / IPv6;
|
||||
- 自定义固定 IP;
|
||||
- 手动测试与全部更新;
|
||||
- 定时任务按设置间隔运行,默认 5 分钟;
|
||||
- 凭证加密保存;
|
||||
- IP 变化、失败等情况进入通知系统。
|
||||
|
||||
### 9.3 证书
|
||||
|
||||
证书链路:
|
||||
|
||||
```text
|
||||
选择 CA 与域名
|
||||
→ 复用 DDNS 凭证
|
||||
→ DNS-01 挑战
|
||||
→ lego 申请
|
||||
→ 写入证书目录
|
||||
→ 更新 certificates 表
|
||||
→ 重载 Nginx
|
||||
→ 前端通过 SSE 获取任务日志
|
||||
```
|
||||
|
||||
当前 CA 相关能力:
|
||||
|
||||
- Let's Encrypt;
|
||||
- ZeroSSL;
|
||||
- LiteSSL;
|
||||
- LiteSSL 强制 EAB;
|
||||
- 可配置 DNS 递归解析器,避免容器内 `127.0.0.11` 无法解析权威 NS;
|
||||
- 支持导入 PEM、下载 ZIP / 证书 / 私钥;
|
||||
- 每天执行续期巡检,按到期阈值提醒。
|
||||
|
||||
### 9.4 FRP 与公网 Agent
|
||||
|
||||
FRP 模块当前支持:
|
||||
|
||||
- 多台公网 frps;
|
||||
- 8 种穿透类型:tcp、udp、http、https、tcpmux、stcp、sudp、xtcp;
|
||||
- frpc 多版本下载、启用与删除;
|
||||
- frpc 配置生成与进程管理;
|
||||
- frps.toml 同源生成;
|
||||
- 服务端认证方式:token、none、OIDC;
|
||||
- frpc 流量、连接数与速率;
|
||||
- 服务端 TCP / 登录 / 代理 / DNS / 归属地诊断;
|
||||
- SSH 一键安装、升级、卸载 `fonu-agent`;
|
||||
- Agent 状态、Nginx、frps 配置、证书、日志、指标;
|
||||
- 公网反代 vhost 部署、漂移检测、版本与回滚;
|
||||
- 四段健康检查:DNS、证书、隧道、回源服务。
|
||||
|
||||
主要风险点:
|
||||
|
||||
- `internal/frp/service.go` 和 `internal/agent/server.go` 都是超大文件;
|
||||
- 宿主机 Nginx 模板与 Agent Nginx 模板存在两份实现,需要人工保持同步;
|
||||
- Agent 与主程序之间存在版本兼容契约,旧 Agent 通过 404/405 映射升级提示,但接口演进仍需谨慎;
|
||||
- VPS 上的系统环境差异大,尤其是 Nginx 安装位置、systemd、宝塔环境与 PATH。
|
||||
|
||||
### 9.5 监控、健康与通知
|
||||
|
||||
调度器每 2 分钟执行 `monitor`:
|
||||
|
||||
- 本机 CPU、内存、磁盘;
|
||||
- 资源阈值;
|
||||
- 本机规则流量;
|
||||
- 公网规则 DNS / 证书 / 隧道 / 服务四段健康;
|
||||
- frps 服务端掉线;
|
||||
- Agent 不可达;
|
||||
- 公网 vhost 配置漂移。
|
||||
|
||||
通知渠道:
|
||||
|
||||
- SMTP 邮件;
|
||||
- Webhook;
|
||||
- Telegram;
|
||||
- Bark;
|
||||
- ntfy;
|
||||
- Gotify;
|
||||
- 企业微信;
|
||||
- 钉钉;
|
||||
- 飞书。
|
||||
|
||||
告警策略已具备:
|
||||
|
||||
- 连续失败阈值;
|
||||
- 恢复通知;
|
||||
- 资源告警冷却;
|
||||
- 配置漂移冷却;
|
||||
- 登录失败窗口统计。
|
||||
|
||||
### 9.6 指标、日志与流量
|
||||
|
||||
指标:
|
||||
|
||||
- 本机资源与每条公网规则流量写入 `metrics_samples`;
|
||||
- 查询时按桶取平均,前端支持 1 小时、24 小时、7 天趋势;
|
||||
- 避免每加一个指标就改表结构。
|
||||
|
||||
日志:
|
||||
|
||||
- `app.log`:应用系统日志;
|
||||
- `access.log`:本机 Nginx 访问日志;
|
||||
- `error.log`:本机 Nginx 错误日志;
|
||||
- `frpc.log`:FRP 客户端日志;
|
||||
- 日志中心支持分页、筛选和 SSE 实时访问/错误日志。
|
||||
|
||||
流量:
|
||||
|
||||
- `traffic.Collector` 跟踪 access log 增量;
|
||||
- 汇总到规则和域名;
|
||||
- 计算累计上下行、速率与当前连接数;
|
||||
- 与监控巡检共用主机维度聚合算法,减少口径漂移。
|
||||
|
||||
### 9.7 备份与恢复
|
||||
|
||||
能力:
|
||||
|
||||
- 手动导出;
|
||||
- 上传恢复;
|
||||
- 定时自动备份;
|
||||
- 服务端归档列表;
|
||||
- 下载、恢复、删除;
|
||||
- 恢复前校验归档;
|
||||
- 按保留份数剪枝;
|
||||
- 自动备份失败通知。
|
||||
|
||||
恢复会覆盖数据库与证书等关键数据,前端需要二次确认。由于 SQLite 正在运行,恢复后推荐重启 Fonu。
|
||||
|
||||
---
|
||||
|
||||
## 十、后台调度
|
||||
|
||||
`internal/app/app.go` 注册的任务:
|
||||
|
||||
| 任务 | 间隔 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| DDNS | 默认 5 分钟,可在设置中调整 | 检测公网 IP 并同步 DNS |
|
||||
| ACME | 24 小时 | 证书续期与到期提醒 |
|
||||
| 中国 IP 段 | 默认 24 小时,可在设置中调整 | 下载并生成 Nginx geo 配置 |
|
||||
| monitor | 2 分钟 | 资源、隧道、服务端、Agent、漂移巡检与指标采样 |
|
||||
| backup | 24 小时 | 自动备份并按策略保留 |
|
||||
|
||||
调度器还负责:
|
||||
|
||||
- 任务 panic 恢复;
|
||||
- 非正间隔跳过;
|
||||
- 应用退出时通过 Context 取消。
|
||||
|
||||
---
|
||||
|
||||
## 十一、部署与运维
|
||||
|
||||
### 11.1 推荐部署形态
|
||||
|
||||
飞牛 fnOS 推荐:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name fonu \
|
||||
--net=host \
|
||||
-v /vol1/docker/fonu-data:/data \
|
||||
--restart unless-stopped \
|
||||
fonu:local
|
||||
```
|
||||
|
||||
管理入口 `http://<NAS-IP>:6893`,默认反代端口 18080/9443,避免和系统 Nginx 抢占 80/443。
|
||||
|
||||
### 11.2 升级方式
|
||||
|
||||
三种方式:
|
||||
|
||||
1. 本地源码:`docker compose up -d --build`;
|
||||
2. 单文件编排:保留 `docker-compose.hub.yml`,重新执行 `docker compose up -d --build`;
|
||||
3. 原生安装:使用 `scripts/build-linux.ps1` 构建包,再由 `scripts/fonu-install.sh` 安装。
|
||||
|
||||
升级脚本:
|
||||
|
||||
- `scripts/upgrade.sh`:拉代码、构建、重建、健康检查,支持 `--no-pull` 和回滚;
|
||||
- `scripts/upgrade-hub.sh`:仅使用编排文件的部署升级;
|
||||
- `scripts/agent-install.sh`:安装 `fonu-agent`。
|
||||
|
||||
### 11.3 数据持久化
|
||||
|
||||
默认使用命名卷 `fonu-data`。如需绑定宿主机目录,在 `.env` 中设置:
|
||||
|
||||
```dotenv
|
||||
FONU_DATA_PATH=/vol1/docker/fonu-data
|
||||
```
|
||||
|
||||
Compose 约定以 `/` 或 `.` 开头的值解释为 bind mount,其余字符串仍是命名卷。
|
||||
|
||||
`FONU_SESSION_SECRET` 同时承担凭据解密,已有数据后不能随意替换,否则 DDNS、FRP、通知和 SSH 等密文可能无法解密。
|
||||
|
||||
---
|
||||
|
||||
## 十二、工程化、测试与质量门禁
|
||||
|
||||
### 12.1 CI
|
||||
|
||||
`.github/workflows/ci.yml` 已实现完整验证顺序:
|
||||
|
||||
1. Checkout;
|
||||
2. 安装 Node 22;
|
||||
3. `npm ci`;
|
||||
4. `npm run build`;
|
||||
5. `npm test`;
|
||||
6. 将 `web/dist` 同步到 `cmd/fonu/web/dist`;
|
||||
7. 安装 Go;
|
||||
8. `go vet ./...`;
|
||||
9. `go test ./... -count=1`;
|
||||
10. `go build ./...`。
|
||||
|
||||
顺序不可颠倒,因为 `cmd/fonu/main.go` 使用 `//go:embed all:web/dist`,干净检出时前端产物必须先生成。
|
||||
|
||||
当前 CI 尚未开启 `gofmt -l` 卡口,原因是仓库仍有历史未格式化文件。建议在集中执行一次 `gofmt -w` 后开启。
|
||||
|
||||
### 12.2 发布工作流
|
||||
|
||||
- `docker-release.yml`:tag 或手动触发,构建 amd64/arm64 镜像并生成多架构 manifest;
|
||||
- `github-release.yml`:根据 `CHANGELOG.md` 生成 GitHub Release。
|
||||
|
||||
### 12.3 测试覆盖
|
||||
|
||||
Go 测试分布较广,`nginx` 包测试最多,其次为 `frp`、`acme`、`api`。
|
||||
|
||||
当前没有测试文件的包:
|
||||
|
||||
- `internal/discovery`;
|
||||
- `internal/secret`;
|
||||
- `internal/service`;
|
||||
- `internal/settings`;
|
||||
- `internal/version`;
|
||||
- `cmd/fonu`;
|
||||
- `cmd/fonu-agent`。
|
||||
|
||||
优先级较高的补测:
|
||||
|
||||
1. `internal/service` 的规则保存、Nginx 自定义配置与回滚;
|
||||
2. `internal/settings` 的类型转换、默认值和迁移;
|
||||
3. `internal/secret` 的加解密与密钥错误提示;
|
||||
4. 路由与中间件测试,确保新增受保护接口不会漏鉴权;
|
||||
5. Agent `readLimitState`、`writeLimitState` 与配置回滚;
|
||||
6. 前端 API 客户端和轮询组合式函数。
|
||||
|
||||
### 12.4 本次验证
|
||||
|
||||
本次分析实际执行:
|
||||
|
||||
```bash
|
||||
go test ./... -count=1
|
||||
go vet ./...
|
||||
cd web && npm run typecheck
|
||||
cd web && npm test
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- Go:所有含测试包通过;
|
||||
- Go vet:无输出,退出码 0;
|
||||
- 前端类型检查:通过;
|
||||
- 前端测试:2 个测试文件、13 条测试全部通过。
|
||||
|
||||
### 12.5 格式化
|
||||
|
||||
仓库目前仍存在历史未格式化文件。本次只做过读取和测试,没有批量执行 `gofmt -w`,以免把格式化噪音混入用户当前未提交的改动。
|
||||
|
||||
---
|
||||
|
||||
## 十三、当前风险与技术债
|
||||
|
||||
### 13.1 功能与兼容性
|
||||
|
||||
1. **旧 `/api/frp` 客户端残留**
|
||||
- `web/src/api/client.ts` 仍定义 `getFRP`、`saveFRP`、`getFRPStatus`、`syncFRPDomains`、`getFRPLogs`;
|
||||
- `web/src/views/DdnsView.vue` 在 `init()` 中调用 `api.getFRP()`;
|
||||
- 后端当前只注册 `/api/frpmulti/*`,因此该请求失败后进入 `catch`,`frpEnabled` 被设为 `false`;
|
||||
- 影响:DDNS 页可能无法显示与 FRP 相关的提示;
|
||||
- 建议:删除旧类型与旧方法,或改为调用 `/api/frpmulti/status`。
|
||||
|
||||
2. **Agent 版本兼容需要长期维护**
|
||||
- 主程序通过 404/405 识别旧 Agent,并提示升级;
|
||||
- 新接口应继续沿用最低版本提示,避免前端只看到网络错误。
|
||||
|
||||
3. **宿主机与 Agent 的 Nginx 模板双份实现**
|
||||
- 一边是 `internal/nginx`,一边是 `internal/agent`;
|
||||
- 修改一处忘记另一处会造成本机与公网行为不一致;
|
||||
- 建议抽共享模板包,至少共享安全指令、限流、geo、WebSocket、TLS 片段。
|
||||
|
||||
### 13.2 安全
|
||||
|
||||
1. **初始管理员密码写入应用日志**
|
||||
- `auth.EnsureDefaultAdmin` 记录用户名和密码;
|
||||
- `logstore.appendSystemAttrs` 会把密码原样拼进系统日志;
|
||||
- `GET /api/logs/system` 可读取该系统日志;
|
||||
- 建议只在进程首次启动时输出到终端一次,不写入持久日志;或生成一次性初始密码文件并设置 0600。
|
||||
|
||||
2. **Agent 默认明文 HTTP 监听**
|
||||
- 默认 `:7700`,Bearer Token 直接在网络中传输;
|
||||
- 如果直接暴露公网,需要 HTTPS、内网限制或前置反向代理;
|
||||
- 建议默认绑定 `127.0.0.1`,由 Nginx 或隧道暴露,并把公网绑定作为显式选项。
|
||||
|
||||
3. **Agent Token 比较未使用常量时间**
|
||||
- Agent 使用字符串比较;
|
||||
- 建议使用 `crypto/subtle.ConstantTimeCompare`。
|
||||
|
||||
4. **Agent 限流状态反序列化失败会静默清空**
|
||||
- `readLimitState` 忽略 JSON 错误;
|
||||
- 后续写入可能删除 zone 文件;
|
||||
- 建议解析失败返回错误并保留原文件。
|
||||
|
||||
5. **备份上传仍允许 64MB**
|
||||
- 已有 `CreateTemp` 修复路径穿越;
|
||||
- 仍建议降低上限并增加频率限制。
|
||||
|
||||
### 13.3 性能与并发
|
||||
|
||||
1. **SQLite 单连接**
|
||||
- 当前实现简单可靠,但监控查询、日志、指标与 API 会竞争同一连接;
|
||||
- 可通过缩短事务、减少大查询、为高频读增加轻量缓存来缓解。
|
||||
|
||||
2. **前端大页面与高频轮询**
|
||||
- 多个页面存在 15 秒轮询,仪表盘流量轮询更快;
|
||||
- 轮询请求应加入 in-flight 互斥,并在页面隐藏时暂停;
|
||||
- 可以统一抽 `useVisibilityPolling`。
|
||||
|
||||
3. **巨型后端文件**
|
||||
- `internal/frp/service.go` 约 1805 行;
|
||||
- `internal/agent/server.go` 约 1551 行;
|
||||
- 建议按能力拆分文件,例如 rate limit、nginx template、frps install、route versions、diagnostics。
|
||||
|
||||
### 13.4 维护性
|
||||
|
||||
1. **前端样式与弹窗外壳重复**
|
||||
- 多个页面重复实现左列表、弹窗外壳和状态色;
|
||||
- 建议抽 `SidebarList.vue`、`ModalShell.vue` 和统一状态令牌。
|
||||
|
||||
2. **硬编码颜色仍需收敛**
|
||||
- `variables.css` 已有品牌令牌;
|
||||
- 页面中仍有大量直接色值与重复色板。
|
||||
|
||||
3. **旧文档时效**
|
||||
- `project-audit-2026-09-24.md` 基于 v1.2.6,其中很多 P0/P1 已实施;
|
||||
- 阅读旧审查时应以 CHANGELOG、当前源码和本文档为准。
|
||||
|
||||
4. **归档目录删除尚未提交**
|
||||
- `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 在工作树中已删除但未提交;
|
||||
- 分析时保持原样,没有回退或提交。
|
||||
|
||||
---
|
||||
|
||||
## 十四、建议改进路线
|
||||
|
||||
### P0:先处理明确问题
|
||||
|
||||
1. 修正 `DdnsView` 对旧 `/api/frp` 的调用,切到 `/api/frpmulti/status`。
|
||||
2. 初始管理员密码不再写入持久应用日志。
|
||||
3. Agent Token 比较改为常量时间。
|
||||
4. `readLimitState` 解析失败时拒绝覆盖原状态。
|
||||
5. 明确 Agent 默认监听策略,最好默认仅本机或要求 HTTPS。
|
||||
|
||||
### P1:降低核心模块风险
|
||||
|
||||
1. 抽共享 Nginx 模板包,统一本机与 Agent 的安全、限流、geo、TLS 逻辑。
|
||||
2. 拆分 `internal/frp/service.go` 与 `internal/agent/server.go`。
|
||||
3. 拆分 `ProxyView.vue`、`SettingsView.vue`、`AgentView.vue`。
|
||||
4. 为路由鉴权、配置回滚、Agent 版本兼容补测试。
|
||||
5. 前端轮询统一抽 composable,加入互斥与页面可见性控制。
|
||||
|
||||
### P2:工程质量提升
|
||||
|
||||
1. 全仓执行一次 `gofmt -w`,随后在 CI 中开启 `gofmt -l` 卡口。
|
||||
2. 清理旧 FRP 类型、旧 API 方法和未使用样式。
|
||||
3. 收敛颜色令牌与重复弹窗外壳。
|
||||
4. 增加前端 API 客户端测试与关键页面组件测试。
|
||||
5. 为 SQLite 高频查询建立基准,确认单连接是否满足长期指标数据。
|
||||
|
||||
### P3:能力增强
|
||||
|
||||
1. 加密迁移包:导出并导入含敏感凭据的完整实例。
|
||||
2. 审计日志:记录谁在何时修改了规则、服务端、证书和设置。
|
||||
3. 2FA / TOTP 与登录设备管理。
|
||||
4. SMART 磁盘健康与 UPS 状态。
|
||||
5. Prometheus `/metrics` 导出。
|
||||
6. 状态页事故记录与服务入口聚合页。
|
||||
|
||||
---
|
||||
|
||||
## 十五、关键源码索引
|
||||
|
||||
| 目标 | 文件 |
|
||||
| --- | --- |
|
||||
| 应用装配 | `internal/app/app.go` |
|
||||
| 配置 | `internal/config/config.go` |
|
||||
| 数据库迁移 | `internal/db/db.go`、`migrations/*.sql` |
|
||||
| HTTP 路由 | `internal/api/router.go` |
|
||||
| 认证中间件 | `internal/api/middleware.go` |
|
||||
| 登录与会话 | `internal/auth/auth.go`、`internal/api/auth_handler.go` |
|
||||
| 本机反代 | `internal/proxy/proxy.go`、`internal/service/nginx_config.go` |
|
||||
| Nginx 生成与重载 | `internal/nginx/*.go` |
|
||||
| DDNS | `internal/ddns/*.go` |
|
||||
| 证书 | `internal/acme/*.go`、`internal/certificate/*.go` |
|
||||
| FRP 主服务 | `internal/frp/service.go` |
|
||||
| FRP 模型 | `internal/frp/model.go` |
|
||||
| Agent 客户端 | `internal/frp/agent_client.go` |
|
||||
| 公网 Agent | `internal/agent/server.go`、`internal/agent/versions.go` |
|
||||
| 监控巡检 | `internal/monitor/monitor.go` |
|
||||
| 通知 | `internal/notify/*.go` |
|
||||
| 日志 | `internal/logstore/*.go` |
|
||||
| 流量 | `internal/traffic/collector.go` |
|
||||
| 指标 | `internal/metrics/*.go` |
|
||||
| 备份 | `internal/backup/*.go` |
|
||||
| 前端路由 | `web/src/router/index.ts` |
|
||||
| 前端 API | `web/src/api/client.ts`、`web/src/api/types.ts` |
|
||||
| 前端布局 | `web/src/layouts/AppLayout.vue` |
|
||||
| CI | `.github/workflows/ci.yml` |
|
||||
| 容器构建 | `Dockerfile`、`docker-compose*.yml` |
|
||||
|
||||
---
|
||||
|
||||
## 十六、总结
|
||||
|
||||
Fonu 已经从一个 Nginx 反代面板发展为覆盖「本地入口、DNS、证书、内网穿透、公网 Agent、监控告警、备份恢复」的 NAS 公网访问控制面。
|
||||
|
||||
它的优势在于:功能完整、模块边界总体清楚、迁移与部署链路成熟、关键配置有版本和回滚、自动化任务覆盖主要运维场景。
|
||||
|
||||
当前最需要做的不是继续横向增加功能,而是先把 FRP / Agent 这条最复杂的链路拆小,把旧接口残留和日志泄密等明确问题清掉,再补上路由鉴权、配置回滚和 Agent 兼容测试。这样后续增加迁移包、审计、2FA、Prometheus 等能力时,才不会把复杂度继续堆进两个超大服务文件。
|
||||
@@ -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`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
|
||||
如果示例页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。
|
||||
@@ -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 不应通过")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 内容应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 安装包的校验路径。
|
||||
func TestFileSHA256(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||
|
||||
@@ -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
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
)
|
||||
|
||||
@@ -48,11 +50,23 @@ import (
|
||||
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
|
||||
// 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 配置。
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
AllowRemote bool
|
||||
Token string
|
||||
NginxConfDir string
|
||||
NginxBinary string
|
||||
@@ -65,6 +79,7 @@ type Config struct {
|
||||
// Server 暴露 agent HTTP 服务。
|
||||
type Server struct {
|
||||
cfg Config
|
||||
listenLoopback bool
|
||||
mu sync.Mutex
|
||||
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
||||
metricsMu sync.Mutex
|
||||
@@ -75,6 +90,9 @@ type Server struct {
|
||||
nginxStatusMu sync.Mutex
|
||||
nginxStatusAt time.Time
|
||||
nginxStatus nginxProbe
|
||||
// 测试注入钩子;生产环境为 nil,走真实 nginx 校验与重载
|
||||
validateNginxFn func() error
|
||||
reloadNginxFn func() error
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) == "" {
|
||||
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
|
||||
@@ -161,35 +183,85 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
return nil, fmt.Errorf("create agent directory: %w", err)
|
||||
}
|
||||
}
|
||||
server := &Server{cfg: cfg}
|
||||
server := &Server{cfg: cfg, listenLoopback: loopback}
|
||||
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
|
||||
// 历史错误目录的缓存会被自动修正。
|
||||
server.preloadNginxConfDir()
|
||||
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 {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
|
||||
mux.HandleFunc("GET /api/v1/status", s.auth(s.status))
|
||||
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))
|
||||
for _, route := range agentRoutes(s) {
|
||||
mux.HandleFunc(route.Method+" "+route.Pattern, s.auth(route.Handler))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -206,7 +278,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
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"})
|
||||
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) {
|
||||
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)}
|
||||
nginxProbe := s.probeNginx()
|
||||
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": "fonu-agent",
|
||||
"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_conf_dir": s.cfg.NginxConfDir,
|
||||
"nginx_ok": nginxProbe.OK,
|
||||
@@ -661,7 +756,14 @@ func detectNginxConfDir(binary string) (string, error) {
|
||||
if nginx == "" {
|
||||
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()
|
||||
if err != nil && migrateVersionDirsFromNginxOutput(string(out)) {
|
||||
out, err = exec.Command(nginx, "-T").CombinedOutput()
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nginx -T 执行失败: %w", err)
|
||||
}
|
||||
@@ -695,6 +797,7 @@ func detectNginxConfDir(binary string) (string, error) {
|
||||
})
|
||||
var lastReason string
|
||||
for _, dir := range dirs {
|
||||
migrateLegacyVersionDirs(dir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
lastReason = fmt.Sprintf("%s: %v", dir, err)
|
||||
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()})
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
if err := s.reloadRouteConfig(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -1210,9 +1313,70 @@ func (s *Server) routePath(domain string) string {
|
||||
}
|
||||
|
||||
func (s *Server) validateNginx() error {
|
||||
migrateLegacyVersionDirs(s.cfg.NginxConfDir)
|
||||
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 {
|
||||
nginx := s.nginxPath()
|
||||
if nginx == "" {
|
||||
@@ -1389,14 +1553,10 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout)
|
||||
}
|
||||
// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
|
||||
if req.SecurityHeaders {
|
||||
b.WriteString(" add_header X-Content-Type-Options \"nosniff\" always;\n")
|
||||
b.WriteString(" add_header X-Frame-Options \"SAMEORIGIN\" always;\n")
|
||||
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")
|
||||
}
|
||||
}
|
||||
b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{
|
||||
Enabled: req.SecurityHeaders,
|
||||
HTTPS: tlsEnabled,
|
||||
}))
|
||||
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
|
||||
if req.RateLimitRate > 0 {
|
||||
b.WriteString(" limit_req_status 429;\n")
|
||||
@@ -1406,26 +1566,19 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
}
|
||||
if req.WebSocket {
|
||||
b.WriteString(" proxy_http_version 1.1;\n")
|
||||
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||||
b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
|
||||
b.WriteString(nginxtmpl.UpgradeHeaders(" ", "\"upgrade\""))
|
||||
}
|
||||
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
|
||||
// Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。
|
||||
var acl strings.Builder
|
||||
for _, ip := range req.AllowIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
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")
|
||||
// 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
|
||||
chinaDeny := ""
|
||||
if req.ChinaOnly {
|
||||
chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n"
|
||||
}
|
||||
aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{
|
||||
Allow: req.AllowIPs,
|
||||
Deny: req.DenyIPs,
|
||||
ChinaDeny: chinaDeny,
|
||||
}, " ")
|
||||
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
|
||||
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
|
||||
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))
|
||||
}
|
||||
}
|
||||
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
|
||||
var limitText strings.Builder
|
||||
if req.RateLimitRate > 0 {
|
||||
zone := zoneKey(req.Domain)
|
||||
if req.RateLimitBurst > 0 {
|
||||
fmt.Fprintf(&limitText, " limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst)
|
||||
} else {
|
||||
fmt.Fprintf(&limitText, " limit_req zone=%s_req nodelay;\n", zone)
|
||||
}
|
||||
}
|
||||
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)
|
||||
// 限流 / 连接数(zone 在 http 上下文定义)
|
||||
limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{
|
||||
Zone: nginxtmpl.ZoneName("fonu", req.Domain),
|
||||
Rate: req.RateLimitRate,
|
||||
Burst: req.RateLimitBurst,
|
||||
Conn: req.ConnLimitMax,
|
||||
}, " ")
|
||||
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)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -1464,16 +1607,7 @@ const agentStateDir = "/var/lib/fonu-agent"
|
||||
|
||||
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
|
||||
func zoneKey(domain string) string {
|
||||
var b strings.Builder
|
||||
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()
|
||||
return nginxtmpl.ZoneName("fonu", domain)
|
||||
}
|
||||
|
||||
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
|
||||
@@ -1502,14 +1636,29 @@ type routeLimit struct {
|
||||
Conn int `json:"conn,omitempty"`
|
||||
}
|
||||
|
||||
func readLimitState() map[string]routeLimit {
|
||||
state := map[string]routeLimit{}
|
||||
data, err := os.ReadFile(limitsStatePath())
|
||||
if err != nil {
|
||||
return state
|
||||
func readLimitState() (map[string]routeLimit, error) {
|
||||
return readLimitStateFile(limitsStatePath())
|
||||
}
|
||||
_ = 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 定义文件;无限流时删除该文件。
|
||||
@@ -1539,10 +1688,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
|
||||
item := state[domain]
|
||||
key := zoneKey(domain)
|
||||
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 {
|
||||
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)
|
||||
@@ -1550,7 +1699,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
|
||||
|
||||
// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
|
||||
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 {
|
||||
state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
|
||||
} else {
|
||||
@@ -1561,7 +1713,10 @@ func (s *Server) applyLimitState(req routeRequest) error {
|
||||
|
||||
// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -1571,12 +1726,11 @@ func (s *Server) clearLimitState(domain string) error {
|
||||
|
||||
// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
|
||||
func sslDirectives(certDir string, tls13Only bool) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem"))
|
||||
if tls13Only {
|
||||
b.WriteString(" ssl_protocols TLSv1.3;\n")
|
||||
}
|
||||
return b.String()
|
||||
return nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
|
||||
CertPath: filepath.Join(certDir, "fullchain.pem"),
|
||||
KeyPath: filepath.Join(certDir, "privkey.pem"),
|
||||
TLS13Only: tls13Only,
|
||||
})
|
||||
}
|
||||
|
||||
// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
if err := s.reloadRouteConfig(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (s *Server) applyRouteContent(path string, content []byte) error {
|
||||
if err := atomicWrite(path, content, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
if err := s.validateRouteConfig(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = atomicWrite(path, previous, 0o640)
|
||||
} else {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
@@ -32,8 +34,10 @@ type Deps struct {
|
||||
Metrics *metrics.Store
|
||||
Discovery *discovery.Service
|
||||
FRPMulti *frp.Service
|
||||
Cloudflare *cloudflared.Service
|
||||
Traffic *traffic.Collector
|
||||
Notify *notify.Service
|
||||
Updater *updater.Client
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
)
|
||||
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
|
||||
type FRPMultiHandler struct {
|
||||
svc *frp.Service
|
||||
auth *auth.Service
|
||||
}
|
||||
|
||||
type frpServerRequest struct {
|
||||
@@ -68,8 +71,8 @@ type frpDownloadProxyRequest struct {
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
|
||||
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
|
||||
return &FRPMultiHandler{svc: svc}
|
||||
func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler {
|
||||
return &FRPMultiHandler{svc: svc, auth: authSvc}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -74,7 +74,8 @@ func tokenDeniedPath(path string) bool {
|
||||
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||||
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 {
|
||||
|
||||
@@ -36,6 +36,8 @@ type proxyRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Remark *string `json:"remark"` // deprecated alias for name
|
||||
Security *securityRequest `json:"security,omitempty"`
|
||||
Exits []string `json:"exits"`
|
||||
CFTunnelID *int64 `json:"cf_tunnel_id"`
|
||||
}
|
||||
|
||||
type proxyReorderRequest struct {
|
||||
@@ -80,6 +82,8 @@ func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled: boolDefault(req.Enabled, true),
|
||||
Name: name,
|
||||
Security: req.securityInput(),
|
||||
Exits: req.Exits,
|
||||
CFTunnelID: int64Default(req.CFTunnelID, 0),
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -258,3 +268,10 @@ func boolDefault(v *bool, fallback bool) bool {
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func int64Default(v *int64, fallback int64) int64 {
|
||||
if v == nil {
|
||||
return fallback
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
+85
-14
@@ -10,39 +10,58 @@ import (
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
|
||||
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
|
||||
type routeSpec struct {
|
||||
Pattern string
|
||||
Handler http.HandlerFunc
|
||||
Public bool
|
||||
}
|
||||
|
||||
func NewRouter(deps Deps) http.Handler {
|
||||
handler, _ := newRouterWithSpecs(deps)
|
||||
return handler
|
||||
}
|
||||
|
||||
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
logsHandler := NewLogsHandler(deps.Config)
|
||||
r := &Router{
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||
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),
|
||||
certHandler: NewCertHandler(deps.ACME),
|
||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||
logsHandler: logsHandler,
|
||||
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
|
||||
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
|
||||
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
|
||||
metricsHandler: NewMetricsHandler(deps.Metrics),
|
||||
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,
|
||||
staticFS: deps.StaticFS,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
||||
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||
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))))
|
||||
var specs []routeSpec
|
||||
public := func(pattern string, handler http.HandlerFunc) {
|
||||
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
|
||||
}
|
||||
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("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/metrics/history", r.metricsHandler.History)
|
||||
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("POST /api/backup/restore", r.backupHandler.Restore)
|
||||
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/ssh-diagnose", frpHandler.AgentSSHDiagnose)
|
||||
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/routes", frpHandler.AgentRoutes)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
|
||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
@@ -190,6 +259,8 @@ type Router struct {
|
||||
metricsHandler *MetricsHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
cloudflareHandler *CloudflareHandler
|
||||
updateHandler *UpdateHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
@@ -26,13 +27,14 @@ type StatusHandler struct {
|
||||
proxySvc *service.ProxyService
|
||||
ddnsSvc *ddns.Service
|
||||
acmeSvc *acme.Service
|
||||
cloudflareSvc *cloudflared.Service
|
||||
startedAt string
|
||||
nginxPIDFile string
|
||||
cpuMu sync.Mutex
|
||||
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)
|
||||
_, sample := sysinfo.Read(sysinfo.CPUSample{})
|
||||
return &StatusHandler{
|
||||
@@ -40,6 +42,7 @@ func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc
|
||||
proxySvc: proxySvc,
|
||||
ddnsSvc: ddnsSvc,
|
||||
acmeSvc: acmeSvc,
|
||||
cloudflareSvc: cloudflareSvc,
|
||||
startedAt: startedAt,
|
||||
nginxPIDFile: nginxPIDFile,
|
||||
cpuSample: sample,
|
||||
@@ -71,6 +74,10 @@ type statusResponse struct {
|
||||
MemTotalBytes uint64 `json:"mem_total_bytes"`
|
||||
DiskUsedBytes uint64 `json:"disk_used_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"`
|
||||
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())
|
||||
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")
|
||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||
@@ -131,6 +142,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
MemTotalBytes: resources.MemTotalBytes,
|
||||
DiskUsedBytes: resources.DiskUsedBytes,
|
||||
DiskTotalBytes: resources.DiskTotalBytes,
|
||||
CloudflareCount: cfSummary.Count,
|
||||
CloudflareOnline: cfSummary.Online,
|
||||
CloudflareStatus: cfSummary.Status,
|
||||
CloudflareMessage: cfSummary.Message,
|
||||
StartedAt: h.startedAt,
|
||||
UptimeSeconds: int64(uptime),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
@@ -28,13 +30,15 @@ const (
|
||||
type StatusPageHandler struct {
|
||||
settings *settings.Store
|
||||
frp *frp.Service
|
||||
cloudflare *cloudflared.Service
|
||||
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{
|
||||
settings: settingsStore,
|
||||
frp: frpSvc,
|
||||
cloudflare: cloudflareSvc,
|
||||
limiter: newStatusPageLimiter(),
|
||||
}
|
||||
}
|
||||
@@ -42,9 +46,19 @@ func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *S
|
||||
type statusPageRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
State string `json:"state"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Uptime float64 `json:"uptime_pct"`
|
||||
DownMinutes int `json:"down_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 {
|
||||
@@ -70,7 +84,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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, "状态页未开启")
|
||||
return
|
||||
}
|
||||
@@ -84,17 +98,30 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := statusPagePayload{
|
||||
Title: "Fonu 服务状态",
|
||||
CodeRequired: codeRequired,
|
||||
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 {
|
||||
switch stat.State {
|
||||
@@ -108,12 +135,64 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||
Domain: stat.Domain,
|
||||
State: stat.State,
|
||||
Source: "frp",
|
||||
Uptime: stat.Uptime,
|
||||
DownMinutes: stat.DownMinutes,
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
@@ -41,6 +43,7 @@ type App struct {
|
||||
server *http.Server
|
||||
nginx *nginx.Manager
|
||||
frpMulti *frp.Service
|
||||
cloudflare *cloudflared.Service
|
||||
scheduler *scheduler.Scheduler
|
||||
shutdownFn context.CancelFunc
|
||||
db interface{ Close() error }
|
||||
@@ -111,8 +114,15 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
conn.Close()
|
||||
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)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
||||
|
||||
handler := api.NewRouter(api.Deps{
|
||||
Config: cfg,
|
||||
@@ -128,7 +138,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
Metrics: metricsStore,
|
||||
Discovery: discoverySvc,
|
||||
FRPMulti: frpMultiSvc,
|
||||
Cloudflare: cloudflareSvc,
|
||||
Traffic: trafficCollector,
|
||||
Updater: updaterClient,
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
@@ -143,6 +155,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
frpMultiSvc.SetBaseContext(ctx)
|
||||
go frpMultiSvc.StartAgentTunnels(ctx)
|
||||
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
|
||||
go cloudflareSvc.StartAll(ctx)
|
||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 5
|
||||
@@ -152,7 +167,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
chinaHours = 24
|
||||
}
|
||||
// 巡检间隔比其它任务短:「连续 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 内」避免频繁重启重复备份
|
||||
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
|
||||
sched := scheduler.New(
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
@@ -14,10 +15,60 @@ import (
|
||||
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
|
||||
type monitorSource struct {
|
||||
frp *frp.Service
|
||||
cloudflare *cloudflared.Service
|
||||
logger *slog.Logger
|
||||
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) {
|
||||
servers, err := s.enabledServers(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -59,7 +59,7 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
|
||||
|
||||
gw := gzip.NewWriter(file)
|
||||
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 {
|
||||
discard()
|
||||
return err
|
||||
|
||||
@@ -88,3 +88,43 @@ func TestRestoreAppliesFixedMode(t *testing.T) {
|
||||
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 隧道配置")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *Service) Update(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, err := s.proxyStore.ListEnabled(ctx)
|
||||
rules, err := s.proxyStore.ListEnabledLocal(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
NginxDefaultHTTPSPort int
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
UpdaterSocket string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -39,6 +40,7 @@ func Load() Config {
|
||||
NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ var catalog = []catalogEntry{
|
||||
{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
|
||||
{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
|
||||
{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 {
|
||||
|
||||
@@ -3,10 +3,16 @@ package frp
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -18,16 +24,107 @@ import (
|
||||
type AgentClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
pin string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
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), "/"),
|
||||
token: token,
|
||||
pin: strings.Join(normalized, ","),
|
||||
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
|
||||
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 {
|
||||
@@ -55,7 +152,10 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
var result map[string]any
|
||||
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
|
||||
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 版本
|
||||
const agentMinVersionInsights = "0.11.0"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,13 @@ func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, e
|
||||
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 急救:进程、端口、服务日志尾部。
|
||||
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'")
|
||||
@@ -123,6 +130,15 @@ mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
|
||||
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
|
||||
systemctl stop fonu-agent 2>/dev/null || true
|
||||
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"
|
||||
if [ ! -x "$TMP_PREFIX" ]; then
|
||||
echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
|
||||
@@ -144,6 +160,10 @@ if [ ! -f "$CONF_FILE" ]; then
|
||||
# 用 /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"
|
||||
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'
|
||||
[Unit]
|
||||
Description=Fonu FRPS management agent
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
@@ -87,6 +87,16 @@ type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
SSHConfigured bool `json:"ssh_configured"`
|
||||
MgmtEnabled bool `json:"mgmt_enabled"`
|
||||
|
||||
+625
-5
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -49,6 +50,9 @@ type Service struct {
|
||||
agentMu sync.Mutex
|
||||
agentClients map[int64]cachedAgentClient
|
||||
|
||||
tunnelMu sync.Mutex
|
||||
tunnels map[int64]*agentTunnel
|
||||
|
||||
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
|
||||
diagMu sync.Mutex
|
||||
diagCache map[int64]diagnosticsCacheEntry
|
||||
@@ -79,6 +83,7 @@ func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *
|
||||
trafficClient: newFRPSTrafficClient(),
|
||||
trafficLast: map[int64]trafficSample{},
|
||||
diagCache: map[int64]diagnosticsCacheEntry{},
|
||||
tunnels: map[int64]*agentTunnel{},
|
||||
}
|
||||
return service
|
||||
}
|
||||
@@ -356,6 +361,10 @@ func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
|
||||
server.LastDisconnectedAt = runtime.LastDisconnectedAt
|
||||
server.LastError = runtime.LastError
|
||||
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) {
|
||||
@@ -629,10 +638,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
|
||||
if !server.Enabled {
|
||||
_ = s.managerForServer(id).Stop(ctx)
|
||||
}
|
||||
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
|
||||
s.stopAgentTunnel(id)
|
||||
}
|
||||
return server, s.persistConfig(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteServer(ctx context.Context, id int64) error {
|
||||
s.stopAgentTunnel(id)
|
||||
if err := s.managerForServer(id).Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -732,10 +745,33 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
if err != nil {
|
||||
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{
|
||||
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
|
||||
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,
|
||||
SSHAuth: server.SSHAuth, MgmtEnabled: true,
|
||||
}
|
||||
@@ -743,6 +779,508 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -751,7 +1289,12 @@ func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData b
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -1305,9 +1848,53 @@ type cachedAgentClient struct {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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 == "" {
|
||||
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 握手
|
||||
fingerprint := server.AgentURL + "|" + encoded
|
||||
fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",")
|
||||
s.agentMu.Lock()
|
||||
defer s.agentMu.Unlock()
|
||||
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 {
|
||||
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}
|
||||
return client, true
|
||||
}
|
||||
@@ -1421,6 +2040,7 @@ func (s *Service) AutoStart(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Service) Stop(ctx context.Context) error {
|
||||
s.stopAgentTunnels()
|
||||
_ = s.ReconcileRuntime(ctx)
|
||||
servers, err := s.store.ListServers(ctx)
|
||||
if err != nil {
|
||||
|
||||
+71
-1
@@ -279,6 +279,72 @@ func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
|
||||
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) {
|
||||
var token string
|
||||
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(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),
|
||||
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
|
||||
FROM frp_servers`
|
||||
|
||||
@@ -439,7 +509,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
|
||||
var server Server
|
||||
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
|
||||
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 {
|
||||
return Server{}, err
|
||||
}
|
||||
|
||||
@@ -61,15 +61,62 @@ func Backup(path string) error {
|
||||
return WriteFileAtomic(path+".bak", data, 0o600)
|
||||
}
|
||||
|
||||
// VersionsDir 返回某文件的版本目录(<path>.versions),供列表 / 回滚读取。
|
||||
// VersionsDir 返回某文件的版本目录(.<name>.versions),供列表 / 回滚读取。
|
||||
// 目录名以点号开头,避免被 nginx 的 include <dir>/* 当成配置文件读取。
|
||||
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"
|
||||
}
|
||||
|
||||
// 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 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。
|
||||
// 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。
|
||||
func BackupVersioned(path string, keep int) (string, error) {
|
||||
if err := MigrateLegacyVersions(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
@@ -107,6 +154,9 @@ type VersionEntry struct {
|
||||
|
||||
// ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。
|
||||
func ListVersions(path string) ([]VersionEntry, error) {
|
||||
if err := MigrateLegacyVersions(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(VersionsDir(path))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -135,6 +185,9 @@ func ReadVersion(path, name string) ([]byte, error) {
|
||||
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
|
||||
return nil, fmt.Errorf("版本名无效")
|
||||
}
|
||||
if err := MigrateLegacyVersions(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(VersionsDir(path), name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
|
||||
@@ -198,3 +198,34 @@ func TestReadVersionRejectsTraversal(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,38 @@ type HealthRecorder interface {
|
||||
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 表示「用户要它运行」时才判定掉线。
|
||||
type ServerStatus struct {
|
||||
ID int64
|
||||
@@ -120,6 +152,7 @@ type Checker struct {
|
||||
driftAlerted map[int64]bool
|
||||
driftNotified map[int64]time.Time
|
||||
lastHealthPrune time.Time
|
||||
lastCFHealthPrune time.Time
|
||||
lastMetricsPrune time.Time
|
||||
}
|
||||
|
||||
@@ -151,6 +184,7 @@ func (c *Checker) Run(ctx context.Context) {
|
||||
c.checkServers(ctx)
|
||||
c.checkAgents(ctx)
|
||||
c.checkDrift(ctx)
|
||||
c.checkCloudflare(ctx)
|
||||
}
|
||||
|
||||
// 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 侧一致的结论 + 失败环节
|
||||
func healthStateOf(route RouteStatus) (string, string) {
|
||||
if !route.Known {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"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)
|
||||
block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames))
|
||||
writeCommentLine(&block, 4, "SSL 证书")
|
||||
block.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", absNginxPath(cert.CertPath)))
|
||||
block.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", absNginxPath(cert.KeyPath)))
|
||||
writeTLSProtocols(&block, rule.Security)
|
||||
writeTLSProtocolComment(&block, rule.Security)
|
||||
block.WriteString(nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
|
||||
CertPath: absNginxPath(cert.CertPath),
|
||||
KeyPath: absNginxPath(cert.KeyPath),
|
||||
TLS13Only: rule.Security.TLSMin13Only,
|
||||
}))
|
||||
block.WriteString("\n")
|
||||
writeServerSecurityHeaders(&block, rule.Security, true)
|
||||
writeErrorPages(&block, cfg)
|
||||
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-Port $server_port;
|
||||
proxy_set_header X-Real-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
%s
|
||||
proxy_buffering off;
|
||||
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) {
|
||||
|
||||
@@ -131,7 +131,7 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
|
||||
}
|
||||
|
||||
currentPath := m.cfg.NginxConfigPath()
|
||||
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚)
|
||||
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),隐藏的 .versions 目录保留最近 10 份(供版本对比与回滚)
|
||||
if err := fsutil.Backup(currentPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
|
||||
+29
-33
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -52,11 +53,11 @@ func writeLimitZones(b *strings.Builder, rules []proxy.Rule) {
|
||||
writeLimitZoneComments(b, rule)
|
||||
}
|
||||
if rule.Security.RateLimitEnabled() {
|
||||
rl := rule.Security.RateLimit
|
||||
b.WriteString(fmt.Sprintf(" limit_req_zone $binary_remote_addr zone=fonu_rule_%d_req:10m rate=%dr/s;\n", rule.ID, rl.Rate))
|
||||
zone := fmt.Sprintf("fonu_rule_%d_req", rule.ID)
|
||||
writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate))
|
||||
}
|
||||
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) {
|
||||
@@ -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 {
|
||||
for _, rule := range rules {
|
||||
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()
|
||||
if !sec.ChinaOnly {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if !opts.ChinaCIDRAvailable {
|
||||
b.WriteString(` if ($fonu_is_private = 0) {
|
||||
return ` if ($fonu_is_private = 0) {
|
||||
return 403;
|
||||
}
|
||||
`)
|
||||
return
|
||||
`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(` set $fonu_china_block 0;
|
||||
if ($fonu_is_china = 0) {
|
||||
set $fonu_china_block 1;
|
||||
@@ -163,6 +171,7 @@ func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptio
|
||||
return 403;
|
||||
}
|
||||
`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
|
||||
@@ -170,20 +179,8 @@ func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, ht
|
||||
return
|
||||
}
|
||||
writeSecurityHeadersComment(b)
|
||||
b.WriteString(` add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
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")
|
||||
}
|
||||
b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true}))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
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()
|
||||
writeLocationSecurityComments(b, rule, opts)
|
||||
|
||||
for _, cidr := range sec.IPBlacklist {
|
||||
b.WriteString(fmt.Sprintf(" deny %s;\n", cidr))
|
||||
acl := nginxtmpl.AccessControl{
|
||||
Deny: sec.IPBlacklist,
|
||||
ChinaDeny: chinaOnlyCheckDirectives(rule, opts),
|
||||
}
|
||||
|
||||
writeChinaOnlyCheck(b, rule, opts)
|
||||
|
||||
if sec.WhitelistEnabled() {
|
||||
for _, cidr := range sec.IPWhitelist {
|
||||
b.WriteString(fmt.Sprintf(" allow %s;\n", cidr))
|
||||
}
|
||||
b.WriteString(" deny all;\n")
|
||||
acl.Allow = sec.IPWhitelist
|
||||
}
|
||||
b.WriteString(nginxtmpl.AccessDirectives(acl, " "))
|
||||
|
||||
limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("fonu_rule_%d", rule.ID)}
|
||||
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() {
|
||||
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() {
|
||||
path := absNginxPath(HtpasswdPath(cfg, rule.ID))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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('_')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -3,14 +3,21 @@ package proxy
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
ExitLocal = "local"
|
||||
ExitCloudflare = "cloudflare"
|
||||
)
|
||||
|
||||
type Host struct {
|
||||
ID int64 `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -32,6 +39,9 @@ type Rule struct {
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -98,6 +108,34 @@ func (r Rule) Hostnames() []string {
|
||||
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 {
|
||||
groups := map[int][]string{}
|
||||
ports := make([]int, 0)
|
||||
@@ -130,6 +168,8 @@ type CreateInput struct {
|
||||
Enabled bool
|
||||
Name string
|
||||
Security SecurityInput
|
||||
Exits []string
|
||||
CFTunnelID int64
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
@@ -143,6 +183,8 @@ type UpdateInput struct {
|
||||
Enabled *bool
|
||||
Name *string
|
||||
Security *SecurityInput
|
||||
Exits *[]string
|
||||
CFTunnelID *int64
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -175,7 +217,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
|
||||
}
|
||||
q := s.querier()
|
||||
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
|
||||
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) {
|
||||
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 = ?
|
||||
`, id)
|
||||
rule, err := scanRule(row)
|
||||
@@ -243,11 +285,19 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
|
||||
if err != nil {
|
||||
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, `
|
||||
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON)
|
||||
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'))
|
||||
`, 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 {
|
||||
return Rule{}, err
|
||||
}
|
||||
@@ -278,6 +328,8 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
|
||||
enabled := current.Enabled
|
||||
name := current.Name
|
||||
hosts := current.Hosts
|
||||
exits := current.Exits
|
||||
cfTunnelID := current.CFTunnelID
|
||||
|
||||
if in.Upstream != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
hosts, err = parseHosts(*in.Hosts, listenPort)
|
||||
if err != nil {
|
||||
@@ -336,6 +402,10 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
exitsJSON, err := marshalExits(exits)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
|
||||
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, `
|
||||
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 = ?
|
||||
`, 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 {
|
||||
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) {
|
||||
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
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -445,6 +515,21 @@ func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
|
||||
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) {
|
||||
upstream, err := validate.Upstream(in.Upstream)
|
||||
if err != nil {
|
||||
@@ -654,6 +739,8 @@ func scanRule(row rowScanner) (Rule, error) {
|
||||
var enabled int
|
||||
var nginxMode string
|
||||
var securityJSON string
|
||||
var exitsJSON string
|
||||
var cfTunnelID int64
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := row.Scan(
|
||||
@@ -669,6 +756,8 @@ func scanRule(row rowScanner) (Rule, error) {
|
||||
&rule.Name,
|
||||
&rule.SortOrder,
|
||||
&securityJSON,
|
||||
&exitsJSON,
|
||||
&cfTunnelID,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
@@ -688,11 +777,67 @@ func scanRule(row rowScanner) (Rule, error) {
|
||||
return Rule{}, err
|
||||
}
|
||||
rule.Security = security
|
||||
rule.Exits = parseExits(exitsJSON)
|
||||
rule.CFTunnelID = cfTunnelID
|
||||
rule.CreatedAt = parseTime(createdAt)
|
||||
rule.UpdatedAt = parseTime(updatedAt)
|
||||
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 {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
rules, err := s.store.ListEnabled(ctx)
|
||||
rules, err := s.store.ListEnabledLocal(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
rules, err := s.store.ListEnabled(ctx)
|
||||
rules, err := s.store.ListEnabledLocal(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ type ProxyService struct {
|
||||
settings *settings.Store
|
||||
nginx *nginx.Manager
|
||||
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 {
|
||||
@@ -33,6 +39,10 @@ func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
|
||||
s.notify = notifySvc
|
||||
}
|
||||
|
||||
func (s *ProxyService) SetExitSync(sync ExitSync) {
|
||||
s.exitSync = sync
|
||||
}
|
||||
|
||||
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
|
||||
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) {
|
||||
if proxy.HasExit(in.Exits, proxy.ExitLocal) {
|
||||
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
|
||||
return proxy.Rule{}, err
|
||||
}
|
||||
}
|
||||
|
||||
rule, err := s.store.Create(ctx, in)
|
||||
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 {
|
||||
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
||||
}
|
||||
if err := s.syncExits(ctx); err != nil {
|
||||
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
@@ -69,9 +84,15 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
|
||||
if in.Hosts != nil {
|
||||
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 {
|
||||
return proxy.Rule{}, err
|
||||
}
|
||||
}
|
||||
|
||||
rule, err := s.store.Update(ctx, id, in)
|
||||
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 {
|
||||
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
||||
}
|
||||
if err := s.syncExits(ctx); err != nil {
|
||||
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
|
||||
}
|
||||
if err := s.syncExits(ctx); err != nil {
|
||||
return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,7 +137,7 @@ func (s *ProxyService) ReloadAll(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 {
|
||||
return err
|
||||
}
|
||||
@@ -124,17 +158,17 @@ func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, e
|
||||
replaced := false
|
||||
for _, r := range existing {
|
||||
if r.ID == candidate.ID {
|
||||
if candidate.Enabled {
|
||||
if candidate.LocalEnabled() {
|
||||
rules = append(rules, candidate)
|
||||
}
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
if r.Enabled {
|
||||
if r.LocalEnabled() {
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
if !replaced && candidate.Enabled {
|
||||
if !replaced && candidate.LocalEnabled() {
|
||||
rules = append(rules, candidate)
|
||||
}
|
||||
certs, err := s.loadCertSources(ctx)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user