feat(cloudflare): 完成隧道接入、运维与 P0 体验优化

This commit is contained in:
2026-10-01 11:32:54 +08:00
parent 36d8b1e464
commit 83cc9d504b
48 changed files with 5383 additions and 201 deletions
+4 -2
View File
@@ -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 三项不做 |
+654
View File
@@ -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
<hostname> -> <tunnel-id>.cfargotunnel.com
```
- 开启 Cloudflare 代理(橙云)。
- 记录 Fonu 创建或接管的 DNS 记录:
- `cf_dns_records`
- 删除域名或移除出口时:
- 只删除 Fonu 自己接管的记录。
- 不覆盖指向其他目标的用户 DNS 记录。
- 域名从一条隧道迁移到另一条隧道时支持接管迁移。
---
## 八、状态、指标与日志
实现文件:
- `internal/cloudflared/manager.go`
- `internal/cloudflared/health.go`
已实现:
- 从 cloudflared metrics 端口读取:
- HA 连接数。
- 延迟。
- 上下行速率。
- 传输协议。
- 隧道列表实时状态。
- 配置页指标卡。
- 日志中心新增“Cloudflare 隧道”页签:
- 选择隧道。
- 查看 200 / 500 / 2000 行日志。
- 关键词筛选。
---
## 九、健康巡检、通知与状态页
迁移:
- `migrations/036_cf_health.sql`
实现文件:
- `internal/cloudflared/health.go`
- `internal/monitor/monitor.go`
- `internal/app/monitor_source.go`
已实现:
- 记录 Cloudflare 域名健康状态变化:
- `up`
- `down`
- `unknown`
- 24 小时可用率。
- 不可用区间。
- 巡检复用现有:
- 路由健康开关。
- 隧道断开通知开关。
- 连续失败阈值。
- 连续不通和恢复复用通知事件 `tunnel_down`。
- 公开状态页合并:
- FRP 路由。
- Cloudflare 路由。
- 状态页展示每条路由的不可用区间:
- 开始时间。
- 持续分钟数。
- 原因。
- 状态页只显示域名、来源、状态和可用率,不泄露隧道 ID 与内网地址。
---
## 十、仪表盘
实现文件:
- `internal/api/status_handler.go`
- `web/src/views/DashboardView.vue`
已实现:
- `/api/status` 增加:
- Cloudflare 隧道总数。
- 在线数。
- 状态。
- 摘要消息。
- 仪表盘健康列表增加“Cloudflare 隧道”条目。
---
## 十一、备份与恢复
实现文件:
- `internal/backup/service.go`
已实现:
- 备份内容包含:
- `fonu.db`
- `certs`
- `nginx`
- `cloudflared/tunnels`
- `cloudflared/tunnels` 包含:
- `config.yml`
- `creds.json`
- 数据库中的 `cf_tunnels`、`cf_settings`、`cf_dns_records` 随 `fonu.db` 恢复。
- 恢复后仍建议重启 Fonu,由启动流程恢复全部隧道。
---
## 十二、漂移检测与诊断
实现文件:
- `internal/cloudflared/operations.go`
已实现:
### 12.1 漂移检测
- 比较本地规则派生 ingress 与 Cloudflare 云端 ingress。
- 检出:
- 本地有、云端缺失。
- 云端有、本地已移除。
- 回源地址不一致。
- 支持“以本地覆盖”:
```text
POST /api/cloudflare/tunnels/{id}/sync
```
- 当前未实现“以云端为准拉取”。
### 12.2 一键诊断
检查项:
- cloudflared 二进制。
- 本地 `config.yml`。
- 隧道进程状态。
- Cloudflare API 与云端配置可访问性。
- Fonu 接管的 DNS 记录。
- Cloudflare edge region1。
- Cloudflare edge region2。
---
## 十三、NAS 场景能力
实现文件:
- `internal/discovery/service.go`
- `web/src/views/ProxyView.vue`
已实现:
- 服务发现目录包含:
- 飞牛 fnOS
- AList
- Jellyfin
- PhotoPrism
- qBittorrent
- Portainer
- Home Assistant
- Immich
- AdGuard Home
- Synology DSM
- 反向代理页“扫描服务”打开发现列表。
- 点击“发布”会:
- 预填服务名称。
- 预填内网 upstream。
- 若已有托管隧道,自动选择 Cloudflare 出口。
- 打开规则编辑表单等待填写域名。
当前不是完全一键创建,仍需要用户填写域名并确认保存。
---
## 十四、安全与保护提示
已实现:
- Cloudflare Tunnel 条款提示:
- 不适合持续大文件/视频分发。
- 仅 Cloudflare 出口时:
- 提示无需申请本机 HTTPS 证书。
- Access 只读检测:
- 检测域名是否已有 Access 应用。
- 不代配 Access 策略。
- API Token 权限提示:
- `Account: Cloudflare Tunnel: Edit`
- `Zone: Zone: Read`
- `Zone: DNS: Edit`
- Zone Resources 必须包含待发布根域名。
---
## 十五、当前 API
### 隧道
```text
GET /api/cloudflare/tunnels
POST /api/cloudflare/tunnels
GET /api/cloudflare/tunnels/{id}
PUT /api/cloudflare/tunnels/{id}
DELETE /api/cloudflare/tunnels/{id}
POST /api/cloudflare/tunnels/{id}/start
POST /api/cloudflare/tunnels/{id}/stop
POST /api/cloudflare/tunnels/{id}/restart
POST /api/cloudflare/tunnels/{id}/credentials/refresh
GET /api/cloudflare/tunnels/{id}/status
GET /api/cloudflare/tunnels/{id}/logs
GET /api/cloudflare/tunnels/{id}/config
GET /api/cloudflare/tunnels/{id}/routes
PUT /api/cloudflare/tunnels/{id}/routes
POST /api/cloudflare/routes/test
GET /api/cloudflare/tunnels/{id}/drift
POST /api/cloudflare/tunnels/{id}/sync
GET /api/cloudflare/tunnels/{id}/diagnostics
```
### 二进制与配置
```text
GET /api/cloudflare/binary
POST /api/cloudflare/binary/download
GET /api/cloudflare/binary/latest
POST /api/cloudflare/binary/rollback
GET /api/cloudflare/settings
PUT /api/cloudflare/settings
POST /api/cloudflare/settings/test
POST /api/cloudflare/enabled
GET /api/cloudflare/preflight
```
### Zone、候选与 Access
```text
GET /api/cloudflare/ingress-candidates
GET /api/cloudflare/zones
GET /api/cloudflare/access-status
```
---
## 十六、数据表
### `cf_tunnels`
保存隧道基础信息:
- 名称。
- 模式。
- Tunnel ID。
- Account Tag。
- 加密后的凭据。
- 配置、凭据、日志路径。
- 网络设置。
- 运行状态。
- metrics 端口。
- 自动启动选项。
- 是否自动托管。
### `cf_settings`
保存应用级配置:
- Account ID。
- 加密后的默认 Token。
- 加密后的 API Token。
- 下载镜像。
- 默认网络设置。
- 全局托管开关。
### `cf_dns_records`
保存 Fonu 接管的 DNS 记录:
- 本地隧道 ID。
- Zone ID。
- DNS Record ID。
- Hostname。
### `cf_health_events`
保存 Cloudflare 域名健康状态变化:
- 隧道 ID。
- 域名。
- 状态。
- 原因。
- 详情。
- 时间。
### `proxy_rules` 扩展
- `exits_json`
- `cf_tunnel_id`
---
## 十七、当前未完成或仅部分实现
以下能力尚未完成,不能按“已实现”宣传:
1. 完整审计日志
- 尚未记录“谁在何时修改了规则、出口、凭据和设置”。
2. 云端配置拉取
- 漂移检测支持“本地覆盖云端”。
- 尚不支持“云端覆盖本地”。
3. 完整无感令牌轮换
- 当前是“刷新/重新获取隧道凭据”。
- 尚未实现创建新隧道后无缝迁移全部规则。
4. Access 策略管理
- 只做只读检测。
- 不创建、不修改 Access 应用。
5. 完全一键服务发布
- 当前是扫描、预填、打开表单。
- 仍需要用户填写域名并确认保存。
6. 正则路径 UI
- 后端路由模型支持 `path`。
- 当前页面主要使用通配路径,尚未提供独立的 path 高级编辑控件。
7. 多 Cloudflare 账号
- 当前应用配置只支持一个 Account ID + API Token。
- `cert.pem` / 多账号模式尚未实现。
---
## 十八、验证基线
当前实现覆盖以下验证:
```text
go test ./... -count=1
go vet ./...
cd web && npm run typecheck
cd web && npm test -- --run
cd web && npm run build
```
新增或重点测试:
- 出口默认值、持久化与过滤。
- Cloudflare 按规则生成 ingress。
- Cloudflare 健康状态与可用率计算。
- 手工 config 路由解析。
- 通配 path 不发送非法正则。
- DNS 记录归属。
- 备份包含 cloudflared 隧道配置。
- 漂移比较。
+576
View File
@@ -0,0 +1,576 @@
# Cloudflare 隧道同类项目调研与 Fonu 体验优化建议
> 日期:2026-09-30
> 调研范围:GitHub 上 Cloudflare Tunnel / cloudflared 管理面板、反向代理管理器、自托管访问平台、状态监测与流量检查器。
> 目标:不是照搬功能,而是提取适合 Fonu 的自动化方式、页面结构和长期运维体验。
---
## 一、调研结论摘要
同类项目大致分成四类:
1. **cloudflared 单容器 Web UI**
- 代表:Cloudflared-web
- 重点:快速设置 Token、启停隧道、查看运行状态。
2. **Cloudflare 路由与 Access 自动化平台**
- 代表:DockFlare
- 重点:Docker 标签、期望状态调和、DNS、ingress、Access 策略统一管理。
3. **Homelab 反向代理 / 私有访问平台**
- 代表:Nginx Proxy Manager、Pangolin、Cosmos Server
- 重点:可视化路由、证书、访问控制、资源入口页、审计。
4. **监控、状态页与流量调试**
- 代表:Uptime Kuma、Tunnel Inspector
- 重点:可用率、状态页、事故记录、请求级观测、重放和抓包体验。
对 Fonu 最重要的启发不是“再加多少按钮”,而是:
- 用期望状态和自动调和代替手工点同步。
- 让规则、隧道、DNS、证书、监控在同一套模型中管理。
- 配置页要面向“服务发布”,而不是面向 cloudflared 参数。
- 把诊断、修复、日志和状态页做成闭环。
- 高级能力可以保留,但默认路径必须非常短。
---
## 二、代表性项目
以下 Star 数据为 2026-09-30 GitHub 查询结果,仅作为热度参考。
| 项目 | 类型 | 技术栈 | Star | 最值得借鉴 |
| --- | --- | --- | ---: | --- |
| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端 | Go | 15970 | 官方能力边界、TryCloudflare、版本支持策略 |
| [DockFlare](https://github.com/ChrispyBacon-dev/DockFlare) | Tunnel + Access 自动化 | Python | 2471 | Docker 标签、期望状态调和、多主 Agent、备份恢复 |
| [Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) | 反向代理面板 | JavaScript | 高热度 | Host / Streams / Access Lists / SSL / 用户与审计 |
| [cloudflare-tunnel-ingress-controller](https://github.com/STRRL/cloudflare-tunnel-ingress-controller) | Tunnel Ingress Controller | Go | 1197 | 声明式路由、Ingress 到 Tunnel/DNS 自动生成 |
| [cftunnel](https://github.com/qingchencloud/cftunnel) | 全协议穿透 CLI | Go | 345 | 快速隧道、Cloud + FRP 双引擎、模板、分享、自动更新 |
| [Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web) | 极简 Web UI | Vue | 465 | 单容器安装、Token 管理、启停、低学习成本 |
| [Tunnel Cloudflared Desktop](https://github.com/maskodingku/Tunnel-Cloudflared-Desktop) | 桌面管理器 | Tauri + Rust | 55 | 多账号、`cert.pem`、双向同步、实时指标、Quick Tunnel |
| [Tunnel Inspector](https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector) | 流量检查器 | TypeScript | 6 | 请求/响应捕获、SSE 实时流、搜索筛选、重放、敏感头脱敏 |
| [Pangolin](https://github.com/fosrl/pangolin) | 自托管访问平台 | Go / Web | 高热度 | 资源入口页、统一身份、按资源授权、浏览器访问 SSH/RDP/VNC |
| [Uptime Kuma](https://github.com/louislam/uptime-kuma) | 监控与状态页 | Vue / Node | 高热度 | 多状态页、事故记录、证书信息、通知渠道、2FA |
---
## 三、项目逐个分析
### 3.1 Cloudflared-web
项目定位:
- 一个 Docker 镜像同时打包 cloudflared 和简单 Web UI。
- 主要面向“只想管理 Token 和启停隧道”的用户。
核心体验:
- Docker 启动后直接打开固定端口网页。
- 填写 Token,一键启停。
- 支持 BASIC AUTH。
- 支持环境变量配置协议、边缘 IP、metrics、重试等。
- 数据持久化到 `/config`。
对 Fonu 的启示:
- 初次配置应该更短:Account ID、API Token、Zone、Tunnel 四步内完成。
- 所有高级网络参数应该折叠到“高级选项”,默认值直接可用。
- 当前 Fonu 已覆盖它的主要能力,并且多了 API 托管、DNS、规则同步和监控。
不建议照搬:
- 只有 Token 和启停,模型太薄。
- 不适合 Fonu 的“服务发布规则”产品定位。
### 3.2 DockFlare
项目定位:
- 面向多变自托管环境的 Cloudflare Tunnel / DNS / Access 控制平面。
- 强调“定义一次意图,然后持续调和”。
核心能力:
- Docker 标签自动发现服务。
- Web UI 手工规则。
- 远程 Agent 多主机管理。
- Tunnel ingress、DNS、Access 统一调和。
- Access group 和可复用策略。
- 加密配置、运行时状态和数据的备份恢复。
- 多语言 UI。
对 Fonu 的启示:
- 当前 Fonu 已经有“反向代理规则派生 Cloudflare ingress”,方向正确。
- 下一步应补“期望状态”和“调和结果”:
- 本地期望配置。
- Cloudflare 实际配置。
- 最后同步时间。
- 漂移原因。
- 一键修复。
- Docker 标签自动发布非常适合 NAS。
- 多主机 Agent 适合进阶用户,但不应优先于单机体验。
- Access 自动化风险较高,Fonu 暂时保持只读更稳。
### 3.3 Tunnel Ingress Controller
项目定位:
- 把 Kubernetes Ingress 转成 Cloudflare Tunnel 和 DNS 配置。
核心思想:
- 用户只维护 Kubernetes 原生声明。
- Controller 持续把声明同步到 Cloudflare。
- 不要求用户手工编辑 cloudflared ingress。
对 Fonu 的启示:
- Fonu 的“反向代理规则就是服务入口声明”是同一思路的非 K8s 版本。
- 无需引入 K8s,但应借鉴:
- 声明与运行状态分离。
- 调和结果可见。
- 失败原因可追踪。
- 不应让用户理解底层 ingress 文件。
### 3.4 Tunnel Cloudflared Desktop
项目定位:
- 面向桌面用户的 Cloudflare Tunnel 管理器。
核心能力:
- Quick Tunnel。
- 多账号 `cert.pem`。
- 本地与 Cloudflare Dashboard 双向同步。
- 实时指标。
- 实时日志。
- 自动下载 cloudflared。
对 Fonu 的启示:
- Quick Tunnel 适合“临时演示”,不建议作为正式发布方式。
- 双向同步是 Fonu 当前明显缺口:
- 当前支持检测漂移并“本地覆盖云端”。
- 缺少“云端覆盖本地”。
- 多账号模式对 Fonu 属于进阶能力,可放到远期。
- 实时日志和指标已具备,继续加强即可。
### 3.5 cftunnel
项目定位:
- 全协议内网穿透 CLI。
- Cloud 模式使用 Cloudflare Tunnel。
- Relay 模式使用自建 frps。
核心体验:
- `quick <port>` 生成临时公网地址。
- 可加 Basic Auth。
- 自动 DNS。
- 预设模板。
- 一键分享二维码、地址、Telegram。
- 自动更新。
对 Fonu 的启示:
- “快速隧道”可以作为临时验证入口,但必须标明不保证 SLA。
- 场景模板非常值得借鉴:
- Webhook
- Home Assistant
- 面板
- 相册
- Jellyfin 管理页
- “复制访问地址 / 二维码分享”适合手机用户。
- Cloud + FRP 双引擎的概念与 Fonu 的出口抽象一致。
### 3.6 Nginx Proxy Manager
项目定位:
- 最经典的自托管反向代理 Web 面板。
核心体验:
- Hosts、Streams、Access Lists、SSL Certificates、Users 分栏。
- 每条代理规则有独立状态、SSL、访问控制、高级配置。
- 证书申请和续签直接在规则旁完成。
- 管理界面简单,新手可按表单完成部署。
对 Fonu 的启示:
- 反向代理规则的“编辑页”应成为服务发布中心。
- Cloudflare 出口应该是一个选项,而不是另一套孤立页面。
- SSL、Access、健康检查、日志应围绕同一条规则组织。
- Fonu 当前已有这一方向,但页面还可以更像“资源详情页”。
### 3.7 Pangolin
项目定位:
- 开源自托管 SASE / Zero Trust 访问平台。
核心体验:
- 资源入口页。
- 用户、角色、资源级授权。
- 浏览器直接访问 SSH、RDP、VNC。
- 对每个资源做审计和会话记录。
- 多 connector 和站点冗余。
对 Fonu 的启示:
- 普通 NAS 用户更需要“访问我的服务”,不是“管理网络隧道”。
- 可以借鉴资源入口页:
- 按应用、域名、出口分组。
- 一键打开服务。
- 显示维护状态和最近访问。
- 不建议 Fonu 当前做企业 RBAC、SSO、PAM;成本过高。
### 3.8 Uptime Kuma
项目定位:
- 自托管监控和状态页。
核心体验:
- 多状态页。
- 不同域名绑定不同状态页。
- Ping 图表、证书信息。
- 通知渠道丰富。
- 2FA。
对 Fonu 的启示:
- 当前公开状态页已有域名和可用率。
- 下一步最值得补:
- 事故记录。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 多个状态页。
- 自定义标题、Logo、说明。
- 这些比继续增加隧道参数更直接影响用户体验。
### 3.9 Tunnel Inspector
项目定位:
- Cloudflare Tunnel 的本地 HTTP 流量检查器。
核心体验:
- 实时请求列表。
- 方法和状态码筛选。
- 请求/响应头、Body、耗时、TTFB。
- 响应流式透传。
- 敏感头脱敏。
- 请求重放和 Copy as cURL。
- SSE 实时推送。
对 Fonu 的启示:
- 适合做成“Cloudflare 出口调试模式”,不要默认常驻。
- 可提供:
- 最近请求。
- 上游状态码。
- 回源耗时。
- 错误请求。
- 一键复制 curl。
- 需要严格限制:
- 仅本机访问。
- 默认脱敏。
- 默认关闭。
- 明确数据不离开本机。
---
## 四、跨项目共同的最佳体验
### 4.1 安装向导与预检
优秀项目不会让用户直接面对复杂配置。
建议 Fonu 增加:
1. 检查 cloudflared 是否安装。
2. 检查 Account ID。
3. 检查 API Token 权限。
4. 列出可用 Zone。
5. 检查目标根域名是否在 Zone 内。
6. 检查 DNS 是否已被其他目标占用。
7. 检查 7844 / 443 连通性。
8. 生成一条测试隧道并验证访问。
### 4.2 期望状态与调和中心
用户看到的不应只是“同步按钮”,而是:
```text
期望状态:已发布
Cloudflare 实际:缺失
最后同步:2 分钟前
原因:DNS CNAME 目标不一致
操作:修复 / 查看差异
```
这比单纯提示“保存失败”更有价值。
### 4.3 可视化拓扑
很多用户不理解 Nginx、frps、cloudflared、DNS、Access 的关系。
建议:
```text
域名 → 出口 → 隧道 → 内网服务
```
例如:
```text
nas.example.com
→ Cloudflare
→ tunnel: home-nas
→ http://192.168.1.10:8001
```
拓扑图可以直接显示:
- 域名。
- 出口。
- DNS 状态。
- 隧道状态。
- 回源状态。
- 证书来源。
### 4.4 路由表体验
当前 Fonu 已有路由表。建议继续补:
- 拖拽排序。
- 启用/停用。
- 复制路由。
- 正则路径提示。
- “测试此路由”按钮。
- 最后匹配结果。
- 路由来源标签:
- 手工。
- 反向代理规则。
- 导入。
- 修改历史和回滚。
### 4.5 状态页与事故
建议把公开状态页从“当前状态列表”升级为:
- 当前状态。
- 最近事故。
- 事故持续时间。
- 维护窗口。
- 最近 24h / 7d / 30d 状态条。
- 服务分组。
### 4.6 流量检查模式
建议作为高级诊断功能:
- 默认关闭。
- 开启后显示最近请求。
- 自动过滤静态资源。
- 显示 4xx/5xx。
- 回源错误和边缘错误分开。
- 可复制为 curl。
- 可查看请求/响应头。
- 默认隐藏 Authorization、Cookie、Token。
### 4.7 快速隧道
适合做“临时演示”:
- 只暴露单个本地端口。
- 生成 `trycloudflare.com` 地址。
- 明确标注:
- 地址会变化。
- 不保证 SLA。
- 不用于正式服务。
- 可复制地址或生成二维码。
### 4.8 一键分享与移动体验
对 NAS / 手机用户很有价值:
- 复制访问链接。
- 生成二维码。
- 分享到 Telegram / 微信。
- 移动端查看状态页。
- PWA 支持可作为远期项。
### 4.9 多账号与 `cert.pem`
桌面管理器和 DockFlare 都把多账号作为进阶能力。
建议 Fonu:
- 当前继续以 Account ID + API Token 为默认。
- 远期可支持多账号。
- `cert.pem` 不应替代 API Token,只作为高级兼容模式。
- 多账号必须有明确的账号隔离,避免路由误绑。
### 4.10 审计与回滚
自托管平台越复杂,回滚越重要。
建议:
- 记录规则和出口变更前后值。
- 记录操作主体。
- 记录 Cloudflare 同步结果。
- 支持手动回滚配置。
- 敏感字段只记录“已修改”,不记录明文。
---
## 五、Fonu 不该照搬的功能
| 功能 | 结论 | 原因 |
| --- | --- | --- |
| Kubernetes Controller | 不做 | 不是 NAS 用户场景 |
| 企业 SSO / PAM / RBAC 平台 | 暂不做 | 成本高,偏离 Fonu 定位 |
| Cloudflare 邮件套件 | 不做 | 与访问管理无关 |
| 多语言 13 种起步 | 暂不做 | 先保持中文产品体验 |
| 多主机 Agent 优先 | 后置 | 先做好单机 NAS |
| 自动配置 Access 策略 | 后置 | 权限风险高 |
| 默认常驻流量抓包 | 不做 | 隐私和性能风险 |
| 用户自定义 cloudflared 镜像管理 | 不做 | 增加维护复杂度 |
---
## 六、Fonu 建议路线
> 实施状态:截至 2026-10-01,本节的 P0 已完成第一轮落地,详见 `cloudflared-current-features.md`。
### P0:最值得先做
1. **Cloudflare 接入向导**
- Account、Token、Zone、Tunnel 一步检查。
2. **调和中心**
- 展示期望状态、云端状态、漂移原因、最后同步时间。
3. **路由表增强**
- 排序、启停、复制、测试、来源标签。
4. **一键修复**
- DNS、ingress、进程、二进制四类修复动作。
5. **状态页事故记录**
- 事故时间线、持续时长、维护窗口。
### P1:用户体验提升
6. **拓扑图**
- 域名 → 出口 → 隧道 → 服务。
7. **Docker 标签发布**
- 容器标签自动生成 Fonu 规则和 Cloudflare 出口。
8. **场景模板**
- Home Assistant、Immich、Jellyfin、qBittorrent、AdGuard。
9. **访问二维码与分享**
- 手机用户高频需求。
10. **云端覆盖本地**
- 完成双向同步,不只支持本地覆盖云端。
### P2:高级能力
11. **流量检查模式**
12. **多 Cloudflare 账号**
13. **Access 策略只读增强**
14. **审计日志**
15. **Quick Tunnel 临时演示**
16. **外部 Agent 多主机发布**
### P3:远期
17. PWA / 移动端体验。
18. 团队用户与资源级授权。
19. 浏览器 SSH / RDP / VNC 网关。
20. 多 connector 主备。
---
## 七、针对当前 Fonu 页面的具体改版建议
### 7.1 Cloudflare 总览页
建议结构:
```text
PageHeader
接入状态卡
隧道总览卡
待处理问题
最近变更
快速操作
```
待处理问题可以包括:
- API Token 权限不足。
- 根域名不在 Zone。
- DNS 被其他记录占用。
- 隧道离线。
- 本地与云端漂移。
- cloudflared 有新版本。
### 7.2 隧道配置页
建议分为:
```text
概览
路由
副本 / 指标
DNS
诊断
日志
高级
```
不要把所有内容放在一个长页面里。
### 7.3 反向代理规则页
在规则详情中增加:
```text
出口:本机 Nginx + Cloudflare
Cloudflare 状态:已同步
DNS:已创建
访问保护:未启用
最近错误:无
```
用户不需要离开规则详情就能知道整条链路是否健康。
### 7.4 仪表盘
增加:
- Cloudflare 在线隧道。
- 异常路由。
- DNS 漂移。
- 最近恢复 / 最近故障。
- cloudflared 可更新提示。
---
## 八、最终建议
Fonu 不应继续横向堆叠 cloudflared 参数。更值得投入的是:
1. 把 Cloudflare 接入做成向导。
2. 把“规则 → 出口 → 隧道 → DNS → 监控”做成同一条链路。
3. 把漂移、诊断和修复做成可操作闭环。
4. 把状态页做成真正的可分享服务状态页。
5. 把 Docker / NAS 服务发布做到自动发现。
6. 把高级能力放到高级页,不干扰默认路径。
这会让 Fonu 与普通 cloudflared 面板产生明确差异:
> 普通面板管理 cloudflared;Fonu 管理“我的服务如何安全、稳定地访问”。
+140 -2
View File
@@ -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) == "" {
+17
View File
@@ -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
}
+14 -2
View File
@@ -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 {
+31 -16
View File
@@ -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),
})
+100 -21
View File
@@ -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 {
+3 -2
View File
@@ -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(
+54 -3
View File
@@ -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) {
+1 -1
View File
@@ -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
+40
View File
@@ -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 隧道配置")
}
}
+1 -1
View File
@@ -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
}
+67 -1
View File
@@ -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)
+72 -7
View File
@@ -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
}
+32
View File
@@ -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)
+45
View File
@@ -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{
+377
View File
@@ -0,0 +1,377 @@
package cloudflared
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
HealthRetention = 30 * 24 * time.Hour
)
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
type HealthStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// HealthSample 交给落库层的状态样本。
type HealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// HealthEvent 一条状态变更记录。
type HealthEvent struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// HealthIncident 一段 Cloudflare 隧道不可用区间。
type HealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
type UptimeStat struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []HealthIncident `json:"incidents"`
}
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
type TunnelSummary struct {
Count int
Online int
Status string
Message string
LastError string
}
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return nil, err
}
out := []HealthStatus{}
for i := range tunnels {
tunnel := tunnels[i]
s.attachConfig(&tunnel)
status := s.manager.Status(tunnel.ID)
for _, domain := range tunnel.Hostnames {
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
}
}
return out, nil
}
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
if !globalEnabled {
item.Reason = "disabled"
item.Detail = "Cloudflare 隧道全局托管已停用"
return item
}
if status.Running {
item.Known = true
if status.Status == "connected" {
item.State = HealthUp
return item
}
item.State = HealthDown
item.Reason = "connection"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
if tunnel.AutoStart {
item.Known = true
item.State = HealthDown
item.Reason = "process"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
item.Reason = "stopped"
item.Detail = "隧道未运行,且未启用自动启动"
return item
}
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
if len(samples) == 0 {
return nil
}
current, err := s.latestHealthStates(ctx)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
continue
}
key := healthKey(sample.TunnelID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx, `
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
VALUES (?, ?, ?, ?, ?, ?)
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return err
}
}
return nil
}
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.store.db.QueryContext(ctx, `
SELECT e.tunnel_id, e.domain, e.state
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var tunnelID int64
var domain, state string
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
return nil, err
}
states[healthKey(tunnelID, domain)] = state
}
return states, rows.Err()
}
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.healthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeHealthUptime(events, since, now), nil
}
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.store.db.QueryContext(ctx, `
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM cf_health_events WHERE at >= ?
UNION ALL
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events WHERE at < ?
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := []HealthEvent{}
for rows.Next() {
var event HealthEvent
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// PruneHealth 清理超过保留期的状态变更。
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Summary 返回隧道数量、在线数与最近错误。
func (s *Service) Summary(ctx context.Context) TunnelSummary {
tunnels, err := s.store.List(ctx)
if err != nil {
return TunnelSummary{Status: "error", Message: err.Error()}
}
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
if len(tunnels) == 0 {
summary.Status = "none"
summary.Message = "未创建隧道"
return summary
}
for _, tunnel := range tunnels {
status := s.manager.Status(tunnel.ID)
if status.Running && status.Status == "connected" {
summary.Online++
}
if strings.TrimSpace(tunnel.LastError) != "" {
summary.LastError = tunnel.LastError
}
}
if summary.Online == 0 {
summary.Status = "error"
summary.Message = "没有在线隧道"
} else if summary.Online < summary.Count {
summary.Status = "warning"
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
} else {
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
}
return summary
}
func healthKey(tunnelID int64, domain string) string {
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
}
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
type healthPoint struct {
at time.Time
tunnelID int64
domain string
state string
reason, detail string
}
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
byRule := map[string][]healthPoint{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := healthKey(event.TunnelID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], healthPoint{
at: at, tunnelID: event.TunnelID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]UptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
stat := computeHealthPoints(points, from, to)
if stat.Domain != "" {
stats = append(stats, stat)
}
}
return stats
}
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
start := from
index := 0
state, reason, detail := "", "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
return UptimeStat{}
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
upMinutes := 0
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case HealthUp:
upMinutes += minutes
case HealthDown:
stat.DownMinutes += minutes
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
stat.State = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
return stat
}
+57
View File
@@ -0,0 +1,57 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
)
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
to := from.Add(2 * time.Hour)
events := []HealthEvent{
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
}
stats := ComputeHealthUptime(events, from, to)
if len(stats) != 1 {
t.Fatalf("应返回一条统计:%#v", stats)
}
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
t.Fatalf("可用率计算不符:%#v", stats[0])
}
}
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
var count int
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
}
}
+1 -1
View File
@@ -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
+7
View File
@@ -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"})
+3
View File
@@ -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 只回显掩码,不返回明文。
+323
View File
@@ -0,0 +1,323 @@
package cloudflared
import (
"context"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
)
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
type DriftResult struct {
TunnelID int64 `json:"tunnel_id"`
Managed bool `json:"managed"`
HasDrift bool `json:"has_drift"`
Items []string `json:"items"`
}
// DiagnosticCheck 一项诊断结果。
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
type DiagnosticsResult struct {
TunnelID int64 `json:"tunnel_id"`
GeneratedAt string `json:"generated_at"`
Checks []DiagnosticCheck `json:"checks"`
}
// PreflightCheck 是接入向导的一项预检结果。
type PreflightCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
type PreflightResult struct {
Ready bool `json:"ready"`
Checks []PreflightCheck `json:"checks"`
}
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
func (s *Service) Preflight(ctx context.Context) PreflightResult {
binary := s.binaryCheck()
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
}
checks = append(checks, PreflightCheck{
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
})
checks = append(checks, PreflightCheck{
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
})
if row.APITokenEnc == "" {
return PreflightResult{Ready: false, Checks: checks}
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
}
zones, err := api.ListZones(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
}
checks = append(checks, PreflightCheck{
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
})
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
}
func statusBool(ok bool) string {
if ok {
return "ok"
}
return "error"
}
func boolText(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func hasPreflightError(checks []PreflightCheck) bool {
for _, check := range checks {
if check.Status == "error" {
return true
}
}
return false
}
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DriftResult{}, err
}
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
if !tunnel.Managed {
return result, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return DriftResult{}, err
}
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DriftResult{}, err
}
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
if err != nil {
return DriftResult{}, err
}
result.Items = compareIngress(expected, actual)
result.HasDrift = len(result.Items) > 0
return result, nil
}
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if !tunnel.Managed {
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
}
return s.syncManagedTunnel(ctx, tunnel)
}
func compareIngress(expected, actual []IngressRule) []string {
expectedMap := ingressMap(expected)
actualMap := ingressMap(actual)
items := []string{}
keys := unionKeys(expectedMap, actualMap)
for _, key := range keys {
want, wantOK := expectedMap[key]
got, gotOK := actualMap[key]
switch {
case wantOK && !gotOK:
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
case !wantOK && gotOK:
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
case want != got:
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
}
}
return items
}
func ingressMap(ingress []IngressRule) map[string]string {
out := map[string]string{}
for _, item := range ingress {
key := strings.ToLower(strings.TrimSpace(item.Hostname))
if key == "" {
key = "(catch-all)"
}
out[key] = strings.TrimSpace(item.Service)
}
return out
}
func unionKeys(a, b map[string]string) []string {
seen := map[string]struct{}{}
for key := range a {
seen[key] = struct{}{}
}
for key := range b {
seen[key] = struct{}{}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DiagnosticsResult{}, err
}
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
result.Checks = append(result.Checks, s.binaryCheck())
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
api := s.apiDiagnostic(ctx, tunnel)
result.Checks = append(result.Checks, api)
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
return result, nil
}
func (s *Service) binaryCheck() DiagnosticCheck {
version := s.BinaryVersion()
if strings.TrimSpace(version) == "" {
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
}
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
}
func configCheck(path string) DiagnosticCheck {
if _, err := os.Stat(path); err != nil {
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
}
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
}
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
if status.Running && status.Status == "connected" {
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
}
if tunnel.AutoStart {
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
}
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
}
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
if tunnel.TunnelID == "" {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
}
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
}
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil {
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
}
if len(records) == 0 {
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
}
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
}
func networkCheck(name, address string) DiagnosticCheck {
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
}
_ = conn.Close()
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
}
// RouteTestResult 是单条路由源服务的连通性测试结果。
type RouteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
LatencyMS float64 `json:"latency_ms"`
}
// TestService 测试一条路由 service 的 TCP 连通性。
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
address, err := serviceAddress(service)
if err != nil {
return RouteTestResult{}, err
}
start := time.Now()
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return RouteTestResult{OK: false, Message: err.Error()}, nil
}
_ = conn.Close()
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
}
func serviceAddress(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, ":") {
return "", fmt.Errorf("service 格式无效:%s", raw)
}
return raw, nil
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
switch parsed.Scheme {
case "http":
port = "80"
case "https":
port = "443"
case "ssh":
port = "22"
default:
return "", fmt.Errorf("service 缺少端口:%s", raw)
}
}
return net.JoinHostPort(host, port), nil
}
+28
View File
@@ -0,0 +1,28 @@
package cloudflared
import "testing"
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
{Service: "http_status:404"},
}
items := compareIngress(expected, actual)
if len(items) != 3 {
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
}
}
func TestCompareIngressIgnoresOrder(t *testing.T) {
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Service: "http_status:404"},
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
}
if items := compareIngress(expected, actual); len(items) != 0 {
t.Fatalf("顺序不同不应算漂移:%#v", items)
}
}
+185
View File
@@ -0,0 +1,185 @@
package cloudflared
import (
"context"
"fmt"
"os"
"strings"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/validate"
)
// RouteView 是配置页展示的一条 ingress 路由。
type RouteView struct {
ID int64 `json:"id,omitempty"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
Source string `json:"source"`
}
// TunnelRoutes 是配置页需要的路由列表。
type TunnelRoutes struct {
Managed bool `json:"managed"`
Routes []RouteView `json:"routes"`
CatchAll string `json:"catch_all"`
}
// RouteInput 是手工隧道路由保存入参。
type RouteInput struct {
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
}
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return s.managedRoutes(ctx, tunnel)
}
return manualRoutes(tunnel.ConfigPath)
}
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
if s.rules == nil {
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return TunnelRoutes{}, err
}
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
for _, host := range rule.Hostnames() {
out.Routes = append(out.Routes, RouteView{
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
})
}
}
return out, nil
}
func manualRoutes(configPath string) (TunnelRoutes, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
return TunnelRoutes{}, err
}
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
hostname := strings.TrimSpace(host)
if hostname == "" || i+1 >= len(lines) {
continue
}
path := "*"
next := i + 1
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
path = strings.TrimSpace(value)
next++
}
if next >= len(lines) {
continue
}
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
if !ok {
continue
}
out.Routes = append(out.Routes, RouteView{
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
})
}
if len(out.Routes) > 0 {
catchAll, err := catchAllService(data)
if err == nil && catchAll != "" {
out.CatchAll = catchAll
}
}
return out, nil
}
func catchAllService(data []byte) (string, error) {
lines := strings.Split(string(data), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- service:") {
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
}
}
return "", fmt.Errorf("未找到 catch-all")
}
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
}
ingress, hostnames, err := buildManualIngress(routes)
if err != nil {
return TunnelRoutes{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
return TunnelRoutes{}, err
}
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return TunnelRoutes{}, err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return TunnelRoutes{}, err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
return TunnelRoutes{}, err
}
}
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
return TunnelRoutes{}, err
}
return s.Routes(ctx, id)
}
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
seen := map[string]struct{}{}
ingress := make([]IngressRule, 0, len(routes)+1)
hostnames := make([]string, 0, len(routes))
for _, route := range routes {
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
if err := validate.Domain(hostname); err != nil {
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
}
if _, ok := seen[hostname]; ok {
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
}
seen[hostname] = struct{}{}
service := strings.TrimSpace(route.Service)
if service == "" || !strings.Contains(service, "://") {
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
}
path := strings.TrimSpace(route.Path)
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
if path == "" || path == "*" {
path = ""
}
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
hostnames = append(hostnames, hostname)
}
ingress = append(ingress, IngressRule{Service: "http_status:404"})
return ingress, hostnames, nil
}
+51
View File
@@ -0,0 +1,51 @@
package cloudflared
import (
"os"
"path/filepath"
"testing"
)
func TestManualRoutesFromConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
content := `tunnel: test
ingress:
- hostname: a.example.com
service: http://127.0.0.1:8080
- hostname: b.example.com
service: http://127.0.0.1:9090
- service: http_status:404
`
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
result, err := manualRoutes(path)
if err != nil {
t.Fatal(err)
}
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
t.Fatalf("手工路由解析不符:%#v", result)
}
if result.CatchAll != "http_status:404" {
t.Fatalf("catch-all 不符:%q", result.CatchAll)
}
}
func TestBuildManualIngress(t *testing.T) {
ingress, hostnames, err := buildManualIngress([]RouteInput{
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
})
if err != nil {
t.Fatal(err)
}
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
t.Fatalf("ingress 结果不符:%#v", ingress)
}
if ingress[0].Path != "" || ingress[1].Path != "" {
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
}
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
t.Fatalf("hostnames 结果不符:%#v", hostnames)
}
}
+320 -19
View File
@@ -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
}
+26
View File
@@ -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)
}
}
+84 -8
View File
@@ -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)
+4
View File
@@ -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 {
+120 -8
View File
@@ -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 {
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"context"
"testing"
)
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
rule, err := store.Create(ctx, CreateInput{
Upstream: "http://192.168.1.10:8080",
ListenPort: 8443,
ListenIPv4: true,
Hosts: []string{"cf.example.com"},
Enabled: true,
Exits: []string{ExitCloudflare},
CFTunnelID: 7,
})
if err != nil {
t.Fatal(err)
}
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
}
localRules, err := store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 0 {
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
}
enabled := true
updated, err := store.Update(ctx, rule.ID, UpdateInput{
Exits: &[]string{ExitLocal, ExitCloudflare},
Enabled: &enabled,
CFTunnelID: int64Ptr(7),
})
if err != nil {
t.Fatal(err)
}
if len(updated.Exits) != 2 {
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
}
localRules, err = store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 1 || localRules[0].ID != rule.ID {
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
}
}
func int64Ptr(v int64) *int64 { return &v }
+39
View File
@@ -0,0 +1,39 @@
package proxy
import "testing"
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
exits, tunnelID, err := normalizeExits(nil, 0)
if err != nil {
t.Fatal(err)
}
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
}
}
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
t.Fatal("Cloudflare 出口缺少隧道时应报错")
}
}
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
if err != nil {
t.Fatal(err)
}
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
}
}
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
rule := Rule{}
if !rule.UsesExit(ExitLocal) {
t.Fatal("空出口应兼容为本机出口")
}
if rule.UsesExit(ExitCloudflare) {
t.Fatal("空出口不应被视为 Cloudflare")
}
}
+153 -8
View File
@@ -3,14 +3,21 @@ package proxy
import (
"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 {
+2 -2
View File
@@ -287,7 +287,7 @@ func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName strin
}
func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
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
}
+42 -8
View File
@@ -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)
+19
View File
@@ -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);
+13
View File
@@ -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);
+35 -2
View File
@@ -43,6 +43,13 @@ import type {
CfTunnelPayload,
CfAppSettings,
CfAppSettingsInput,
CfDriftResult,
CfDiagnosticsResult,
CfAccessStatus,
CfZone,
CfTunnelRoutes,
CfPreflightResult,
CfRouteTestResult,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
@@ -495,20 +502,46 @@ export const api = {
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }),
refreshCfCredentials: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }),
getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/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<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`),
replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) =>
request<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`, {
method: 'PUT',
body: JSON.stringify({ routes }),
}),
getCfTunnelDrift: (id: number) => request<CfDriftResult>(`/api/cloudflare/tunnels/${id}/drift`),
syncCfTunnelRoutes: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }),
getCfTunnelDiagnostics: (id: number) =>
request<CfDiagnosticsResult>(`/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<CfAccessStatus>(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`),
getCfPreflight: () => request<CfPreflightResult>('/api/cloudflare/preflight'),
testCfRoute: (service: string) =>
request<CfRouteTestResult>('/api/cloudflare/routes/test', {
method: 'POST',
body: JSON.stringify({ service }),
}),
getCfSettings: () => request<CfAppSettings>('/api/cloudflare/settings'),
saveCfSettings: (payload: CfAppSettingsInput) =>
request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }),
+82
View File
@@ -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
}
+1 -1
View File
@@ -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 },
+7
View File
@@ -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',
@@ -0,0 +1,714 @@
<template>
<PageHeader :title="`配置 · ${tunnel?.name || 'Cloudflare 隧道'}`" description="配置隧道、查看副本指标与已发布路由">
<template #actions>
<n-button @click="router.push('/cloudflare')">
<template #icon><n-icon :component="ArrowBackOutline" /></template>
返回列表
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else-if="tunnel">
<div class="stats-row">
<StatCard label="活动副本" :value="status?.connections ?? 0" sub="cloudflared HA 连接" tone="blue">
<template #icon><n-icon :component="GitNetworkOutline" /></template>
</StatCard>
<StatCard label="路由" :value="routeData?.routes.length ?? 0" sub="已发布应用" tone="green">
<template #icon><n-icon :component="LayersOutline" /></template>
</StatCard>
<StatCard label="状态" :sub="status?.transport || tunnel.mode" tone="teal">
<template #icon><n-icon :component="PulseOutline" /></template>
<template #value><StatusBadge :kind="statusKind(runtimeState)" :text="statusText(runtimeState)" /></template>
</StatCard>
<StatCard label="运行时间" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
<template #icon><n-icon :component="TimeOutline" /></template>
</StatCard>
</div>
<FonuCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<div class="section-body">
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
这是自动托管隧道。暴露域名与回源请到
<router-link to="/proxies">反向代理</router-link>
的 Cloudflare 出口中维护。
</n-alert>
<div v-else class="config-basic-action">
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="路由" subtitle="流量通过以下路由到应用程序和服务">
<div class="section-body">
<div class="topology-canvas" :style="{ height: `${topologyHeight}px` }">
<div class="topology-graph">
<div
v-for="(route, index) in routeData?.routes || []"
:key="'domain-' + route.hostname"
class="topology-domain"
:style="{ top: `${routeY(index)}px` }"
>
<div class="topology-node-copy">
<span class="topology-domain__name mono">{{ route.hostname }}</span>
</div>
<n-tag size="small" type="info" :bordered="false" round>已发布的应用程序</n-tag>
</div>
<n-button
v-if="!tunnel.managed"
class="topology-add"
size="small"
:style="{ top: `${addRouteY}px` }"
@click="openAddRoute"
>
<template #icon><n-icon :component="AddOutline" /></template>
添加路由
</n-button>
<div class="topology-exit" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="CloudOutline" />
<div class="topology-node-copy">
<strong>Cloudflare</strong>
</div>
</div>
<div class="topology-tunnel" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="GitNetworkOutline" />
<div class="topology-node-copy">
<strong>{{ tunnel.name }}</strong>
</div>
</div>
<div
v-for="(service, index) in topologyServices"
:key="'service-' + service"
class="topology-service"
:style="{ top: `${serviceY(index)}px` }"
>
<n-icon :component="ServerOutline" />
<div class="topology-node-copy">
<span class="mono">{{ service }}</span>
</div>
</div>
<svg class="topology-lines" :viewBox="`0 0 ${TOPOLOGY_WIDTH} ${topologyHeight}`">
<template v-for="(route, index) in routeData?.routes || []" :key="'line-' + route.hostname">
<path :d="domainToExitPath(routeY(index) + 22)" />
<path :d="exitToTunnelPath" />
<path :d="tunnelToServicePath(serviceY(topologyServices.indexOf(route.service)) + 22)" />
</template>
<path v-if="!tunnel.managed" :d="domainToExitPath(addRouteY + 17)" />
</svg>
</div>
</div>
<div v-if="routeData?.routes.length" class="routes-table">
<div class="routes-table__head">
<span>顺序</span>
<span>目标</span>
<span>路径</span>
<span>服务</span>
<span>来源</span>
<span>操作</span>
</div>
<div v-for="(route, index) in routeData.routes" :key="route.hostname + route.service" class="routes-table__row">
<span>{{ index + 1 }}</span>
<span class="mono">{{ route.hostname }}</span>
<span class="mono">{{ route.path }}</span>
<span class="mono">{{ route.service }}</span>
<n-tag size="small" :bordered="false" round>{{ route.source === 'rule' ? '反向代理规则' : 'config.yml' }}</n-tag>
<div class="routes-table__actions">
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === 0" @click="moveRoute(index, -1)">上移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === (routeData?.routes.length ?? 1) - 1" @click="moveRoute(index, 1)">下移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed" @click="copyRoute(route)">复制</n-button>
<n-button size="tiny" quaternary @click="testRoute(route)">测试</n-button>
<n-button size="tiny" :disabled="tunnel.managed" @click="openEditRoute(route, index)">编辑</n-button>
<n-popconfirm :disabled="tunnel.managed" @positive-click="deleteRoute(index)">
<template #trigger>
<n-button size="tiny" type="error" quaternary :disabled="tunnel.managed">删除</n-button>
</template>
删除路由 {{ route.hostname }}?保存后会同步到 Cloudflare。
</n-popconfirm>
</div>
</div>
</div>
<EmptyState
v-else
title="还没有已发布路由"
description="手工隧道可在基础配置中添加域名和源服务,托管隧道请到反向代理规则维护。"
>
<template v-if="!tunnel.managed" #action>
<n-button type="primary" @click="openAddRoute">添加路由</n-button>
</template>
</EmptyState>
<div class="routes-catchall">
<span>全部捕获规则:</span>
<strong class="mono">{{ routeData?.catch_all || 'http_status:404' }}</strong>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<div class="section-body">
<n-space>
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
<n-button type="warning" :disabled="runtimeState !== 'connected'" @click="runAction('停止')">停止</n-button>
<n-button @click="runAction('重启')">重启</n-button>
</n-space>
</div>
</FonuCard>
</template>
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
<n-form label-placement="top" class="config-form">
<n-form-item label="名称">
<n-input v-model:value="form.name" />
</n-form-item>
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="form.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="form.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ basicHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="form.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="form.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
<div class="config-form__footer">
<n-switch v-model:value="form.autoStart" />
<span>随 Fonu 自动启动</span>
</div>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="basicVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">保存配置</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="routeVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" :title="routeEditingIndex === null ? '添加路由' : '编辑路由'">
<n-form label-placement="top" class="config-form">
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="routeForm.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="routeForm.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ routeHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="routeForm.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="routeForm.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="routeVisible = false">取消</n-button>
<n-button type="primary" :loading="routeSaving" @click="saveRoute">保存路由</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm, NSelect, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
import type { StatusKind } from '../utils/status'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const tunnel = ref<CfTunnel | null>(null)
const status = ref<CfRuntimeStatus | null>(null)
const routeData = ref<CfTunnelRoutes | null>(null)
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const basicVisible = ref(false)
const routeVisible = ref(false)
const routeSaving = ref(false)
const routeEditingIndex = ref<number | null>(null)
const routeForm = ref({ subdomain: '', domain: '', type: 'http', target: '' })
const loadError = ref('')
const form = ref({ name: '', subdomain: '', domain: '', type: 'http', target: '', autoStart: false })
const TOPOLOGY_WIDTH = 820
const TOPOLOGY_ROW_HEIGHT = 54
const TOPOLOGY_START_Y = 38
const tunnelID = computed(() => Number(route.params.id))
const runtimeState = computed(() => status.value?.status || tunnel.value?.status || 'stopped')
const zoneOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const topologyServices = computed(() => {
const values = new Set((routeData.value?.routes || []).map((route) => route.service))
return [...values]
})
const addRouteY = computed(() => TOPOLOGY_START_Y + (routeData.value?.routes.length ?? 0) * TOPOLOGY_ROW_HEIGHT)
const topologyHeight = computed(() => Math.max(240, Math.max(addRouteY.value + 70, topologyServices.value.length * TOPOLOGY_ROW_HEIGHT + TOPOLOGY_START_Y + 30)))
const centerY = computed(() => topologyHeight.value / 2)
function routeY(index: number) {
return TOPOLOGY_START_Y + index * TOPOLOGY_ROW_HEIGHT
}
function serviceY(index: number) {
const count = Math.max(1, topologyServices.value.length)
const total = (count - 1) * TOPOLOGY_ROW_HEIGHT
return topologyHeight.value / 2 - total / 2 + index * TOPOLOGY_ROW_HEIGHT - 22
}
function domainToExitPath(sourceY: number) {
return `M 220 ${sourceY} C 250 ${sourceY}, 265 ${centerY.value}, 292 ${centerY.value}`
}
function exitToTunnelPath() {
return `M 392 ${centerY.value} L 470 ${centerY.value}`
}
function tunnelToServicePath(targetY: number) {
return `M 578 ${centerY.value} C 610 ${centerY.value}, 625 ${targetY}, 660 ${targetY}`
}
async function load() {
if (!Number.isFinite(tunnelID.value) || tunnelID.value <= 0) {
loadError.value = '隧道 ID 无效'
return
}
loading.value = true
loadError.value = ''
try {
const [item, runtime, routes, zoneResult] = await Promise.all([
api.getCfTunnel(tunnelID.value),
api.getCfTunnelStatus(tunnelID.value),
api.getCfTunnelRoutes(tunnelID.value),
api.getCfZones().catch(() => ({ zones: [] as CfZone[] })),
])
tunnel.value = item
status.value = runtime
routeData.value = routes
zones.value = asList(zoneResult.zones)
loadForm(item)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '加载隧道配置失败'
} finally {
loading.value = false
}
}
function loadForm(item: CfTunnel) {
const hostname = item.hostnames?.[0] || ''
const origin = item.origin_service || 'http://127.0.0.1:80'
const [type, target = ''] = origin.split('://')
const zone = zones.value
.filter((entry) => hostname === entry.name || hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
form.value = {
name: item.name,
subdomain: zone ? hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || hostname,
type: type || 'http',
target: target || '127.0.0.1:80',
autoStart: item.auto_start,
}
}
async function save() {
if (!tunnel.value || tunnel.value.managed) return
const hostname = manualHostname()
if (!hostname || !form.value.target.trim()) {
message.warning('请填写域名和源服务')
return
}
const payload: CfTunnelPayload = {
name: form.value.name.trim(),
mode: tunnel.value.mode,
token: '',
hostnames: [hostname],
origin_service: `${form.value.type}://${form.value.target.trim().replace(/^[a-z]+:\/\//i, '')}`,
network: tunnel.value.network,
auto_start: form.value.autoStart,
managed: false,
}
saving.value = true
try {
const result = await api.updateCfTunnel(tunnel.value.id, payload)
tunnel.value = result
if (result.dns_warning) message.warning(result.dns_warning)
else message.success('配置已保存')
basicVisible.value = false
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
function openAddRoute() {
routeEditingIndex.value = null
routeForm.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
routeVisible.value = true
}
function openEditRoute(route: CfRouteView, index: number) {
const [type, target = ''] = route.service.split('://')
const zone = zones.value
.filter((entry) => route.hostname === entry.name || route.hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
routeEditingIndex.value = index
routeForm.value = {
subdomain: zone ? route.hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || route.hostname,
type: type || 'http',
target,
}
routeVisible.value = true
}
async function saveRoute() {
if (!tunnel.value || !routeData.value) return
const hostname = routeHostname()
const target = routeForm.value.target.trim().replace(/^[a-z]+:\/\//i, '')
if (!hostname || !target) {
message.warning('请填写目标域名和源服务')
return
}
const next = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
const item = { hostname, path: '*', service: `${routeForm.value.type}://${target}` }
if (routeEditingIndex.value === null) next.push(item)
else next[routeEditingIndex.value] = item
routeSaving.value = true
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
routeVisible.value = false
message.success('路由已保存')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存路由失败')
} finally {
routeSaving.value = false
}
}
async function deleteRoute(index: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const next = routeData.value.routes
.filter((_, routeIndex) => routeIndex !== index)
.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
message.success('路由已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除路由失败')
}
}
async function moveRoute(index: number, delta: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const target = index + delta
if (target < 0 || target >= routeData.value.routes.length) return
const routes = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
;[routes[index], routes[target]] = [routes[target], routes[index]]
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, routes)
message.success('路由顺序已更新')
} catch (error) {
message.error(error instanceof Error ? error.message : '调整顺序失败')
}
}
function copyRoute(route: CfRouteView) {
openAddRoute()
const [type, target = ''] = route.service.split('://')
routeForm.value = {
subdomain: '',
domain: zones.value.find((zone) => route.hostname.endsWith('.' + zone.name))?.name || route.hostname,
type: type || 'http',
target,
}
}
async function testRoute(route: CfRouteView) {
try {
const result = await api.testCfRoute(route.service)
if (result.ok) message.success(`${route.hostname}:${result.message},${result.latency_ms} ms`)
else message.error(`${route.hostname}:${result.message}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '测试失败')
}
}
function routeHostname() {
const subdomain = routeForm.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = routeForm.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function basicHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function manualHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
async function runAction(action: '启动' | '停止' | '重启') {
if (!tunnel.value) return
try {
if (action === '启动') await api.startCfTunnel(tunnel.value.id)
if (action === '停止') await api.stopCfTunnel(tunnel.value.id)
if (action === '重启') await api.restartCfTunnel(tunnel.value.id)
message.success(`${action}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${action}失败`)
}
}
function statusText(value: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[value] || value || '未知'
}
function statusKind(value: string): StatusKind {
if (value === 'connected') return 'success'
if (value === 'starting' || value === 'reconnecting') return 'warning'
if (value === 'failed') return 'error'
return 'unknown'
}
onMounted(load)
</script>
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
}
.section-card + .section-card { margin-top: var(--fonu-space-4); }
.config-basic-action { display: flex; justify-content: flex-end; }
.topology-canvas {
position: relative;
width: 100%;
margin-bottom: var(--fonu-space-4);
overflow-x: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background-color: var(--fonu-bg);
background-image: radial-gradient(var(--fonu-border) 0.8px, transparent 0.8px);
background-size: 10px 10px;
}
.topology-graph {
position: relative;
width: 820px;
height: 100%;
margin: 0 auto;
}
.topology-domain {
position: absolute;
left: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: 220px;
height: 44px;
}
.topology-domain__name {
max-width: 125px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fonu-brand-text);
text-decoration: underline;
}
.topology-node-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.topology-lines {
position: absolute;
inset: 0;
z-index: 1;
width: 820px;
height: 100%;
pointer-events: none;
}
.topology-lines path {
fill: none;
stroke: var(--fonu-border);
stroke-width: 1.5;
}
.topology-add {
position: absolute;
left: 0;
z-index: 2;
width: 150px;
}
.topology-exit,
.topology-tunnel {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--fonu-border);
border-radius: 999px;
background: var(--fonu-surface);
box-shadow: var(--fonu-shadow);
white-space: nowrap;
}
.topology-exit { left: 292px; min-width: 100px; }
.topology-tunnel { left: 470px; min-width: 108px; }
.topology-service {
position: absolute;
left: 660px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: 150px;
height: 44px;
padding: 7px 8px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-surface);
color: var(--fonu-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
.topology-service :deep(.n-icon) { flex: none; color: var(--fonu-brand); }
.config-form { max-width: 780px; }
.config-form__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.config-origin {
display: grid;
grid-template-columns: 128px auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
}
.config-origin__type { width: 128px; }
.config-origin__scheme {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
color: var(--fonu-text-muted);
white-space: nowrap;
}
.config-form__footer { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.config-form__spacer { flex: 1; }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
}
.hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.hostname-preview strong { color: var(--fonu-text); }
.routes-table { overflow-x: auto; }
.routes-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-3);
}
.routes-table__head,
.routes-table__row {
display: grid;
grid-template-columns: 64px minmax(180px, 1fr) 90px minmax(220px, 1.2fr) 130px 360px;
gap: 12px;
align-items: center;
min-width: 1080px;
padding: 10px 0;
}
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.routes-table__head { color: var(--fonu-text-muted); font-size: 12px; border-bottom: 1px solid var(--fonu-border); }
.routes-table__row { border-bottom: 1px solid var(--fonu-border); }
.routes-table__row:last-child { border-bottom: none; }
.routes-empty { padding: 20px 0; color: var(--fonu-text-muted); }
.routes-catchall { margin-top: 12px; padding: 10px 12px; background: var(--fonu-bg); border-radius: var(--fonu-radius-sm); font-size: 13px; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.config-form__grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.stats-row { grid-template-columns: 1fr; }
}
</style>
+452 -62
View File
@@ -1,6 +1,7 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
</template>
@@ -9,43 +10,72 @@
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text">
<strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:隧道进程由 Fonu 托管,auto_start 的隧道会在应用启动时恢复' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Fonu 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
</div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ binaryVersion }}</span>
已安装:<span class="mono">{{ displayBinaryVersion(binaryVersion) }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<n-alert type="info" :bordered="false" class="cf-alert">
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
</n-alert>
<FonuCard title="隧道列表" subtitle="token 模式默认使用 Fonu 生成的本地 ingress;云端规则模式请在 Cloudflare 面板管理">
<FonuCard title="隧道列表" subtitle="托管隧道从「反向代理」规则的 Cloudflare 出口自动派生;手工隧道仍在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-for="item in tunnels" :key="item.id" class="cf-row">
<div class="cf-row__main">
<strong>{{ item.name }}</strong>
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" />
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
<div v-else class="cf-table">
<div class="cf-table__head">
<span>隧道</span>
<span>状态</span>
<span>副本</span>
<span>版本</span>
<span>路由</span>
<span>调和</span>
<span>操作</span>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openEdit(item)">编辑</n-button>
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
<div v-for="item in tunnels" :key="item.id" class="cf-table__row">
<div class="cf-table__tunnel">
<strong>{{ item.name }}</strong>
<div class="cf-table__meta">
<n-tag v-if="item.managed" size="small" type="info" :bordered="false" round>自动托管</n-tag>
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
</div>
</div>
<div>
<StatusBadge
:kind="statusKind(runtimeStatus(item).status || item.status)"
:text="statusText(runtimeStatus(item).status || item.status)"
/>
</div>
<div class="cf-table__number">{{ replicaCount(item) }}</div>
<div class="cf-meta mono">{{ displayBinaryVersion(binaryVersion) || '—' }}</div>
<div><n-tag size="small" :bordered="false" round>{{ routeCount(item) }} 条</n-tag></div>
<div>
<n-tag size="small" :type="driftTagType(item)" :bordered="false" round>{{ driftText(item) }}</n-tag>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openConfig(item)">配置</n-button>
<n-button v-if="runtimeStatus(item).status !== 'connected' && runtimeStatus(item).status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-button v-if="driftByTunnel[item.id]?.has_drift" size="small" type="warning" @click="repairTunnel(item)">修复</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
</div>
</div>
</div>
</FonuCard>
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" :title="editing ? '编辑隧道' : '新建隧道'">
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="新建隧道">
<div class="cf-modal__body">
<n-alert type="info" :bordered="false" class="cf-mode-alert">
隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。
@@ -56,16 +86,47 @@
<n-divider title-placement="left">基本信息</n-divider>
<n-form label-placement="top">
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
</n-form>
<n-divider title-placement="left">暴露规则</n-divider>
<n-form label-placement="top">
<n-form-item label="暴露域名">
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
</n-form-item>
<n-form-item label="回源 service">
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
<n-form-item label="自动托管">
<div class="cf-managed-field">
<n-switch v-model:value="form.managed" />
<span>启用后,暴露域名与回源由「反向代理」规则中的 Cloudflare 出口自动生成。</span>
</div>
</n-form-item>
</n-form>
<template v-if="!form.managed">
<n-divider title-placement="left">暴露规则</n-divider>
<n-form label-placement="top">
<div class="cf-rule-row">
<n-form-item label="子域名(可选)" class="cf-rule-row__subdomain">
<n-input v-model:value="manualRule.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域" class="cf-rule-row__domain">
<n-select
v-model:value="manualRule.domain"
:options="domainOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="cf-hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ manualHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="cf-origin-row">
<n-select v-model:value="manualRule.type" :options="originTypeOptions" class="cf-origin-row__type" />
<span class="cf-origin-row__scheme">://</span>
<n-input v-model:value="manualRule.target" placeholder="192.168.11.30:8001" />
</div>
<p class="cf-hint">路由流量的源服务,例如:192.168.11.30:8001、localhost:8080、192.168.1.2:3306</p>
</n-form-item>
</n-form>
</template>
<n-alert v-else type="info" :bordered="false" class="cf-mode-alert">
保存托管隧道后,请到「反向代理」编辑服务规则,在出口中选择 Cloudflare 并指定该隧道。
</n-alert>
<n-divider title-placement="left">运行</n-divider>
<n-form label-placement="top">
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
@@ -85,7 +146,7 @@
<div class="cf-detail__stats">
<div class="cf-stat"><label>状态</label><StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" /></div>
<div class="cf-stat"><label>连接数</label><strong>{{ detailStatus?.connections ?? 0 }}</strong></div>
<div class="cf-stat"><label>延迟</label><strong>{{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</strong></div>
<div class="cf-stat"><label>延迟</label><strong>{{ detailStatus?.transport === 'http2' ? '—' : `${(detailStatus?.latency_ms ?? 0).toFixed(0)} ms` }}</strong></div>
<div class="cf-stat"><label>传输协议</label><strong>{{ detailStatus?.transport || '—' }}</strong></div>
<div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div>
</div>
@@ -99,6 +160,31 @@
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</n-tab-pane>
<n-tab-pane name="diagnostics" tab="漂移与诊断">
<div class="cf-code-toolbar">
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
<n-button size="tiny" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
</div>
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
<template v-if="detailDrift.has_drift">
发现 {{ detailDrift.items.length }} 项云端漂移:
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
</template>
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
</n-alert>
<div v-if="detailDiagnostics" class="cf-diagnostics">
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
</div>
</div>
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Fonu 只做只读检测,不代配策略。' }}
</n-alert>
</n-tab-pane>
</n-tabs>
</div>
<template #footer>
@@ -113,7 +199,8 @@
<div class="cf-modal__body">
<n-divider title-placement="left">程序安装</n-divider>
<div class="cf-binary">
<div><label>当前版本</label><strong class="mono">{{ binaryVersion || '未安装' }}</strong></div>
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div>
<n-form label-placement="top">
@@ -122,7 +209,9 @@
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div>
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
@@ -147,7 +236,7 @@
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限。</p>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
<n-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
@@ -164,13 +253,33 @@
</div>
</template>
</n-modal>
<n-modal v-model:show="preflightVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" title="Cloudflare 接入向导">
<div class="cf-preflight">
<div v-for="check in preflight?.checks || []" :key="check.name" class="cf-preflight__row">
<n-tag size="small" :type="check.status === 'ok' ? 'success' : 'error'" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
<n-button v-if="check.action === 'settings' && check.status !== 'ok'" size="tiny" @click="openSettings">去配置</n-button>
</div>
<n-alert v-if="preflight" :type="preflight.ready ? 'success' : 'warning'" :bordered="false" class="cf-mode-alert">
{{ preflight.ready ? '预检通过,可以创建隧道或发布服务。' : '还有项目未完成,请按上面的提示处理。' }}
</n-alert>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button :loading="preflightLoading" @click="loadPreflight">重新检查</n-button>
<n-button type="primary" @click="preflightVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { CfAppSettings, CfAppSettingsInput, CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types'
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -179,13 +288,19 @@ import { copyText } from '../utils/clipboard'
import type { StatusKind } from '../utils/status'
const message = useMessage()
const router = useRouter()
const tunnels = ref<CfTunnel[]>([])
const runtimeStatuses = ref<Record<number, CfRuntimeStatus>>({})
const driftByTunnel = ref<Record<number, CfDriftResult>>({})
const binaryVersion = ref('')
const latestBinaryVersion = ref('')
const mirrors = ref<CfMirror[]>([])
const candidates = ref<string[]>([])
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const binaryLoading = ref(false)
const binaryChecking = ref(false)
const binaryRollbackLoading = ref(false)
const globalLoading = ref(false)
const settingsVisible = ref(false)
const settingsLoading = ref(false)
@@ -206,14 +321,25 @@ const appSettings = ref<CfAppSettings>({
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
const editorVisible = ref(false)
const detailVisible = ref(false)
const editing = ref<CfTunnel | null>(null)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
const detailLogs = ref('')
const detailLoading = ref(false)
const detailDrift = ref<CfDriftResult | null>(null)
const detailDiagnostics = ref<CfDiagnosticsResult | null>(null)
const driftLoading = ref(false)
const driftSyncing = ref(false)
const diagnosticsLoading = ref(false)
const detailAccess = ref<CfAccessStatus | null>(null)
const accessLoading = ref(false)
const credentialLoading = ref(false)
const preflightVisible = ref(false)
const preflightLoading = ref(false)
const preflight = ref<CfPreflightResult | null>(null)
const form = ref<CfTunnelPayload>(emptyForm())
const manualRule = ref({ subdomain: '', domain: '', type: 'http', target: '127.0.0.1:80' })
const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
@@ -229,7 +355,13 @@ const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' },
]
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const domainOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const mirrorOptions = computed(() => {
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
return list.map((item) => ({ label: item.name, value: item.id }))
@@ -243,6 +375,7 @@ function emptyForm(): CfTunnelPayload {
origin_service: 'http://127.0.0.1:80',
network: emptyNetwork(),
auto_start: false,
managed: true,
}
}
@@ -254,6 +387,26 @@ async function load() {
loading.value = true
try {
tunnels.value = asList(await api.listCfTunnels())
const pairs = await Promise.all(
tunnels.value.map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelStatus(tunnel.id)] as const
} catch {
return [tunnel.id, { running: false, status: tunnel.status, connections: 0, latency_ms: 0, bandwidth_in: 0, bandwidth_out: 0, transport: '' }] as const
}
}),
)
runtimeStatuses.value = Object.fromEntries(pairs)
const driftPairs = await Promise.all(
tunnels.value.filter((tunnel) => tunnel.managed).map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelDrift(tunnel.id)] as const
} catch {
return [tunnel.id, { tunnel_id: tunnel.id, managed: true, has_drift: false, items: [] }] as const
}
}),
)
driftByTunnel.value = Object.fromEntries(driftPairs)
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally {
@@ -261,6 +414,48 @@ async function load() {
}
}
function runtimeStatus(item: CfTunnel): CfRuntimeStatus {
return runtimeStatuses.value[item.id] ?? {
running: false,
status: item.status,
connections: 0,
latency_ms: 0,
bandwidth_in: 0,
bandwidth_out: 0,
transport: '',
}
}
function replicaCount(item: CfTunnel): number {
return runtimeStatus(item).connections || 0
}
function routeCount(item: CfTunnel): number {
return item.hostnames?.length || 0
}
function driftText(item: CfTunnel): string {
if (!item.managed) return '手工'
const drift = driftByTunnel.value[item.id]
if (!drift) return '未知'
return drift.has_drift ? '有漂移' : '已同步'
}
function driftTagType(item: CfTunnel): 'success' | 'warning' | 'default' {
if (!item.managed) return 'default'
return driftByTunnel.value[item.id]?.has_drift ? 'warning' : 'success'
}
async function repairTunnel(item: CfTunnel) {
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则修复云端配置')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '修复失败')
}
}
async function toggleGlobal(value: boolean) {
globalLoading.value = true
try {
@@ -275,6 +470,22 @@ async function toggleGlobal(value: boolean) {
}
}
async function openPreflight() {
preflightVisible.value = true
await loadPreflight()
}
async function loadPreflight() {
preflightLoading.value = true
try {
preflight.value = await api.getCfPreflight()
} catch (error) {
message.error(error instanceof Error ? error.message : '预检失败')
} finally {
preflightLoading.value = false
}
}
async function loadBinary() {
try {
const result = await api.getCfBinary()
@@ -285,32 +496,41 @@ async function loadBinary() {
}
}
async function loadCandidates() {
async function loadZones() {
try {
candidates.value = (await api.getCfIngressCandidates()).hostnames || []
const result = await api.getCfZones()
zones.value = asList(result.zones)
} catch {
candidates.value = []
zones.value = []
}
}
function openCreate() {
editing.value = null
function resetManualRule() {
manualRule.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
}
function manualHostname() {
const subdomain = manualRule.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = manualRule.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
function manualOriginService() {
const type = manualRule.value.type || 'http'
const target = manualRule.value.target.trim().replace(/^[a-z]+:\/\//i, '')
return `${type}://${target}`
}
async function openCreate() {
if (!zones.value.length) await loadZones()
form.value = emptyForm()
resetManualRule()
editorVisible.value = true
}
function openEdit(item: CfTunnel) {
editing.value = item
form.value = {
name: item.name,
mode: item.mode,
token: '',
hostnames: [...(item.hostnames || [])],
origin_service: item.origin_service || 'http://127.0.0.1:80',
network: emptyNetwork(),
auto_start: item.auto_start,
}
editorVisible.value = true
function openConfig(item: CfTunnel) {
void router.push(`/cloudflare/${item.id}/config`)
}
async function saveTunnel() {
@@ -322,21 +542,28 @@ async function saveTunnel() {
message.warning('请先在「应用配置」中填写 Account ID 与 API Token')
return
}
if (!form.value.hostnames.length) {
message.warning('请至少选择一个暴露域名')
return
if (!form.value.managed) {
const hostname = manualHostname()
if (!hostname) {
message.warning('请选择或填写域')
return
}
if (!manualRule.value.target.trim()) {
message.warning('请填写源服务 URL')
return
}
form.value.hostnames = [hostname]
form.value.origin_service = manualOriginService()
}
const payload: CfTunnelPayload = {
...form.value,
mode: editing.value ? form.value.mode : 'account-local',
mode: 'account-local',
token: '',
network: { ...appSettings.value.network },
}
saving.value = true
try {
const result = editing.value
? await api.updateCfTunnel(editing.value.id, payload)
: await api.createCfTunnel(payload)
const result = await api.createCfTunnel(payload)
editorVisible.value = false
if (result.dns_warning) {
message.warning(result.dns_warning)
@@ -385,6 +612,9 @@ async function runAction(label: string, fn: () => Promise<unknown>) {
async function openDetail(item: CfTunnel) {
detailTunnel.value = item
detailDrift.value = null
detailDiagnostics.value = null
detailAccess.value = null
detailVisible.value = true
await refreshDetail()
}
@@ -409,6 +639,85 @@ async function refreshDetail() {
}
}
async function loadDrift() {
const item = detailTunnel.value
if (!item) return
driftLoading.value = true
try {
detailDrift.value = await api.getCfTunnelDrift(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '漂移检测失败')
} finally {
driftLoading.value = false
}
}
async function syncDrift() {
const item = detailTunnel.value
if (!item) return
driftSyncing.value = true
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则覆盖云端配置')
await Promise.all([loadDrift(), refreshDetail(), load()])
} catch (error) {
message.error(error instanceof Error ? error.message : '同步失败')
} finally {
driftSyncing.value = false
}
}
async function loadDiagnostics() {
const item = detailTunnel.value
if (!item) return
diagnosticsLoading.value = true
try {
detailDiagnostics.value = await api.getCfTunnelDiagnostics(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '诊断失败')
} finally {
diagnosticsLoading.value = false
}
}
async function loadAccessStatus() {
const item = detailTunnel.value
const hostname = item?.hostnames?.[0]
if (!hostname) {
message.warning('该隧道暂无暴露域名')
return
}
accessLoading.value = true
try {
detailAccess.value = await api.getCfAccessStatus(hostname)
} catch (error) {
message.error(error instanceof Error ? error.message : 'Access 检测失败,请确认 Token 具备 Access: Apps: Read 权限')
} finally {
accessLoading.value = false
}
}
async function refreshCredentials() {
const item = detailTunnel.value
if (!item) return
credentialLoading.value = true
try {
detailTunnel.value = await api.refreshCfCredentials(item.id)
message.success('隧道凭据已刷新')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '刷新凭据失败')
} finally {
credentialLoading.value = false
}
}
function diagnosticTag(status: string) {
if (status === 'ok') return 'success'
if (status === 'warning') return 'warning'
return 'error'
}
async function copyConfig() {
if (!detailConfig.value) return
try {
@@ -420,6 +729,7 @@ async function copyConfig() {
}
async function openSettings() {
preflightVisible.value = false
settingsVisible.value = true
await loadAppSettings()
}
@@ -463,6 +773,7 @@ async function saveSettings() {
const result = await api.saveCfSettings(settingsForm.value)
appSettings.value = result
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
await loadZones()
message.success('应用配置已保存')
} catch (error) {
message.error(error instanceof Error ? error.message : '保存应用配置失败')
@@ -474,7 +785,7 @@ async function saveSettings() {
async function downloadBinary() {
binaryLoading.value = true
try {
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official')
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official', latestBinaryVersion.value)
binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装')
} catch (error) {
@@ -484,6 +795,41 @@ async function downloadBinary() {
}
}
async function checkLatestBinary() {
binaryChecking.value = true
try {
const result = await api.getLatestCfBinary()
latestBinaryVersion.value = result.version || ''
if (binaryVersion.value.includes(latestBinaryVersion.value)) {
message.success('当前已是最新版本')
} else {
message.info(`发现新版本 ${latestBinaryVersion.value}`)
}
} catch (error) {
message.error(error instanceof Error ? error.message : '检查更新失败')
} finally {
binaryChecking.value = false
}
}
async function rollbackBinary() {
binaryRollbackLoading.value = true
try {
const result = await api.rollbackCfBinary()
binaryVersion.value = result.version || ''
latestBinaryVersion.value = ''
message.success('已回滚 cloudflared')
} catch (error) {
message.error(error instanceof Error ? error.message : '回滚失败')
} finally {
binaryRollbackLoading.value = false
}
}
function displayBinaryVersion(version: string) {
return version.match(/\d{4}\.\d+\.\d+/)?.[0] || version
}
function modeText(mode: string) {
if (mode === 'account-local') return 'API 管理'
if (mode === 'token-remote') return '云端规则'
@@ -512,7 +858,7 @@ onMounted(() => {
void load()
void loadBinary()
void loadAppSettings()
void loadCandidates()
void loadZones()
})
useVisibilityPolling(() => load(), 5000)
@@ -536,6 +882,26 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-global__text span { color: var(--fonu-text-secondary); }
.cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); }
.cf-table { width: 100%; overflow-x: auto; }
.cf-table__head,
.cf-table__row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) 110px 70px minmax(120px, 0.8fr) 80px 90px minmax(340px, 1.8fr);
gap: 12px;
align-items: center;
min-width: 980px;
padding: 10px 0;
}
.cf-table__head {
color: var(--fonu-text-muted);
font-size: 12px;
border-bottom: 1px solid var(--fonu-border);
}
.cf-table__row { border-bottom: 1px solid var(--fonu-border); }
.cf-table__row:last-child { border-bottom: none; }
.cf-table__tunnel { min-width: 0; }
.cf-table__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.cf-table__number { font-size: 14px; font-weight: 600; }
.cf-row {
display: flex;
align-items: center;
@@ -551,6 +917,30 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-mode-alert { margin-bottom: 4px; }
.cf-preflight { display: grid; gap: 10px; }
.cf-preflight__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.cf-preflight__row span { flex: 1; min-width: 0; color: var(--fonu-text-secondary); font-size: 12.5px; }
.cf-managed-field { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--fonu-text-muted); }
.cf-rule-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.cf-rule-row__subdomain,
.cf-rule-row__domain { min-width: 0; }
.cf-hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
}
.cf-hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.cf-hostname-preview strong { color: var(--fonu-text); }
.cf-origin-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.cf-origin-row__type { width: 126px; flex: none; }
.cf-origin-row__scheme { color: var(--fonu-text-muted); flex: none; }
.cf-diagnostics { display: grid; gap: 8px; margin-top: 10px; }
.cf-diagnostic-row { display: flex; align-items: flex-start; gap: 10px; font-size: 12.5px; line-height: 1.5; }
.cf-detail__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.cf-stat label { font-size: 12px; color: var(--fonu-text-muted); }
+9
View File
@@ -746,6 +746,15 @@ const healthItems = computed(() => [
icon: GlobeOutline,
tone: 'green',
},
{
name: 'Cloudflare 隧道',
desc: '无公网 IP 的 Cloudflare 出口',
meta: status.value?.cloudflare_message || '未创建隧道',
status: status.value?.cloudflare_status,
label: status.value?.cloudflare_status === 'ok' ? '运行中' : status.value?.cloudflare_status === 'warning' ? '部分在线' : status.value?.cloudflare_status === 'none' ? '未配置' : '需关注',
icon: CloudOutline,
tone: 'blue',
},
])
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
+101 -3
View File
@@ -66,6 +66,26 @@
/>
</n-tab-pane>
<n-tab-pane name="cloudflare" tab="Cloudflare 隧道">
<LogToolbar v-model:keyword="cfKeyword" @refresh="loadCfLogs" />
<div class="remote-nginx-bar">
<n-select
v-model:value="cfTunnelId"
:options="cfTunnelOptions"
placeholder="选择 Cloudflare 隧道"
size="small"
class="remote-nginx-bar__server"
/>
<n-select v-model:value="cfLines" :options="cfLineOptions" size="small" style="width: 110px" />
</div>
<pre v-if="filteredCfLogLines.length" class="cf-log-pre">{{ filteredCfLogLines.join('\n') }}</pre>
<EmptyState
v-else-if="!cfLoading"
title="暂无 Cloudflare 隧道日志"
:description="cfError || '选择隧道并启动 cloudflared 后,连接与错误日志会显示在这里。'"
/>
</n-tab-pane>
<n-tab-pane name="remote-nginx" tab="公网 Nginx">
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
<div class="remote-nginx-bar">
@@ -178,7 +198,7 @@ import {
} from 'naive-ui'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import type { AccessLogEntry, CfTunnel, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
@@ -282,7 +302,7 @@ const LogToolbar = defineComponent({
const message = useMessage()
const route = useRoute()
const LOG_TABS = ['system', 'frp', 'remote-nginx', 'access', 'nginx'] as const
const LOG_TABS = ['system', 'frp', 'cloudflare', 'remote-nginx', 'access', 'nginx'] as const
type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab {
@@ -408,6 +428,13 @@ const frpKeyword = ref('')
const frpLevel = ref('')
const frpPage = ref(1)
const loadingFrp = ref(false)
const cfTunnels = ref<CfTunnel[]>([])
const cfTunnelId = ref<number | null>(null)
const cfLines = ref(200)
const cfKeyword = ref('')
const cfRawLog = ref('')
const cfLoading = ref(false)
const cfError = ref('')
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
@@ -512,6 +539,60 @@ function frpRowClassName(row: FrpLogEntry) {
return 'log-row'
}
const cfTunnelOptions = computed(() =>
cfTunnels.value.map((tunnel) => ({
label: `${tunnel.name}${tunnel.status === 'connected' ? '(已连接)' : ''}`,
value: tunnel.id,
})),
)
const cfLineOptions = [
{ label: '200 行', value: 200 },
{ label: '500 行', value: 500 },
{ label: '2000 行', value: 2000 },
]
const filteredCfLogLines = computed(() => {
const keyword = cfKeyword.value.trim().toLowerCase()
const lines = cfRawLog.value ? cfRawLog.value.split('\n') : []
if (!keyword) return lines
return lines.filter((line) => line.toLowerCase().includes(keyword))
})
async function loadCfTunnels() {
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
if (cfTunnelId.value === null || !cfTunnels.value.some((tunnel) => tunnel.id === cfTunnelId.value)) {
cfTunnelId.value = cfTunnels.value[0]?.id ?? null
}
}
async function loadCfLogs() {
if (cfTunnelId.value === null) {
cfRawLog.value = ''
cfError.value = '还没有 Cloudflare 隧道'
return
}
cfLoading.value = true
cfError.value = ''
try {
const result = await api.getCfTunnelLogs(cfTunnelId.value, cfLines.value)
cfRawLog.value = result.output || ''
} catch (error) {
cfRawLog.value = ''
cfError.value = error instanceof Error ? error.message : '读取 Cloudflare 日志失败'
} finally {
cfLoading.value = false
}
}
watch([cfTunnelId, cfLines], () => {
if (cfTunnelId.value !== null) void loadCfLogs()
})
// 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
const nginxTargets = ref<import('../api/types').FrpServer[]>([])
const nginxTargetId = ref<number | null>(null)
@@ -780,7 +861,7 @@ async function loadSystemLogs() {
async function loadAll() {
pageError.value = ''
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs(), loadCfTunnels()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
}
@@ -815,6 +896,9 @@ watch(tab, (name) => {
if (name === 'access') loadAccess()
else if (name === 'nginx') loadErrorLogs()
else if (name === 'system') loadSystemLogs()
else if (name === 'cloudflare') {
void loadCfTunnels().then(() => loadCfLogs())
}
})
onMounted(() => {
@@ -929,6 +1013,20 @@ onMounted(() => {
margin-bottom: var(--fonu-space-2);
}
.cf-log-pre {
max-height: 620px;
margin: 0 var(--fonu-space-5);
padding: var(--fonu-space-4);
overflow: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.remote-nginx-bar__server {
width: 200px;
}
+201 -17
View File
@@ -104,7 +104,7 @@
:data="tableRules"
:bordered="false"
size="small"
:scroll-x="canReorder ? 1460 : 1420"
:scroll-x="canReorder ? 1610 : 1570"
:row-key="(r: ProxyRule) => r.id"
:row-props="rowProps"
/>
@@ -130,6 +130,28 @@
</div>
</template>
<n-modal v-model:show="discoveryVisible" preset="card" :style="{ width: 'min(680px, 96vw)' }" title="发现本机服务">
<div class="discovery-list">
<div v-for="item in discoveryResults" :key="item.platform + item.port" class="discovery-item">
<div class="discovery-item__main">
<strong>{{ item.name }}</strong>
<span class="mono">{{ item.upstream }}</span>
<n-tag size="small" :type="item.detected ? 'success' : 'default'" :bordered="false">
{{ item.detected ? '检测到服务' : '端口未开放' }}
</n-tag>
</div>
<n-button size="small" type="primary" :disabled="!item.detected" @click="publishDiscovered(item)">
发布
</n-button>
</div>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="discoveryVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="showDetailPanel" :mask-closable="true" transform-origin="center">
<div v-if="selectedRule" class="proxy-detail-modal">
<div class="proxy-detail-modal__header">
@@ -206,6 +228,10 @@
</n-tag>
</span>
</div>
<div class="overview-strip__item">
<span class="overview-strip__label">出口</span>
<span class="overview-strip__value">{{ exitLabel(selectedRule) }}</span>
</div>
</div>
<div class="overview-security">
@@ -453,6 +479,33 @@
</div>
</n-form-item>
<n-form-item label="出口">
<div class="field-stack">
<div class="exit-options">
<n-checkbox v-model:checked="form.exit_local">本机 Nginx</n-checkbox>
<n-checkbox v-model:checked="form.exit_cloudflare">Cloudflare 隧道</n-checkbox>
</div>
<p class="field-hint">可多选;关闭本机出口后,该规则只通过 Cloudflare 隧道访问。</p>
</div>
</n-form-item>
<n-form-item v-if="form.exit_cloudflare" label="Cloudflare 隧道">
<div class="field-stack">
<n-select
v-model:value="form.cf_tunnel_id"
:options="cfTunnelOptions"
placeholder="选择自动托管隧道"
/>
<p v-if="cfTunnelOptions.length === 0" class="field-hint field-hint--warning">
还没有自动托管隧道,请先到
<router-link to="/cloudflare">Cloudflare 隧道</router-link>
新建并开启「自动托管」。
</p>
<p class="field-hint">域名的根 Zone 必须已加入当前 Cloudflare 账号,且 API Token 的 Zone Resources 包含该域名。</p>
<p v-if="!form.exit_local" class="field-hint">仅走 Cloudflare 出口时,HTTPS 由 Cloudflare 边缘提供,不需要在 Fonu 申请本机证书。</p>
</div>
</n-form-item>
<div class="form-switch-list form-switch-list--compact">
<div class="form-switch-row">
<div class="form-switch-row__text">
@@ -859,7 +912,7 @@ import {
WarningOutline,
} from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
@@ -876,6 +929,9 @@ import { renderTableRowActions } from '../utils/tableActions'
const message = useMessage()
const dialog = useDialog()
const rules = ref<ProxyRule[]>([])
const cfTunnels = ref<CfTunnel[]>([])
const discoveryVisible = ref(false)
const discoveryResults = ref<DiscoveredService[]>([])
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
@@ -930,6 +986,15 @@ const httpsOptions = [
{ label: 'HTTP', value: 'off' },
]
const cfTunnelOptions = computed(() =>
cfTunnels.value
.filter((tunnel) => tunnel.managed)
.map((tunnel) => ({
label: `${tunnel.name}${tunnel.status === 'connected' ? '(已连接)' : ''}`,
value: tunnel.id,
})),
)
const defaultSecurityForm = () => ({
ip_blacklist_text: '',
ip_blacklist_mode: false,
@@ -960,6 +1025,9 @@ const form = reactive({
http_redirect: true,
enabled: true,
name: '',
exit_local: true,
exit_cloudflare: false,
cf_tunnel_id: null as number | null,
...defaultSecurityForm(),
})
@@ -1374,6 +1442,17 @@ function ruleTitle(rule: ProxyRule): string {
return ruleName(rule)
}
function ruleExits(rule: ProxyRule): string[] {
return rule.exits?.length ? rule.exits : ['local']
}
function exitLabel(rule: ProxyRule): string {
const labels: string[] = []
if (ruleExits(rule).includes('local')) labels.push('本机 Nginx')
if (ruleExits(rule).includes('cloudflare')) labels.push('Cloudflare')
return labels.join(' + ') || '未设置'
}
function listenLabel(rule: ProxyRule): string {
const stacks = []
if (rule.listen_ipv4) stacks.push('IPv4')
@@ -1399,7 +1478,14 @@ function hostAccessUrl(rule: ProxyRule, hostPart: string): string {
}
function ruleAccessUrls(rule: ProxyRule): string[] {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
const exits = ruleExits(rule)
if (exits.includes('local')) {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
}
if (exits.includes('cloudflare')) {
return ruleHosts(rule).map((host) => `https://${host.replace(/:\d+$/, '')}`)
}
return []
}
import { copyText } from '../utils/clipboard'
@@ -1548,6 +1634,26 @@ function renderProtocol(row: ProxyRule): VNode {
return h('div', { class: 'proto-tags' }, tags)
}
function renderExits(row: ProxyRule): VNode {
const exits = ruleExits(row)
return h(
'div',
{ class: 'proto-tags' },
exits.map((exit) =>
h(
NTag,
{
size: 'small',
type: exit === 'cloudflare' ? 'info' : 'default',
bordered: false,
round: true,
},
() => (exit === 'cloudflare' ? 'Cloudflare' : '本机'),
),
),
)
}
function rowProps(row: ProxyRule) {
return {
class: showDetailPanel.value && selectedRuleId.value === row.id ? 'proxy-row--active' : '',
@@ -1599,6 +1705,12 @@ const columns = computed<DataTableColumns<ProxyRule>>(() => {
width: 88,
render: (row) => renderProtocol(row),
},
{
title: '出口',
key: 'exits',
width: 150,
render: (row) => renderExits(row),
},
{
title: '安全',
key: 'security',
@@ -1825,6 +1937,11 @@ async function load() {
loadError.value = ''
try {
rules.value = asList(await api.listProxies())
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} finally {
@@ -1842,9 +1959,19 @@ function resetForm() {
form.http_redirect = true
form.enabled = true
form.name = ''
form.exit_local = true
form.exit_cloudflare = false
form.cf_tunnel_id = null
Object.assign(form, defaultSecurityForm())
}
function loadExitsToForm(rule?: ProxyRule | null) {
const exits = rule?.exits?.length ? rule.exits : ['local']
form.exit_local = exits.includes('local')
form.exit_cloudflare = exits.includes('cloudflare')
form.cf_tunnel_id = rule?.cf_tunnel_id || null
}
function loadSecurityToForm(rule?: ProxyRule | null) {
const sec = rule?.security ?? {}
form.ip_blacklist_text = (sec.ip_blacklist ?? []).join('\n')
@@ -1923,6 +2050,16 @@ function buildPayload(): ProxySavePayload {
if (!form.listen_ipv4 && !form.listen_ipv6) {
throw new Error('至少需要启用 IPv4 或 IPv6 监听')
}
if (!form.exit_local && !form.exit_cloudflare) {
throw new Error('至少需要选择一个出口')
}
if (form.exit_cloudflare && !form.cf_tunnel_id) {
throw new Error('请选择 Cloudflare 隧道')
}
const exits = [
...(form.exit_local ? ['local'] : []),
...(form.exit_cloudflare ? ['cloudflare'] : []),
]
return {
upstream: form.upstream,
listen_port: form.listen_port,
@@ -1934,6 +2071,8 @@ function buildPayload(): ProxySavePayload {
enabled: form.enabled,
name: form.name.trim(),
security: buildSecurityPayload(),
exits,
cf_tunnel_id: form.exit_cloudflare ? form.cf_tunnel_id! : 0,
}
}
@@ -1941,15 +2080,9 @@ async function scanServices() {
scanning.value = true
try {
const items = await api.scanDiscovery('127.0.0.1')
const found = items.find((i) => i.detected)
if (found) {
resetForm()
form.upstream = found.upstream
showModal.value = true
message.success(`检测到 ${found.name},请填写前端域名`)
} else {
message.info('未检测到常见服务')
}
discoveryResults.value = asList(items)
discoveryVisible.value = true
if (!items.some((item) => item.detected)) message.info('未检测到常见服务,可检查端口或手动填写目标地址')
} catch (error) {
message.error(error instanceof Error ? error.message : '扫描失败')
} finally {
@@ -1957,6 +2090,18 @@ async function scanServices() {
}
}
function publishDiscovered(item: DiscoveredService) {
discoveryVisible.value = false
openCreate()
form.name = item.name
form.upstream = item.upstream
if (cfTunnelOptions.value.length > 0) {
form.exit_cloudflare = true
form.cf_tunnel_id = Number(cfTunnelOptions.value[0].value)
}
message.info('已填入服务信息,请填写域名后保存')
}
function openCreate() {
editing.value = null
formTab.value = 'basic'
@@ -1973,7 +2118,11 @@ async function toggleRuleEnabled(row: ProxyRule, enabled: boolean) {
try {
const updated = await api.updateProxy(row.id, { enabled })
rules.value = rules.value.map((rule) => (rule.id === updated.id ? updated : rule))
message.success(enabled ? '已启用' : '已停用')
if (updated.sync_warning) {
message.warning(updated.sync_warning)
} else {
message.success(enabled ? '已启用' : '已停用')
}
} catch (error) {
rules.value = rules.value.map((rule) => (rule.id === row.id ? { ...rule, enabled: prev } : rule))
const msg = error instanceof Error ? error.message : '更新失败'
@@ -2002,6 +2151,7 @@ function openDuplicate(rule: ProxyRule) {
enabled: rule.enabled,
name: duplicateName(rule),
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
formTab.value = 'basic'
@@ -2024,6 +2174,7 @@ function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic'
enabled: rule.enabled,
name: rule.name ?? '',
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
if (tab === 'nginx') {
@@ -2097,11 +2248,13 @@ async function save() {
return
}
if (editing.value) {
await api.updateProxy(editing.value.id, payload)
message.success('规则已保存')
const result = await api.updateProxy(editing.value.id, payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已保存')
} else {
await api.createProxy(payload)
message.success('规则已创建')
const result = await api.createProxy(payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已创建')
}
showModal.value = false
await load()
@@ -3287,6 +3440,37 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
line-height: 1.5;
}
.field-hint--warning { color: var(--fonu-warning); }
.exit-options {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.discovery-list {
display: grid;
gap: 8px;
}
.discovery-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
}
.discovery-item__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.proxy-modal__pane :deep(.n-form-item .n-form-item-blank) {
display: block;
width: 100%;
+21 -4
View File
@@ -19,10 +19,21 @@
<p v-else-if="error" class="status-page__error">{{ error }}</p>
<div v-if="page && page.routes.length" class="status-page__list">
<div v-for="route in page.routes" :key="route.domain" class="status-row">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__domain mono">{{ route.domain }}</span>
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
<div v-for="route in page.routes" :key="route.domain" class="status-route">
<div class="status-row">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__domain mono">{{ route.domain }}</span>
<span v-if="route.source" class="status-row__source">{{ route.source === 'cloudflare' ? 'Cloudflare' : '公网反代' }}</span>
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
</div>
<div v-if="route.incidents?.length" class="incident-list">
<div v-for="incident in route.incidents.slice(0, 3)" :key="incident.from" class="incident-row">
<span class="incident-row__dot" />
<span class="mono">{{ formatDate(incident.from) }} 起</span>
<span>{{ incident.minutes }} 分钟</span>
<span>{{ incident.detail || incident.reason || '服务不可用' }}</span>
</div>
</div>
</div>
</div>
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
@@ -107,7 +118,13 @@ onMounted(load)
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
}
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.status-row__source { flex: none; font-size: 11.5px; color: var(--fonu-text-muted); }
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--fonu-text-secondary); }
.status-route { border-bottom: 1px solid var(--fonu-border); }
.status-route:last-child { border-bottom: none; }
.incident-list { display: grid; gap: 5px; padding: 0 var(--fonu-space-3) 10px; }
.incident-row { display: flex; align-items: center; gap: 10px; color: var(--fonu-text-muted); font-size: 12px; flex-wrap: wrap; }
.incident-row__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fonu-error); }
.status-page__hint { margin: var(--fonu-space-3) 0 0; font-size: 12.5px; color: var(--fonu-text-muted); }
.status-page__hint--foot { margin-top: var(--fonu-space-5); }
</style>