From 83cc9d504b170087661b77fe467badf19b245197 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Thu, 1 Oct 2026 11:32:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(cloudflare):=20=E5=AE=8C=E6=88=90=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E6=8E=A5=E5=85=A5=E3=80=81=E8=BF=90=E7=BB=B4=E4=B8=8E?= =?UTF-8?q?=20P0=20=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 6 +- docs/cloudflared-current-features.md | 654 +++++++++++++++++ docs/cloudflared-github-ux-research.md | 576 +++++++++++++++ internal/api/cloudflare_handler.go | 142 +++- internal/api/proxy_handler.go | 17 + internal/api/router.go | 16 +- internal/api/status_handler.go | 47 +- internal/api/status_page_handler.go | 121 +++- internal/app/app.go | 5 +- internal/app/monitor_source.go | 57 +- internal/backup/service.go | 2 +- internal/backup/service_test.go | 40 ++ internal/chinacidr/service.go | 2 +- internal/cloudflared/binary.go | 68 +- internal/cloudflared/cloudflare_api.go | 79 +- internal/cloudflared/config.go | 32 + internal/cloudflared/config_test.go | 45 ++ internal/cloudflared/health.go | 377 ++++++++++ internal/cloudflared/health_test.go | 57 ++ internal/cloudflared/manager.go | 2 +- internal/cloudflared/manager_test.go | 7 + internal/cloudflared/model.go | 3 + internal/cloudflared/operations.go | 323 +++++++++ internal/cloudflared/operations_test.go | 28 + internal/cloudflared/routes.go | 185 +++++ internal/cloudflared/routes_test.go | 51 ++ internal/cloudflared/service.go | 339 ++++++++- internal/cloudflared/settings_test.go | 26 + internal/cloudflared/store.go | 92 ++- internal/discovery/service.go | 4 + internal/monitor/monitor.go | 128 +++- internal/proxy/exits_store_test.go | 55 ++ internal/proxy/exits_test.go | 39 + internal/proxy/proxy.go | 161 ++++- internal/service/nginx_config.go | 4 +- internal/service/proxy.go | 50 +- migrations/035_proxy_exits.sql | 19 + migrations/036_cf_health.sql | 13 + web/src/api/client.ts | 37 +- web/src/api/types.ts | 82 +++ web/src/layouts/AppLayout.vue | 2 +- web/src/router/index.ts | 7 + web/src/views/CloudflareTunnelConfigView.vue | 714 +++++++++++++++++++ web/src/views/CloudflareView.vue | 514 +++++++++++-- web/src/views/DashboardView.vue | 9 + web/src/views/LogsView.vue | 104 ++- web/src/views/ProxyView.vue | 218 +++++- web/src/views/StatusPageView.vue | 25 +- 48 files changed, 5383 insertions(+), 201 deletions(-) create mode 100644 docs/cloudflared-current-features.md create mode 100644 docs/cloudflared-github-ux-research.md create mode 100644 internal/cloudflared/health.go create mode 100644 internal/cloudflared/health_test.go create mode 100644 internal/cloudflared/operations.go create mode 100644 internal/cloudflared/operations_test.go create mode 100644 internal/cloudflared/routes.go create mode 100644 internal/cloudflared/routes_test.go create mode 100644 internal/proxy/exits_store_test.go create mode 100644 internal/proxy/exits_test.go create mode 100644 migrations/035_proxy_exits.sql create mode 100644 migrations/036_cf_health.sql create mode 100644 web/src/views/CloudflareTunnelConfigView.vue diff --git a/docs/README.md b/docs/README.md index 4ff18e5..122f1db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-30(v1.8.0) +> 最近更新:2026-10-01(v1.8.0) ## 方案与设计(本目录) @@ -13,7 +13,9 @@ | `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 优先) | +| `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 三项不做 | diff --git a/docs/cloudflared-current-features.md b/docs/cloudflared-current-features.md new file mode 100644 index 0000000..96db018 --- /dev/null +++ b/docs/cloudflared-current-features.md @@ -0,0 +1,654 @@ +# Cloudflare 隧道当前功能说明 + +> 日期:2026-09-30 +> 状态:阶段 1-3 主体已落地。本文描述**当前代码实际实现的能力**,不是早期规划稿。 +> 相关文档: +> - `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 + -> .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 隧道配置。 +- 漂移比较。 diff --git a/docs/cloudflared-github-ux-research.md b/docs/cloudflared-github-ux-research.md new file mode 100644 index 0000000..d9745ad --- /dev/null +++ b/docs/cloudflared-github-ux-research.md @@ -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 ` 生成临时公网地址。 +- 可加 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 管理“我的服务如何安全、稳定地访问”。 diff --git a/internal/api/cloudflare_handler.go b/internal/api/cloudflare_handler.go index e34a013..af7227e 100644 --- a/internal/api/cloudflare_handler.go +++ b/internal/api/cloudflare_handler.go @@ -101,6 +101,10 @@ 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 { @@ -158,6 +162,102 @@ func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) { 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(), @@ -225,13 +325,32 @@ func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) { func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) { var request struct { - Mirror string `json:"mirror"` + 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.DownloadBinary(r.Context(), request.Mirror) + 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 @@ -248,6 +367,25 @@ func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Req 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) == "" { diff --git a/internal/api/proxy_handler.go b/internal/api/proxy_handler.go index 156e4c6..9e046e4 100644 --- a/internal/api/proxy_handler.go +++ b/internal/api/proxy_handler.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go index bd9b847..3c5a573 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,14 +28,14 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { 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, deps.Auth), @@ -201,16 +201,28 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { 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 { diff --git a/internal/api/status_handler.go b/internal/api/status_handler.go index 7de34f3..c580a87 100644 --- a/internal/api/status_handler.go +++ b/internal/api/status_handler.go @@ -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" @@ -22,27 +23,29 @@ import ( ) type StatusHandler struct { - cfg config.Config - proxySvc *service.ProxyService - ddnsSvc *ddns.Service - acmeSvc *acme.Service - startedAt string - nginxPIDFile string - cpuMu sync.Mutex - cpuSample sysinfo.CPUSample + cfg config.Config + 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{ - cfg: cfg, - proxySvc: proxySvc, - ddnsSvc: ddnsSvc, - acmeSvc: acmeSvc, - startedAt: startedAt, - nginxPIDFile: nginxPIDFile, - cpuSample: sample, + cfg: cfg, + 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), }) diff --git a/internal/api/status_page_handler.go b/internal/api/status_page_handler.go index 1e7d7f0..470e1b4 100644 --- a/internal/api/status_page_handler.go +++ b/internal/api/status_page_handler.go @@ -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" @@ -26,25 +28,37 @@ const ( // 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址—— // 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。 type StatusPageHandler struct { - settings *settings.Store - frp *frp.Service - limiter *statusPageLimiter + 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, - limiter: newStatusPageLimiter(), + settings: settingsStore, + frp: frpSvc, + cloudflare: cloudflareSvc, + limiter: newStatusPageLimiter(), } } type statusPageRoute struct { - Domain string `json:"domain"` - State string `json:"state"` - Uptime float64 `json:"uptime_pct"` - DownMinutes int `json:"down_minutes"` - UnknownMinutes int `json:"unknown_minutes"` + 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 { diff --git a/internal/app/app.go b/internal/app/app.go index 99c235a..65196b1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -119,6 +119,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) 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) @@ -156,7 +157,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) frpMultiSvc.SetBaseContext(ctx) go frpMultiSvc.StartAgentTunnels(ctx) go frpMultiSvc.StartAgentCertificateRenewal(ctx) - go cloudflareSvc.StartAuto(ctx) + go cloudflareSvc.StartAll(ctx) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) if intervalMinutes <= 0 { intervalMinutes = 5 @@ -166,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( diff --git a/internal/app/monitor_source.go b/internal/app/monitor_source.go index f1c5673..411830d 100644 --- a/internal/app/monitor_source.go +++ b/internal/app/monitor_source.go @@ -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" @@ -13,9 +14,59 @@ import ( // monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」, // 怎么从 frp / agent 取是这里的事,两边因此互不依赖。 type monitorSource struct { - frp *frp.Service - logger *slog.Logger - traffic *traffic.Collector + 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) { diff --git a/internal/backup/service.go b/internal/backup/service.go index 2d75aae..5ac3a33 100644 --- a/internal/backup/service.go +++ b/internal/backup/service.go @@ -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 diff --git a/internal/backup/service_test.go b/internal/backup/service_test.go index fa69b4d..9755100 100644 --- a/internal/backup/service_test.go +++ b/internal/backup/service_test.go @@ -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 隧道配置") + } +} diff --git a/internal/chinacidr/service.go b/internal/chinacidr/service.go index 2d31f6a..20b8826 100644 --- a/internal/chinacidr/service.go +++ b/internal/chinacidr/service.go @@ -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 } diff --git a/internal/cloudflared/binary.go b/internal/cloudflared/binary.go index f0c8e7b..ef7626e 100644 --- a/internal/cloudflared/binary.go +++ b/internal/cloudflared/binary.go @@ -2,6 +2,7 @@ package cloudflared import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -86,6 +87,11 @@ func (s *Service) BinaryVersion() string { // 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 { @@ -93,7 +99,7 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, } tmp := target + ".download" _ = os.Remove(tmp) - if err := fetchFile(ctx, DownloadURL(mirror, ""), tmp); err != nil { + if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil { _ = os.Remove(tmp) return "", err } @@ -109,13 +115,73 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, _ = 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) diff --git a/internal/cloudflared/cloudflare_api.go b/internal/cloudflared/cloudflare_api.go index 4468043..e8f84b6 100644 --- a/internal/cloudflared/cloudflare_api.go +++ b/internal/cloudflared/cloudflare_api.go @@ -144,6 +144,19 @@ func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, 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) @@ -155,6 +168,15 @@ type Zone struct { 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, "."), ".") @@ -168,23 +190,66 @@ func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, return zones[0].ID, zones[0].Name, nil } } - return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认 API Token 具备 Zone:Read 权限", hostname) + 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) error { +// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。 +// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。 +func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) { var records []struct { - ID string `json:"id"` + 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 + return "", err } payload := map[string]any{ "type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1, } if len(records) > 0 { - return a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil) + 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 } - return a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, 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 } diff --git a/internal/cloudflared/config.go b/internal/cloudflared/config.go index ec322d3..7f70330 100644 --- a/internal/cloudflared/config.go +++ b/internal/cloudflared/config.go @@ -6,6 +6,8 @@ import ( "fmt" "sort" "strings" + + "github.com/fonu/fonu/internal/proxy" ) type tokenPayload struct { @@ -86,6 +88,9 @@ func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress [] 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 } @@ -144,6 +149,33 @@ func BuildIngress(hostnames []string, service string) []IngressRule { 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) diff --git a/internal/cloudflared/config_test.go b/internal/cloudflared/config_test.go index 04aee21..51f97c2 100644 --- a/internal/cloudflared/config_test.go +++ b/internal/cloudflared/config_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "strings" "testing" + + "github.com/fonu/fonu/internal/proxy" ) func TestDecodeToken(t *testing.T) { @@ -68,6 +70,49 @@ func TestBuildIngressInvariants(t *testing.T) { } } +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{ diff --git a/internal/cloudflared/health.go b/internal/cloudflared/health.go new file mode 100644 index 0000000..957867c --- /dev/null +++ b/internal/cloudflared/health.go @@ -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 +} diff --git a/internal/cloudflared/health_test.go b/internal/cloudflared/health_test.go new file mode 100644 index 0000000..05a40c1 --- /dev/null +++ b/internal/cloudflared/health_test.go @@ -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) + } +} diff --git a/internal/cloudflared/manager.go b/internal/cloudflared/manager.go index cd98747..81df669 100644 --- a/internal/cloudflared/manager.go +++ b/internal/cloudflared/manager.go @@ -18,7 +18,7 @@ import ( "github.com/fonu/fonu/internal/config" ) -var metricsPortPattern = regexp.MustCompile(`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`) +var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`) type processState struct { cmd *exec.Cmd diff --git a/internal/cloudflared/manager_test.go b/internal/cloudflared/manager_test.go index 2022df2..5129d9c 100644 --- a/internal/cloudflared/manager_test.go +++ b/internal/cloudflared/manager_test.go @@ -41,6 +41,13 @@ func TestParseMetricsHTTP2(t *testing.T) { } } +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"}) diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go index 4c036d0..d071ab9 100644 --- a/internal/cloudflared/model.go +++ b/internal/cloudflared/model.go @@ -33,6 +33,7 @@ func DefaultNetworkSettings() NetworkSettings { // 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"` } @@ -53,6 +54,7 @@ type Tunnel struct { 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"` @@ -69,6 +71,7 @@ type CreateInput struct { OriginService string `json:"origin_service"` Network NetworkSettings `json:"network"` AutoStart bool `json:"auto_start"` + Managed bool `json:"managed"` } // AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。 diff --git a/internal/cloudflared/operations.go b/internal/cloudflared/operations.go new file mode 100644 index 0000000..2548e46 --- /dev/null +++ b/internal/cloudflared/operations.go @@ -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 +} diff --git a/internal/cloudflared/operations_test.go b/internal/cloudflared/operations_test.go new file mode 100644 index 0000000..b1f0892 --- /dev/null +++ b/internal/cloudflared/operations_test.go @@ -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) + } +} diff --git a/internal/cloudflared/routes.go b/internal/cloudflared/routes.go new file mode 100644 index 0000000..647a722 --- /dev/null +++ b/internal/cloudflared/routes.go @@ -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 +} diff --git a/internal/cloudflared/routes_test.go b/internal/cloudflared/routes_test.go new file mode 100644 index 0000000..c5a5558 --- /dev/null +++ b/internal/cloudflared/routes_test.go @@ -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) + } +} diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index a97918d..f62bc24 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -102,6 +102,9 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) { 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 @@ -120,8 +123,15 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) { _ = 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.ensureTunnelDNS(ctx, api, decodedID, in.Hostnames); err != nil { + 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() } @@ -134,6 +144,11 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, 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) @@ -141,24 +156,31 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, 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 } - dnsWarning := "" - if current.Mode == ModeAccountLocal && current.TunnelID != "" { - if api, err := s.cloudflareAPI(ctx); err == nil { - _ = api.PutIngress(ctx, current.TunnelID, ingress) - if err := s.ensureTunnelDNS(ctx, api, current.TunnelID, in.Hostnames); err != nil { - s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", current.TunnelID, "error", err.Error()) - dnsWarning = "DNS 记录同步失败:" + err.Error() - } - } - } 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 } @@ -168,9 +190,13 @@ func (s *Service) Delete(ctx context.Context, id int64) error { 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) } } @@ -180,6 +206,23 @@ func (s *Service) Delete(ctx context.Context, id int64) error { 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 { @@ -223,6 +266,40 @@ func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) { 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 @@ -301,7 +378,7 @@ func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, er return AppSettings{}, err } if enabled { - s.StartAuto(ctx) + s.StartAll(ctx) } else { s.StopAll(ctx) } @@ -353,15 +430,131 @@ func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) { return NewCloudflareAPI(accountID, token), nil } -// ensureTunnelDNS 为所有 hostname 幂等创建或更新指向隧道的 CNAME 记录。 -func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunnelID string, hostnames []string) error { - target := tunnelID + ".cfargotunnel.com" +// 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 _, raw := range hostnames { - hostname := strings.ToLower(strings.TrimSpace(raw)) - if hostname == "" { + 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 { @@ -369,13 +562,71 @@ func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunne } continue } - if err := api.EnsureCNAME(ctx, zoneID, hostname, target); err != nil && firstErr == nil { + 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) { @@ -408,6 +659,28 @@ func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (st 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) @@ -504,6 +777,28 @@ func (s *Service) StartAuto(ctx context.Context) { } } +// 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 == '.' { @@ -523,5 +818,11 @@ func validateInput(in CreateInput) error { 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 } diff --git a/internal/cloudflared/settings_test.go b/internal/cloudflared/settings_test.go index 892a8d7..3c2130c 100644 --- a/internal/cloudflared/settings_test.go +++ b/internal/cloudflared/settings_test.go @@ -74,3 +74,29 @@ func TestSetEnabledBlocksStart(t *testing.T) { 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) + } +} diff --git a/internal/cloudflared/store.go b/internal/cloudflared/store.go index 0ccd94f..8d8bdc7 100644 --- a/internal/cloudflared/store.go +++ b/internal/cloudflared/store.go @@ -23,7 +23,7 @@ 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), created_at, updated_at + 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) { @@ -55,10 +55,10 @@ func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag 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, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, datetime('now')) + 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)) + configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed)) if err != nil { return Tunnel{}, mapUniqueName(err) } @@ -72,10 +72,10 @@ func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, 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 = ?, updated_at = datetime('now') + 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), id) + configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id) if err != nil { return Tunnel{}, mapUniqueName(err) } @@ -95,6 +95,13 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri 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 @@ -105,6 +112,74 @@ type settingsRow struct { 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 @@ -137,14 +212,15 @@ func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) { var item Tunnel var networkJSON, created, updated string - var autoStart int + 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, &created, &updated) + &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) diff --git a/internal/discovery/service.go b/internal/discovery/service.go index fc165de..aea5a4d 100644 --- a/internal/discovery/service.go +++ b/internal/discovery/service.go @@ -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 { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 0d24686..5ec5461 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -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 @@ -113,14 +145,15 @@ type Checker struct { health HealthRecorder metrics MetricsRecorder - mu sync.Mutex - cpuPrev sysinfo.CPUSample - resourceAlerts map[string]time.Time - failures map[string]int - driftAlerted map[int64]bool - driftNotified map[int64]time.Time - lastHealthPrune time.Time - lastMetricsPrune time.Time + mu sync.Mutex + cpuPrev sysinfo.CPUSample + resourceAlerts map[string]time.Time + failures map[string]int + driftAlerted map[int64]bool + driftNotified map[int64]time.Time + lastHealthPrune time.Time + lastCFHealthPrune time.Time + lastMetricsPrune time.Time } func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source, health HealthRecorder, metricsRecorder MetricsRecorder) *Checker { @@ -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 { diff --git a/internal/proxy/exits_store_test.go b/internal/proxy/exits_store_test.go new file mode 100644 index 0000000..9cc1a38 --- /dev/null +++ b/internal/proxy/exits_store_test.go @@ -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 } diff --git a/internal/proxy/exits_test.go b/internal/proxy/exits_test.go new file mode 100644 index 0000000..e726678 --- /dev/null +++ b/internal/proxy/exits_test.go @@ -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") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e02dd05..3f74672 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -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 { diff --git a/internal/service/nginx_config.go b/internal/service/nginx_config.go index 40ff329..0814761 100644 --- a/internal/service/nginx_config.go +++ b/internal/service/nginx_config.go @@ -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 } diff --git a/internal/service/proxy.go b/internal/service/proxy.go index e017e4c..fe9573c 100644 --- a/internal/service/proxy.go +++ b/internal/service/proxy.go @@ -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,8 +52,10 @@ 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 err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil { - return proxy.Rule{}, err + 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) @@ -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,8 +84,14 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu if in.Hosts != nil { hosts = *in.Hosts } - if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil { - return proxy.Rule{}, err + 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) @@ -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) diff --git a/migrations/035_proxy_exits.sql b/migrations/035_proxy_exits.sql new file mode 100644 index 0000000..420bd2f --- /dev/null +++ b/migrations/035_proxy_exits.sql @@ -0,0 +1,19 @@ +-- 服务发布规则出口:默认仍走本机 Nginx,保持旧数据行为不变。 +ALTER TABLE proxy_rules ADD COLUMN exits_json TEXT NOT NULL DEFAULT '["local"]'; +ALTER TABLE proxy_rules ADD COLUMN cf_tunnel_id INTEGER NOT NULL DEFAULT 0; + +-- 托管隧道标记:只有托管隧道才从反向代理规则派生 ingress,避免覆盖手工隧道。 +ALTER TABLE cf_tunnels ADD COLUMN managed INTEGER NOT NULL DEFAULT 0; + +-- 记录 Fonu 自动创建或接管的 Cloudflare DNS 记录,删除时只清理自己管理的记录。 +CREATE TABLE IF NOT EXISTS cf_dns_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tunnel_id INTEGER NOT NULL, + zone_id TEXT NOT NULL, + record_id TEXT NOT NULL, + hostname TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_cf_dns_records_tunnel ON cf_dns_records(tunnel_id); diff --git a/migrations/036_cf_health.sql b/migrations/036_cf_health.sql new file mode 100644 index 0000000..5c4b66b --- /dev/null +++ b/migrations/036_cf_health.sql @@ -0,0 +1,13 @@ +-- Cloudflare 隧道健康事件:只记录状态变化,供巡检通知与公开状态页计算 24h 可用率。 +CREATE TABLE IF NOT EXISTS cf_health_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tunnel_id INTEGER NOT NULL, + domain TEXT NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_cf_health_events_lookup ON cf_health_events(tunnel_id, domain, id); +CREATE INDEX IF NOT EXISTS idx_cf_health_events_at ON cf_health_events(at); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bba34aa..22dfdee 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,6 +43,13 @@ import type { CfTunnelPayload, CfAppSettings, CfAppSettingsInput, + CfDriftResult, + CfDiagnosticsResult, + CfAccessStatus, + CfZone, + CfTunnelRoutes, + CfPreflightResult, + CfRouteTestResult, } from './types' export function asList(value: T[] | null | undefined): T[] { @@ -495,20 +502,46 @@ export const api = { request(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }), restartCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }), + refreshCfCredentials: (id: number) => + request(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }), getCfTunnelStatus: (id: number) => request(`/api/cloudflare/tunnels/${id}/status`), getCfTunnelLogs: (id: number, lines = 200) => request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`), getCfTunnelConfig: (id: number) => request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`), + getCfTunnelRoutes: (id: number) => + request(`/api/cloudflare/tunnels/${id}/routes`), + replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) => + request(`/api/cloudflare/tunnels/${id}/routes`, { + method: 'PUT', + body: JSON.stringify({ routes }), + }), + getCfTunnelDrift: (id: number) => request(`/api/cloudflare/tunnels/${id}/drift`), + syncCfTunnelRoutes: (id: number) => + request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }), + getCfTunnelDiagnostics: (id: number) => + request(`/api/cloudflare/tunnels/${id}/diagnostics`), getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'), - downloadCfBinary: (mirror: string) => + downloadCfBinary: (mirror: string, version = '') => request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', { method: 'POST', - body: JSON.stringify({ mirror }), + body: JSON.stringify({ mirror, version }), }), + getLatestCfBinary: () => request<{ version: string }>('/api/cloudflare/binary/latest'), + rollbackCfBinary: () => + request<{ ok: boolean; version: string }>('/api/cloudflare/binary/rollback', { method: 'POST' }), getCfIngressCandidates: () => request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'), + getCfZones: () => request<{ zones: CfZone[] }>('/api/cloudflare/zones'), + getCfAccessStatus: (hostname: string) => + request(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`), + getCfPreflight: () => request('/api/cloudflare/preflight'), + testCfRoute: (service: string) => + request('/api/cloudflare/routes/test', { + method: 'POST', + body: JSON.stringify({ service }), + }), getCfSettings: () => request('/api/cloudflare/settings'), saveCfSettings: (payload: CfAppSettingsInput) => request('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 043b9a1..dce2d67 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -50,6 +50,9 @@ export interface ProxyRule { name: string sort_order: number security: ProxySecurityConfig + exits: string[] + cf_tunnel_id: number + sync_warning?: string created_at: string updated_at: string } @@ -108,6 +111,8 @@ export interface ProxySavePayload { enabled?: boolean name?: string security?: ProxySecurityPayload + exits?: string[] + cf_tunnel_id?: number } export interface AuthStatus { @@ -165,6 +170,10 @@ export interface DashboardStatus { mem_total_bytes: number disk_used_bytes: number disk_total_bytes: number + cloudflare_count: number + cloudflare_online: number + cloudflare_status: string + cloudflare_message?: string } export interface DDNSDomainRecord { @@ -1024,9 +1033,19 @@ export interface MetricsHistory { export interface StatusPageRoute { domain: string state: string + source?: 'frp' | 'cloudflare' | string uptime_pct: number down_minutes: number unknown_minutes: number + incidents: StatusPageIncident[] +} + +export interface StatusPageIncident { + from: string + to?: string + reason?: string + detail?: string + minutes: number } export interface StatusPagePayload { @@ -1064,6 +1083,7 @@ export interface CfTunnel { last_error?: string metrics_port?: number auto_start: boolean + managed: boolean hostnames?: string[] origin_service?: string dns_warning?: string @@ -1090,6 +1110,11 @@ export interface CfMirror { base: string } +export interface CfZone { + id: string + name: string +} + export interface CfTunnelPayload { name: string mode: string @@ -1098,6 +1123,7 @@ export interface CfTunnelPayload { origin_service?: string network: CfNetworkSettings auto_start: boolean + managed: boolean } export interface CfAppSettings { @@ -1119,3 +1145,59 @@ export interface CfAppSettingsInput { mirror: string network: CfNetworkSettings } + +export interface CfDriftResult { + tunnel_id: number + managed: boolean + has_drift: boolean + items: string[] +} + +export interface CfRouteView { + id?: number + hostname: string + path: string + service: string + source: 'rule' | 'config' | string +} + +export interface CfTunnelRoutes { + managed: boolean + routes: CfRouteView[] + catch_all: string +} + +export interface CfDiagnosticCheck { + name: string + status: 'ok' | 'warning' | 'error' | string + message: string +} + +export interface CfDiagnosticsResult { + tunnel_id: number + generated_at: string + checks: CfDiagnosticCheck[] +} + +export interface CfPreflightCheck { + name: string + status: 'ok' | 'error' | string + message: string + action?: string +} + +export interface CfPreflightResult { + ready: boolean + checks: CfPreflightCheck[] +} + +export interface CfRouteTestResult { + ok: boolean + message: string + latency_ms: number +} + +export interface CfAccessStatus { + enabled: boolean + name?: string +} diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 2df2b46..7ef4ce4 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -201,8 +201,8 @@ const menuDefs = [ { label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline }, { label: '内网穿透', key: 'frp', icon: GitNetworkOutline }, { label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline }, - { label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline }, { label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline }, + { label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline }, { label: '日志中心', key: 'logs', icon: DocumentTextOutline }, { label: '系统设置', key: 'settings', icon: CogOutline }, { label: '示例页面', key: 'example', icon: FlashOutline }, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index eb1fb9b..2bfc0fb 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -10,6 +10,7 @@ import LogsView from '../views/LogsView.vue' import SettingsView from '../views/SettingsView.vue' import FrpView from '../views/FrpView.vue' import CloudflareView from '../views/CloudflareView.vue' +import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue' import AgentView from '../views/AgentView.vue' import ProxyRemoteView from '../views/ProxyRemoteView.vue' import StatusPageView from '../views/StatusPageView.vue' @@ -70,6 +71,12 @@ const router = createRouter({ component: CloudflareView, meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' }, }, + { + path: 'cloudflare/:id/config', + name: 'cloudflare-tunnel-config', + component: CloudflareTunnelConfigView, + meta: { title: 'Cloudflare 隧道配置', description: '配置隧道、查看副本指标与已发布路由' }, + }, { path: 'public-proxy', name: 'public-proxy', diff --git a/web/src/views/CloudflareTunnelConfigView.vue b/web/src/views/CloudflareTunnelConfigView.vue new file mode 100644 index 0000000..35d68c8 --- /dev/null +++ b/web/src/views/CloudflareTunnelConfigView.vue @@ -0,0 +1,714 @@ + + + + + diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index 8003c59..b2d6a79 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -1,6 +1,7 @@