feat(cloudflare): 完成隧道接入、运维与 P0 体验优化
CI / verify (push) Canceled after 0s

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 文档索引 # 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 兼容清理待后续 | | `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-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-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 实施中 | | `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | | `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | | `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
+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) 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)) { func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { 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}) 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) { func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(), "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) { func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct { 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 { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效") writeError(r, w, http.StatusBadRequest, "请求格式无效")
return 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 { if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
@@ -248,6 +367,25 @@ func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts}) 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 { func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings() defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" { if strings.TrimSpace(network.TransportProtocol) == "" {
+17
View File
@@ -36,6 +36,8 @@ type proxyRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Remark *string `json:"remark"` // deprecated alias for name Remark *string `json:"remark"` // deprecated alias for name
Security *securityRequest `json:"security,omitempty"` Security *securityRequest `json:"security,omitempty"`
Exits []string `json:"exits"`
CFTunnelID *int64 `json:"cf_tunnel_id"`
} }
type proxyReorderRequest struct { type proxyReorderRequest struct {
@@ -80,6 +82,8 @@ func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
Enabled: boolDefault(req.Enabled, true), Enabled: boolDefault(req.Enabled, true),
Name: name, Name: name,
Security: req.securityInput(), Security: req.securityInput(),
Exits: req.Exits,
CFTunnelID: int64Default(req.CFTunnelID, 0),
} }
rule, err := h.svc.Create(r.Context(), in) rule, err := h.svc.Create(r.Context(), in)
@@ -135,6 +139,12 @@ func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
sec := req.securityInput() sec := req.securityInput()
in.Security = &sec in.Security = &sec
} }
if req.Exits != nil {
in.Exits = &req.Exits
}
if req.CFTunnelID != nil {
in.CFTunnelID = req.CFTunnelID
}
rule, err := h.svc.Update(r.Context(), id, in) rule, err := h.svc.Update(r.Context(), id, in)
if err != nil { if err != nil {
@@ -258,3 +268,10 @@ func boolDefault(v *bool, fallback bool) bool {
} }
return *v return *v
} }
func int64Default(v *int64, fallback int64) int64 {
if v == nil {
return fallback
}
return *v
}
+14 -2
View File
@@ -28,14 +28,14 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
authHandler: NewAuthHandler(deps.Auth, deps.Notify), authHandler: NewAuthHandler(deps.Auth, deps.Notify),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic), proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy), nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile), statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
ddnsHandler: NewDDNSHandler(deps.DDNS), ddnsHandler: NewDDNSHandler(deps.DDNS),
certHandler: NewCertHandler(deps.ACME), certHandler: NewCertHandler(deps.ACME),
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify), settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR), chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
logsHandler: logsHandler, logsHandler: logsHandler,
backupHandler: NewBackupHandler(deps.Backup, deps.Settings), backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti), statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
metricsHandler: NewMetricsHandler(deps.Metrics), metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery), discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth), 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}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop) protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart) 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}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs) protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config) 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("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary) 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("GET /api/cloudflare/settings", cf.AppSettings)
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings) protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
protect("POST /api/cloudflare/settings/test", cf.TestToken) protect("POST /api/cloudflare/settings/test", cf.TestToken)
protect("POST /api/cloudflare/enabled", cf.SetEnabled) protect("POST /api/cloudflare/enabled", cf.SetEnabled)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates) protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
protect("GET /api/cloudflare/zones", cf.Zones)
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
protect("GET /api/cloudflare/preflight", cf.Preflight)
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
} }
if deps.StaticFS != nil { if deps.StaticFS != nil {
+31 -16
View File
@@ -13,6 +13,7 @@ import (
"github.com/fonu/fonu/internal/acme" "github.com/fonu/fonu/internal/acme"
certstore "github.com/fonu/fonu/internal/certificate" certstore "github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns" "github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/logstore" "github.com/fonu/fonu/internal/logstore"
@@ -22,27 +23,29 @@ import (
) )
type StatusHandler struct { type StatusHandler struct {
cfg config.Config cfg config.Config
proxySvc *service.ProxyService proxySvc *service.ProxyService
ddnsSvc *ddns.Service ddnsSvc *ddns.Service
acmeSvc *acme.Service acmeSvc *acme.Service
startedAt string cloudflareSvc *cloudflared.Service
nginxPIDFile string startedAt string
cpuMu sync.Mutex nginxPIDFile string
cpuSample sysinfo.CPUSample 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) // 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
_, sample := sysinfo.Read(sysinfo.CPUSample{}) _, sample := sysinfo.Read(sysinfo.CPUSample{})
return &StatusHandler{ return &StatusHandler{
cfg: cfg, cfg: cfg,
proxySvc: proxySvc, proxySvc: proxySvc,
ddnsSvc: ddnsSvc, ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc, acmeSvc: acmeSvc,
startedAt: startedAt, cloudflareSvc: cloudflareSvc,
nginxPIDFile: nginxPIDFile, startedAt: startedAt,
cpuSample: sample, nginxPIDFile: nginxPIDFile,
cpuSample: sample,
} }
} }
@@ -71,6 +74,10 @@ type statusResponse struct {
MemTotalBytes uint64 `json:"mem_total_bytes"` MemTotalBytes uint64 `json:"mem_total_bytes"`
DiskUsedBytes uint64 `json:"disk_used_bytes"` DiskUsedBytes uint64 `json:"disk_used_bytes"`
DiskTotalBytes uint64 `json:"disk_total_bytes"` DiskTotalBytes uint64 `json:"disk_total_bytes"`
CloudflareCount int `json:"cloudflare_count"`
CloudflareOnline int `json:"cloudflare_online"`
CloudflareStatus string `json:"cloudflare_status"`
CloudflareMessage string `json:"cloudflare_message,omitempty"`
StartedAt string `json:"started_at"` StartedAt string `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
} }
@@ -97,6 +104,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
resources, cpuSample := sysinfo.Read(h.currentCPUSample()) resources, cpuSample := sysinfo.Read(h.currentCPUSample())
h.setCPUSample(cpuSample) h.setCPUSample(cpuSample)
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
if h.cloudflareSvc != nil {
cfSummary = h.cloudflareSvc.Summary(r.Context())
}
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log") accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
total, errors, avgMs := logstore.CountTodayAccess(accessLog) total, errors, avgMs := logstore.CountTodayAccess(accessLog)
@@ -131,6 +142,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
MemTotalBytes: resources.MemTotalBytes, MemTotalBytes: resources.MemTotalBytes,
DiskUsedBytes: resources.DiskUsedBytes, DiskUsedBytes: resources.DiskUsedBytes,
DiskTotalBytes: resources.DiskTotalBytes, DiskTotalBytes: resources.DiskTotalBytes,
CloudflareCount: cfSummary.Count,
CloudflareOnline: cfSummary.Online,
CloudflareStatus: cfSummary.Status,
CloudflareMessage: cfSummary.Message,
StartedAt: h.startedAt, StartedAt: h.startedAt,
UptimeSeconds: int64(uptime), UptimeSeconds: int64(uptime),
}) })
+100 -21
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"net" "net"
@@ -9,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/notify" "github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/settings" "github.com/fonu/fonu/internal/settings"
@@ -26,25 +28,37 @@ const (
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址—— // 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。 // 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
type StatusPageHandler struct { type StatusPageHandler struct {
settings *settings.Store settings *settings.Store
frp *frp.Service frp *frp.Service
limiter *statusPageLimiter 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{ return &StatusPageHandler{
settings: settingsStore, settings: settingsStore,
frp: frpSvc, frp: frpSvc,
limiter: newStatusPageLimiter(), cloudflare: cloudflareSvc,
limiter: newStatusPageLimiter(),
} }
} }
type statusPageRoute struct { type statusPageRoute struct {
Domain string `json:"domain"` Domain string `json:"domain"`
State string `json:"state"` State string `json:"state"`
Uptime float64 `json:"uptime_pct"` Source string `json:"source,omitempty"`
DownMinutes int `json:"down_minutes"` Uptime float64 `json:"uptime_pct"`
UnknownMinutes int `json:"unknown_minutes"` 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 { type statusPagePayload struct {
@@ -70,7 +84,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled) enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
if !enabled || h.frp == nil { if !enabled || (h.frp == nil && h.cloudflare == nil) {
writeError(r, w, http.StatusNotFound, "状态页未开启") writeError(r, w, http.StatusNotFound, "状态页未开启")
return return
} }
@@ -84,17 +98,30 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
payload := statusPagePayload{ payload := statusPagePayload{
Title: "Fonu 服务状态", Title: "Fonu 服务状态",
CodeRequired: codeRequired, CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339), CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0, len(stats)), Routes: make([]statusPageRoute, 0),
}
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
if err != nil {
return err
} }
for _, stat := range stats { for _, stat := range stats {
switch stat.State { switch stat.State {
@@ -108,12 +135,64 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
payload.Routes = append(payload.Routes, statusPageRoute{ payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain, Domain: stat.Domain,
State: stat.State, State: stat.State,
Source: "frp",
Uptime: stat.Uptime, Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes, DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes, UnknownMinutes: stat.UnknownMinutes,
Incidents: mapFRPIncidents(stat.Incidents),
}) })
} }
writeJSON(w, http.StatusOK, payload) return nil
}
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case cloudflared.HealthUp:
payload.Up++
case cloudflared.HealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "cloudflare",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapCloudflareIncidents(stat.Incidents),
})
}
return nil
}
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
} }
func hashStatusPageCode(code string) string { func hashStatusPageCode(code string) string {
+3 -2
View File
@@ -119,6 +119,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
conn.Close() conn.Close()
return nil, err return nil, err
} }
proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339) startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket) updaterClient := updater.NewClient(cfg.UpdaterSocket)
@@ -156,7 +157,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
frpMultiSvc.SetBaseContext(ctx) frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx) go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx) go frpMultiSvc.StartAgentCertificateRenewal(ctx)
go cloudflareSvc.StartAuto(ctx) go cloudflareSvc.StartAll(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 intervalMinutes = 5
@@ -166,7 +167,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
chinaHours = 24 chinaHours = 24
} }
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定 // 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore) monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份 // 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger) backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New( sched := scheduler.New(
+54 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/monitor" "github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/traffic" "github.com/fonu/fonu/internal/traffic"
@@ -13,9 +14,59 @@ import (
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」, // monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。 // 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
type monitorSource struct { type monitorSource struct {
frp *frp.Service frp *frp.Service
logger *slog.Logger cloudflare *cloudflared.Service
traffic *traffic.Collector 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) { 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) gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw) tw := tar.NewWriter(gw)
for _, entry := range []string{"fonu.db", "certs", "nginx"} { for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} {
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil { if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard() discard()
return err return err
+40
View File
@@ -88,3 +88,43 @@ func TestRestoreAppliesFixedMode(t *testing.T) {
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got) t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
} }
} }
func TestExportIncludesCloudflareTunnelConfig(t *testing.T) {
dataDir := t.TempDir()
tunnelDir := filepath.Join(dataDir, "cloudflared", "tunnels", "home")
if err := os.MkdirAll(tunnelDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tunnelDir, "config.yml"), []byte("tunnel: test\n"), 0o640); err != nil {
t.Fatal(err)
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
found := false
for {
header, err := tr.Next()
if err != nil {
break
}
if header.Name == "cloudflared/tunnels/home/config.yml" {
found = true
break
}
}
if !found {
t.Fatal("备份应包含 Cloudflare 隧道配置")
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (s *Service) Update(ctx context.Context) error {
return err return err
} }
rules, err := s.proxyStore.ListEnabled(ctx) rules, err := s.proxyStore.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
+67 -1
View File
@@ -2,6 +2,7 @@ package cloudflared
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -86,6 +87,11 @@ func (s *Service) BinaryVersion() string {
// DownloadBinary 下载并替换本机 cloudflared 二进制。 // DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) { 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) mirror := findMirror(mirrorID)
target := s.binaryPath() target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 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" tmp := target + ".download"
_ = os.Remove(tmp) _ = 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) _ = os.Remove(tmp)
return "", err return "", err
} }
@@ -109,13 +115,73 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string,
_ = os.Remove(tmp) _ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out))) 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 { if err := os.Rename(tmp, target); err != nil {
_ = os.Rename(previous, target)
_ = os.Remove(tmp) _ = os.Remove(tmp)
return "", err return "", err
} }
return s.BinaryVersion(), nil 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 { func fetchFile(ctx context.Context, url, path string) error {
client := &http.Client{Timeout: 5 * time.Minute} client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 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) }, 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 删除云端隧道。 // DeleteTunnel 删除云端隧道。
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error { 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) 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"` 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。 // FindZone 从 hostname 逐级向上查找所属 Zone。
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) { func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
labels := strings.Split(strings.Trim(hostname, "."), ".") 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 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 记录,并开启代理(橙云)。 // EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) error { // 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
var records []struct { 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) path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil { if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
return err return "", err
} }
payload := map[string]any{ payload := map[string]any{
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1, "type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
} }
if len(records) > 0 { 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" "fmt"
"sort" "sort"
"strings" "strings"
"github.com/fonu/fonu/internal/proxy"
) )
type tokenPayload struct { type tokenPayload struct {
@@ -86,6 +88,9 @@ func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []
for _, rule := range ingress { for _, rule := range ingress {
if rule.Hostname != "" { if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", 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) fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue continue
} }
@@ -144,6 +149,33 @@ func BuildIngress(hostnames []string, service string) []IngressRule {
return append(out, IngressRule{Service: "http_status:404"}) 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。 // NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
func NormalizeService(protocol, host string, port int) string { func NormalizeService(protocol, host string, port int) string {
host = strings.TrimSpace(host) host = strings.TrimSpace(host)
+45
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64" "encoding/base64"
"strings" "strings"
"testing" "testing"
"github.com/fonu/fonu/internal/proxy"
) )
func TestDecodeToken(t *testing.T) { 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) { func TestBuildConfig(t *testing.T) {
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80")) config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
for _, want := range []string{ 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" "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 { type processState struct {
cmd *exec.Cmd 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) { func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080") t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"}) env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
+3
View File
@@ -33,6 +33,7 @@ func DefaultNetworkSettings() NetworkSettings {
// IngressRule 对应 config.yml 的 ingress 条目。 // IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct { type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"` Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Service string `json:"service" yaml:"service"` Service string `json:"service" yaml:"service"`
} }
@@ -53,6 +54,7 @@ type Tunnel struct {
LastError string `json:"last_error"` LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"` MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"` AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
Hostnames []string `json:"hostnames"` Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"` OriginService string `json:"origin_service"`
DNSWarning string `json:"dns_warning,omitempty"` DNSWarning string `json:"dns_warning,omitempty"`
@@ -69,6 +71,7 @@ type CreateInput struct {
OriginService string `json:"origin_service"` OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"` Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"` AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
} }
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。 // 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 origin = defaultOriginService
} }
ingress := BuildIngress(in.Hostnames, origin) 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 { if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID) _ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err return Tunnel{}, err
@@ -120,8 +123,15 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
_ = api.DeleteTunnel(ctx, tunnelID) _ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err 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) _ = 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()) s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + 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 { if err != nil {
return Tunnel{}, err 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) _ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network) in.Network = s.applyDefaultNetwork(ctx, in.Network)
origin := strings.TrimSpace(in.OriginService) origin := strings.TrimSpace(in.OriginService)
@@ -141,24 +156,31 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
origin = defaultOriginService origin = defaultOriginService
} }
ingress := BuildIngress(in.Hostnames, origin) 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 { if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err 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, updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath) current.ConfigPath, current.CredsPath, current.LogPath)
if err != nil { if err != nil {
return Tunnel{}, err 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 updated.DNSWarning = dnsWarning
return updated, nil return updated, nil
} }
@@ -168,9 +190,13 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
if err != nil { if err != nil {
return err return err
} }
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return fmt.Errorf("仍有 %d 条反向代理规则使用该隧道,请先移除这些规则的 Cloudflare 出口", count)
}
_ = s.manager.Stop(id) _ = s.manager.Stop(id)
if current.TunnelID != "" { if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil { if api, err := s.cloudflareAPI(ctx); err == nil {
s.deleteTunnelDNS(ctx, api, current.ID)
_ = api.DeleteTunnel(ctx, current.TunnelID) _ = 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) 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) { func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
settings, err := s.store.GetSettingsRaw(ctx) settings, err := s.store.GetSettingsRaw(ctx)
if err != nil { if err != nil {
@@ -223,6 +266,40 @@ func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
return s.Start(ctx, id) return s.Start(ctx, id)
} }
// RefreshCredentials 重新从 Cloudflare 获取隧道凭据并更新本地 creds.json。
func (s *Service) RefreshCredentials(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
token, err := api.TunnelToken(ctx, tunnel.TunnelID)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.CredsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := s.store.UpdateCredentials(ctx, id, encrypted); err != nil {
return Tunnel{}, err
}
if s.manager.Status(id).Running {
return s.Restart(ctx, id)
}
return s.Get(ctx, id)
}
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) { func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
if _, err := s.store.Get(ctx, id); err != nil { if _, err := s.store.Get(ctx, id); err != nil {
return RuntimeStatus{}, err return RuntimeStatus{}, err
@@ -301,7 +378,7 @@ func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, er
return AppSettings{}, err return AppSettings{}, err
} }
if enabled { if enabled {
s.StartAuto(ctx) s.StartAll(ctx)
} else { } else {
s.StopAll(ctx) s.StopAll(ctx)
} }
@@ -353,15 +430,131 @@ func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
return NewCloudflareAPI(accountID, token), nil return NewCloudflareAPI(accountID, token), nil
} }
// ensureTunnelDNS 为所有 hostname 幂等创建或更新指向隧道的 CNAME 记录。 // SyncRules 让所有托管隧道从反向代理规则重新派生 ingress 与 DNS。
func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunnelID string, hostnames []string) error { func (s *Service) SyncRules(ctx context.Context) error {
target := tunnelID + ".cfargotunnel.com" 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 var firstErr error
for _, raw := range hostnames { for _, tunnel := range tunnels {
hostname := strings.ToLower(strings.TrimSpace(raw)) if !tunnel.Managed {
if hostname == "" {
continue 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) zoneID, _, err := api.FindZone(ctx, hostname)
if err != nil { if err != nil {
if firstErr == nil { if firstErr == nil {
@@ -369,13 +562,71 @@ func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunne
} }
continue 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 firstErr = err
} }
} }
return firstErr 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 接口。 // VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
// accountID / input 为空时回退到应用配置中已保存的值。 // accountID / input 为空时回退到应用配置中已保存的值。
func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) { 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) 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,供编辑弹窗使用。 // attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
func (s *Service) attachConfig(item *Tunnel) { func (s *Service) attachConfig(item *Tunnel) {
data, err := os.ReadFile(item.ConfigPath) 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 { func (s *Service) tunnelDir(name string) string {
safe := strings.Map(func(r rune) rune { safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { 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: default:
return fmt.Errorf("不支持的隧道模式: %s", in.Mode) 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 return nil
} }
+26
View File
@@ -74,3 +74,29 @@ func TestSetEnabledBlocksStart(t *testing.T) {
t.Fatalf("重新启用失败:%v %#v", err, settings) 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(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''), COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''), 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` FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) { 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) network, _ := json.Marshal(in.Network)
result, err := s.db.ExecContext(ctx, ` result, err := s.db.ExecContext(ctx, `
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc, 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) config_path, creds_path, log_path, network_json, status, auto_start, managed, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc, `, 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 { if err != nil {
return Tunnel{}, mapUniqueName(err) return Tunnel{}, mapUniqueName(err)
} }
@@ -72,10 +72,10 @@ func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID,
UPDATE cf_tunnels UPDATE cf_tunnels
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?, SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?, 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 = ? WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc, `, 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 { if err != nil {
return Tunnel{}, mapUniqueName(err) return Tunnel{}, mapUniqueName(err)
} }
@@ -95,6 +95,13 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
return err 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 内部解密,不直接回给前端。 // settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
type settingsRow struct { type settingsRow struct {
AccountID string AccountID string
@@ -105,6 +112,74 @@ type settingsRow struct {
Network NetworkSettings 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) { func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
var enabled int 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) { func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel var item Tunnel
var networkJSON, created, updated string 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, err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath, &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 { if err != nil {
return Tunnel{}, err return Tunnel{}, err
} }
item.AutoStart = autoStart == 1 item.AutoStart = autoStart == 1
item.Managed = managed == 1
item.Network = DefaultNetworkSettings() item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network) _ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created) item.CreatedAt = parseTime(created)
+4
View File
@@ -38,6 +38,10 @@ var catalog = []catalogEntry{
{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}}, {Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}}, {Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
{Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}}, {Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
{Name: "Home Assistant", Port: 8123, Platform: "home-assistant", Keywords: []string{"home assistant", "homeassistant"}},
{Name: "Immich", Port: 2283, Platform: "immich", Keywords: []string{"immich"}},
{Name: "AdGuard Home", Port: 3000, Platform: "adguard-home", Keywords: []string{"adguard home", "adguardhome"}},
{Name: "Synology DSM", Port: 5000, Platform: "synology", Keywords: []string{"synology", "diskstation"}},
} }
func New() *Service { func New() *Service {
+120 -8
View File
@@ -57,6 +57,38 @@ type HealthRecorder interface {
PruneRouteHealth(ctx context.Context) error PruneRouteHealth(ctx context.Context) error
} }
// CloudflareRouteStatus 是 Cloudflare 隧道域名的巡检快照。
type CloudflareRouteStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// CloudflareHealthSample 是 Cloudflare 隧道的状态变更样本。
type CloudflareHealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// CloudflareHealthSource 提供 Cloudflare 隧道健康快照。
type CloudflareHealthSource interface {
CloudflareRoutes(ctx context.Context) ([]CloudflareRouteStatus, error)
}
// CloudflareHealthRecorder 持久化 Cloudflare 隧道健康状态。
type CloudflareHealthRecorder interface {
RecordCloudflareHealth(ctx context.Context, samples []CloudflareHealthSample) error
PruneCloudflareHealth(ctx context.Context) error
}
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。 // ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
type ServerStatus struct { type ServerStatus struct {
ID int64 ID int64
@@ -113,14 +145,15 @@ type Checker struct {
health HealthRecorder health HealthRecorder
metrics MetricsRecorder metrics MetricsRecorder
mu sync.Mutex mu sync.Mutex
cpuPrev sysinfo.CPUSample cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time resourceAlerts map[string]time.Time
failures map[string]int failures map[string]int
driftAlerted map[int64]bool driftAlerted map[int64]bool
driftNotified map[int64]time.Time driftNotified map[int64]time.Time
lastHealthPrune time.Time lastHealthPrune time.Time
lastMetricsPrune 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 { 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.checkServers(ctx)
c.checkAgents(ctx) c.checkAgents(ctx)
c.checkDrift(ctx) c.checkDrift(ctx)
c.checkCloudflare(ctx)
} }
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。 // runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
@@ -343,6 +377,84 @@ func (c *Checker) checkRoutes(ctx context.Context) {
} }
} }
// checkCloudflare 把 Cloudflare 隧道纳入现有巡检、通知与健康记录。
func (c *Checker) checkCloudflare(ctx context.Context) {
source, ok := c.source.(CloudflareHealthSource)
if !ok {
return
}
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
recorder, canRecord := c.source.(CloudflareHealthRecorder)
if !canRecord {
recordHealth = false
}
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
if !recordHealth && !alertEnabled {
return
}
routes, err := source.CloudflareRoutes(ctx)
if err != nil {
c.logger.Warn("巡检 Cloudflare 隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
if recordHealth {
samples := make([]CloudflareHealthSample, 0, len(routes))
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
samples = append(samples, CloudflareHealthSample{
TunnelID: route.TunnelID,
Name: route.Name,
Domain: route.Domain,
State: route.State,
Reason: route.Reason,
Detail: route.Detail,
})
}
if err := recorder.RecordCloudflareHealth(ctx, samples); err != nil {
c.logger.Warn("记录 Cloudflare 隧道健康失败", "module", "MONITOR", "error", err.Error())
}
c.pruneCloudflareHealthIfDue(ctx, recorder)
}
if !alertEnabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
for _, route := range routes {
if !route.Known || strings.TrimSpace(route.Domain) == "" {
continue
}
_, event := c.track(fmt.Sprintf("cloudflare:%d:%s", route.TunnelID, route.Domain), route.State == HealthUp, limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未连接:%s", route.Domain, limit, detailOr(route.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道已恢复",
fmt.Sprintf("%s 已重新连接 Cloudflare 边缘", route.Domain))
}
}
}
func (c *Checker) pruneCloudflareHealthIfDue(ctx context.Context, recorder CloudflareHealthRecorder) {
c.mu.Lock()
due := c.lastCFHealthPrune.IsZero() || time.Since(c.lastCFHealthPrune) >= 24*time.Hour
if due {
c.lastCFHealthPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if err := recorder.PruneCloudflareHealth(ctx); err != nil {
c.logger.Warn("清理 Cloudflare 隧道健康记录失败", "module", "MONITOR", "error", err.Error())
}
}
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节 // healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
func healthStateOf(route RouteStatus) (string, string) { func healthStateOf(route RouteStatus) (string, string) {
if !route.Known { if !route.Known {
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"context"
"testing"
)
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
rule, err := store.Create(ctx, CreateInput{
Upstream: "http://192.168.1.10:8080",
ListenPort: 8443,
ListenIPv4: true,
Hosts: []string{"cf.example.com"},
Enabled: true,
Exits: []string{ExitCloudflare},
CFTunnelID: 7,
})
if err != nil {
t.Fatal(err)
}
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
}
localRules, err := store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 0 {
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
}
enabled := true
updated, err := store.Update(ctx, rule.ID, UpdateInput{
Exits: &[]string{ExitLocal, ExitCloudflare},
Enabled: &enabled,
CFTunnelID: int64Ptr(7),
})
if err != nil {
t.Fatal(err)
}
if len(updated.Exits) != 2 {
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
}
localRules, err = store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 1 || localRules[0].ID != rule.ID {
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
}
}
func int64Ptr(v int64) *int64 { return &v }
+39
View File
@@ -0,0 +1,39 @@
package proxy
import "testing"
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
exits, tunnelID, err := normalizeExits(nil, 0)
if err != nil {
t.Fatal(err)
}
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
}
}
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
t.Fatal("Cloudflare 出口缺少隧道时应报错")
}
}
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
if err != nil {
t.Fatal(err)
}
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
}
}
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
rule := Rule{}
if !rule.UsesExit(ExitLocal) {
t.Fatal("空出口应兼容为本机出口")
}
if rule.UsesExit(ExitCloudflare) {
t.Fatal("空出口不应被视为 Cloudflare")
}
}
+153 -8
View File
@@ -3,14 +3,21 @@ package proxy
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/validate" "github.com/fonu/fonu/internal/validate"
) )
const (
ExitLocal = "local"
ExitCloudflare = "cloudflare"
)
type Host struct { type Host struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
@@ -32,6 +39,9 @@ type Rule struct {
Name string `json:"name"` Name string `json:"name"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
Security SecurityConfig `json:"security"` Security SecurityConfig `json:"security"`
Exits []string `json:"exits"`
CFTunnelID int64 `json:"cf_tunnel_id"`
SyncWarning string `json:"sync_warning,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -98,6 +108,34 @@ func (r Rule) Hostnames() []string {
return names return names
} }
// UsesExit 判断规则是否启用了指定出口;空值按旧数据兼容为本机出口。
func (r Rule) UsesExit(exit string) bool {
if len(r.Exits) == 0 {
return exit == ExitLocal
}
for _, item := range r.Exits {
if item == exit {
return true
}
}
return false
}
// HasExit 判断出口列表是否包含指定出口;空值按旧数据兼容为本机出口。
func HasExit(exits []string, exit string) bool {
return Rule{Exits: exits}.UsesExit(exit)
}
// LocalEnabled 判断规则当前是否需要生成本机 Nginx 配置。
func (r Rule) LocalEnabled() bool {
return r.Enabled && r.UsesExit(ExitLocal)
}
// CloudflareEnabled 判断规则当前是否需要同步到 Cloudflare 隧道。
func (r Rule) CloudflareEnabled() bool {
return r.Enabled && r.UsesExit(ExitCloudflare) && r.CFTunnelID > 0
}
func (r Rule) PortGroups() []PortGroup { func (r Rule) PortGroups() []PortGroup {
groups := map[int][]string{} groups := map[int][]string{}
ports := make([]int, 0) ports := make([]int, 0)
@@ -130,6 +168,8 @@ type CreateInput struct {
Enabled bool Enabled bool
Name string Name string
Security SecurityInput Security SecurityInput
Exits []string
CFTunnelID int64
} }
type UpdateInput struct { type UpdateInput struct {
@@ -143,6 +183,8 @@ type UpdateInput struct {
Enabled *bool Enabled *bool
Name *string Name *string
Security *SecurityInput Security *SecurityInput
Exits *[]string
CFTunnelID *int64
} }
type Store struct { type Store struct {
@@ -175,7 +217,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
} }
q := s.querier() q := s.querier()
rows, err := q.QueryContext(ctx, ` rows, err := q.QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules FROM proxy_rules
ORDER BY sort_order ASC, id ASC ORDER BY sort_order ASC, id ASC
`) `)
@@ -200,7 +242,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
func (s *Store) Get(ctx context.Context, id int64) (Rule, error) { func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
row := s.querier().QueryRowContext(ctx, ` row := s.querier().QueryRowContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE id = ? FROM proxy_rules WHERE id = ?
`, id) `, id)
rule, err := scanRule(row) rule, err := scanRule(row)
@@ -243,11 +285,19 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
exits, cfTunnelID, err := normalizeExits(in.Exits, in.CFTunnelID)
if err != nil {
return Rule{}, err
}
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
res, err := s.querier().ExecContext(ctx, ` res, err := s.querier().ExecContext(ctx, `
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at) INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, exits_json, cf_tunnel_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON) `, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON, exitsJSON, cfTunnelID)
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
@@ -278,6 +328,8 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
enabled := current.Enabled enabled := current.Enabled
name := current.Name name := current.Name
hosts := current.Hosts hosts := current.Hosts
exits := current.Exits
cfTunnelID := current.CFTunnelID
if in.Upstream != nil { if in.Upstream != nil {
upstream, err = validate.Upstream(*in.Upstream) upstream, err = validate.Upstream(*in.Upstream)
@@ -315,6 +367,20 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
return Rule{}, err return Rule{}, err
} }
} }
if in.Exits != nil || in.CFTunnelID != nil {
rawExits := exits
if in.Exits != nil {
rawExits = *in.Exits
}
rawTunnelID := cfTunnelID
if in.CFTunnelID != nil {
rawTunnelID = *in.CFTunnelID
}
exits, cfTunnelID, err = normalizeExits(rawExits, rawTunnelID)
if err != nil {
return Rule{}, err
}
}
if in.Hosts != nil { if in.Hosts != nil {
hosts, err = parseHosts(*in.Hosts, listenPort) hosts, err = parseHosts(*in.Hosts, listenPort)
if err != nil { if err != nil {
@@ -336,6 +402,10 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil { if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
for _, host := range current.Hosts { for _, host := range current.Hosts {
@@ -350,9 +420,9 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
_, err = s.querier().ExecContext(ctx, ` _, err = s.querier().ExecContext(ctx, `
UPDATE proxy_rules UPDATE proxy_rules
SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, updated_at = datetime('now') SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, exits_json = ?, cf_tunnel_id = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, id) `, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, exitsJSON, cfTunnelID, id)
if err != nil { if err != nil {
return Rule{}, err return Rule{}, err
} }
@@ -423,7 +493,7 @@ func (s *Store) Delete(ctx context.Context, id int64) error {
func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) { func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
rows, err := s.querier().QueryContext(ctx, ` rows, err := s.querier().QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
`) `)
if err != nil { if err != nil {
@@ -445,6 +515,21 @@ func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
return s.attachHosts(ctx, rules) return s.attachHosts(ctx, rules)
} }
// ListEnabledLocal 只返回需要生成本机 Nginx 配置的启用规则。
func (s *Store) ListEnabledLocal(ctx context.Context) ([]Rule, error) {
rules, err := s.ListEnabled(ctx)
if err != nil {
return nil, err
}
out := make([]Rule, 0, len(rules))
for _, rule := range rules {
if rule.UsesExit(ExitLocal) {
out = append(out, rule)
}
}
return out, nil
}
func validateCreateInput(in CreateInput) (string, []Host, error) { func validateCreateInput(in CreateInput) (string, []Host, error) {
upstream, err := validate.Upstream(in.Upstream) upstream, err := validate.Upstream(in.Upstream)
if err != nil { if err != nil {
@@ -654,6 +739,8 @@ func scanRule(row rowScanner) (Rule, error) {
var enabled int var enabled int
var nginxMode string var nginxMode string
var securityJSON string var securityJSON string
var exitsJSON string
var cfTunnelID int64
var createdAt string var createdAt string
var updatedAt string var updatedAt string
if err := row.Scan( if err := row.Scan(
@@ -669,6 +756,8 @@ func scanRule(row rowScanner) (Rule, error) {
&rule.Name, &rule.Name,
&rule.SortOrder, &rule.SortOrder,
&securityJSON, &securityJSON,
&exitsJSON,
&cfTunnelID,
&createdAt, &createdAt,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
@@ -688,11 +777,67 @@ func scanRule(row rowScanner) (Rule, error) {
return Rule{}, err return Rule{}, err
} }
rule.Security = security rule.Security = security
rule.Exits = parseExits(exitsJSON)
rule.CFTunnelID = cfTunnelID
rule.CreatedAt = parseTime(createdAt) rule.CreatedAt = parseTime(createdAt)
rule.UpdatedAt = parseTime(updatedAt) rule.UpdatedAt = parseTime(updatedAt)
return rule, nil return rule, nil
} }
func normalizeExits(raw []string, cfTunnelID int64) ([]string, int64, error) {
if len(raw) == 0 {
raw = []string{ExitLocal}
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
exit := strings.ToLower(strings.TrimSpace(item))
switch exit {
case ExitLocal, ExitCloudflare:
default:
return nil, 0, fmt.Errorf("不支持的出口: %s", item)
}
if _, ok := seen[exit]; ok {
continue
}
seen[exit] = struct{}{}
out = append(out, exit)
}
sort.Strings(out)
_, usesCloudflare := seen[ExitCloudflare]
if usesCloudflare && cfTunnelID <= 0 {
return nil, 0, fmt.Errorf("选择 Cloudflare 出口时需要指定隧道")
}
if !usesCloudflare {
cfTunnelID = 0
}
return out, cfTunnelID, nil
}
func marshalExits(exits []string) (string, error) {
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return "", err
}
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func parseExits(raw string) []string {
var exits []string
if err := json.Unmarshal([]byte(raw), &exits); err != nil {
return []string{ExitLocal}
}
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return []string{ExitLocal}
}
return normalized
}
func parseTime(v string) time.Time { func parseTime(v string) time.Time {
t, err := time.Parse("2006-01-02 15:04:05", v) t, err := time.Parse("2006-01-02 15:04:05", v)
if err != nil { if err != nil {
+2 -2
View File
@@ -287,7 +287,7 @@ func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName strin
} }
func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error { func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -315,7 +315,7 @@ func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.
} }
func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error { func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
+42 -8
View File
@@ -23,6 +23,12 @@ type ProxyService struct {
settings *settings.Store settings *settings.Store
nginx *nginx.Manager nginx *nginx.Manager
notify *notify.Service notify *notify.Service
exitSync ExitSync
}
// ExitSync 处理服务发布规则变化后的外部出口同步。
type ExitSync interface {
SyncRules(ctx context.Context) error
} }
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService { func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
@@ -33,6 +39,10 @@ func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
s.notify = notifySvc s.notify = notifySvc
} }
func (s *ProxyService) SetExitSync(sync ExitSync) {
s.exitSync = sync
}
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) { func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
return s.store.List(ctx) return s.store.List(ctx)
} }
@@ -42,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) { func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil { if proxy.HasExit(in.Exits, proxy.ExitLocal) {
return proxy.Rule{}, err if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
return proxy.Rule{}, err
}
} }
rule, err := s.store.Create(ctx, in) 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 { if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err) return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil return rule, nil
} }
@@ -69,8 +84,14 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
if in.Hosts != nil { if in.Hosts != nil {
hosts = *in.Hosts hosts = *in.Hosts
} }
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil { exits := current.Exits
return proxy.Rule{}, err 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) 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 { if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err) return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil return rule, nil
} }
func (s *ProxyService) syncExits(ctx context.Context) error {
if s.exitSync == nil {
return nil
}
return s.exitSync.SyncRules(ctx)
}
func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error { func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
return s.store.Reorder(ctx, ids) return s.store.Reorder(ctx, ids)
} }
@@ -95,6 +126,9 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err) return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
} }
if err := s.syncExits(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
}
return nil return nil
} }
@@ -103,7 +137,7 @@ func (s *ProxyService) ReloadAll(ctx context.Context) error {
} }
func (s *ProxyService) applyNginx(ctx context.Context) error { func (s *ProxyService) applyNginx(ctx context.Context) error {
rules, err := s.store.ListEnabled(ctx) rules, err := s.store.ListEnabledLocal(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -124,17 +158,17 @@ func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, e
replaced := false replaced := false
for _, r := range existing { for _, r := range existing {
if r.ID == candidate.ID { if r.ID == candidate.ID {
if candidate.Enabled { if candidate.LocalEnabled() {
rules = append(rules, candidate) rules = append(rules, candidate)
} }
replaced = true replaced = true
continue continue
} }
if r.Enabled { if r.LocalEnabled() {
rules = append(rules, r) rules = append(rules, r)
} }
} }
if !replaced && candidate.Enabled { if !replaced && candidate.LocalEnabled() {
rules = append(rules, candidate) rules = append(rules, candidate)
} }
certs, err := s.loadCertSources(ctx) certs, err := s.loadCertSources(ctx)
+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, CfTunnelPayload,
CfAppSettings, CfAppSettings,
CfAppSettingsInput, CfAppSettingsInput,
CfDriftResult,
CfDiagnosticsResult,
CfAccessStatus,
CfZone,
CfTunnelRoutes,
CfPreflightResult,
CfRouteTestResult,
} from './types' } from './types'
export function asList<T>(value: T[] | null | undefined): T[] { 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' }), request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) => restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }), 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) => getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`), request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`),
getCfTunnelLogs: (id: number, lines = 200) => getCfTunnelLogs: (id: number, lines = 200) =>
request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`), request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`),
getCfTunnelConfig: (id: number) => getCfTunnelConfig: (id: number) =>
request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`), 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'), 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', { request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', {
method: 'POST', 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: () => getCfIngressCandidates: () =>
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'), 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'), getCfSettings: () => request<CfAppSettings>('/api/cloudflare/settings'),
saveCfSettings: (payload: CfAppSettingsInput) => saveCfSettings: (payload: CfAppSettingsInput) =>
request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }),
+82
View File
@@ -50,6 +50,9 @@ export interface ProxyRule {
name: string name: string
sort_order: number sort_order: number
security: ProxySecurityConfig security: ProxySecurityConfig
exits: string[]
cf_tunnel_id: number
sync_warning?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -108,6 +111,8 @@ export interface ProxySavePayload {
enabled?: boolean enabled?: boolean
name?: string name?: string
security?: ProxySecurityPayload security?: ProxySecurityPayload
exits?: string[]
cf_tunnel_id?: number
} }
export interface AuthStatus { export interface AuthStatus {
@@ -165,6 +170,10 @@ export interface DashboardStatus {
mem_total_bytes: number mem_total_bytes: number
disk_used_bytes: number disk_used_bytes: number
disk_total_bytes: number disk_total_bytes: number
cloudflare_count: number
cloudflare_online: number
cloudflare_status: string
cloudflare_message?: string
} }
export interface DDNSDomainRecord { export interface DDNSDomainRecord {
@@ -1024,9 +1033,19 @@ export interface MetricsHistory {
export interface StatusPageRoute { export interface StatusPageRoute {
domain: string domain: string
state: string state: string
source?: 'frp' | 'cloudflare' | string
uptime_pct: number uptime_pct: number
down_minutes: number down_minutes: number
unknown_minutes: number unknown_minutes: number
incidents: StatusPageIncident[]
}
export interface StatusPageIncident {
from: string
to?: string
reason?: string
detail?: string
minutes: number
} }
export interface StatusPagePayload { export interface StatusPagePayload {
@@ -1064,6 +1083,7 @@ export interface CfTunnel {
last_error?: string last_error?: string
metrics_port?: number metrics_port?: number
auto_start: boolean auto_start: boolean
managed: boolean
hostnames?: string[] hostnames?: string[]
origin_service?: string origin_service?: string
dns_warning?: string dns_warning?: string
@@ -1090,6 +1110,11 @@ export interface CfMirror {
base: string base: string
} }
export interface CfZone {
id: string
name: string
}
export interface CfTunnelPayload { export interface CfTunnelPayload {
name: string name: string
mode: string mode: string
@@ -1098,6 +1123,7 @@ export interface CfTunnelPayload {
origin_service?: string origin_service?: string
network: CfNetworkSettings network: CfNetworkSettings
auto_start: boolean auto_start: boolean
managed: boolean
} }
export interface CfAppSettings { export interface CfAppSettings {
@@ -1119,3 +1145,59 @@ export interface CfAppSettingsInput {
mirror: string mirror: string
network: CfNetworkSettings 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: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline }, { label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline }, { label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline }, { label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline }, { label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline }, { label: '系统设置', key: 'settings', icon: CogOutline },
{ label: '示例页面', key: 'example', icon: FlashOutline }, { 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 SettingsView from '../views/SettingsView.vue'
import FrpView from '../views/FrpView.vue' import FrpView from '../views/FrpView.vue'
import CloudflareView from '../views/CloudflareView.vue' import CloudflareView from '../views/CloudflareView.vue'
import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue'
import AgentView from '../views/AgentView.vue' import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue' import ProxyRemoteView from '../views/ProxyRemoteView.vue'
import StatusPageView from '../views/StatusPageView.vue' import StatusPageView from '../views/StatusPageView.vue'
@@ -70,6 +71,12 @@ const router = createRouter({
component: CloudflareView, component: CloudflareView,
meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' }, meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' },
}, },
{
path: 'cloudflare/:id/config',
name: 'cloudflare-tunnel-config',
component: CloudflareTunnelConfigView,
meta: { title: 'Cloudflare 隧道配置', description: '配置隧道、查看副本指标与已发布路由' },
},
{ {
path: 'public-proxy', path: 'public-proxy',
name: '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> <template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP"> <PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions> <template #actions>
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button> <n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button> <n-button type="primary" @click="openCreate">新建隧道</n-button>
</template> </template>
@@ -9,43 +10,72 @@
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }"> <div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text"> <div class="cf-global__text">
<strong>全局托管</strong> <strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:隧道进程由 Fonu 托管,auto_start 的隧道会在应用启动时恢复' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span> <span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Fonu 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
</div> </div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" /> <n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div> </div>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert"> <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>
<n-alert v-else type="warning" :bordered="false" class="cf-alert"> <n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。 尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert> </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-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-for="item in tunnels" :key="item.id" class="cf-row"> <div v-else class="cf-table">
<div class="cf-row__main"> <div class="cf-table__head">
<strong>{{ item.name }}</strong> <span>隧道</span>
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" /> <span>状态</span>
<span class="cf-meta mono">{{ modeText(item.mode) }}</span> <span>副本</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span> <span>版本</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span> <span>路由</span>
<span>调和</span>
<span>操作</span>
</div> </div>
<div class="cf-row__actions"> <div v-for="item in tunnels" :key="item.id" class="cf-table__row">
<n-button size="small" @click="openDetail(item)">详情</n-button> <div class="cf-table__tunnel">
<n-button size="small" @click="openEdit(item)">编辑</n-button> <strong>{{ item.name }}</strong>
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button> <div class="cf-table__meta">
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button> <n-tag v-if="item.managed" size="small" type="info" :bordered="false" round>自动托管</n-tag>
<n-button size="small" @click="restartTunnel(item)">重启</n-button> <span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<n-popconfirm @positive-click="removeTunnel(item)"> <span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template> <span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
将停止该隧道并删除本地配置、凭据与日志。继续? </div>
</n-popconfirm> </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>
</div> </div>
</FonuCard> </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"> <div class="cf-modal__body">
<n-alert type="info" :bordered="false" class="cf-mode-alert"> <n-alert type="info" :bordered="false" class="cf-mode-alert">
隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。 隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。
@@ -56,16 +86,47 @@
<n-divider title-placement="left">基本信息</n-divider> <n-divider title-placement="left">基本信息</n-divider>
<n-form label-placement="top"> <n-form label-placement="top">
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item> <n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
</n-form> <n-form-item label="自动托管">
<n-divider title-placement="left">暴露规则</n-divider> <div class="cf-managed-field">
<n-form label-placement="top"> <n-switch v-model:value="form.managed" />
<n-form-item label="暴露域名"> <span>启用后,暴露域名与回源由「反向代理」规则中的 Cloudflare 出口自动生成。</span>
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" /> </div>
</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> </n-form-item>
</n-form> </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-divider title-placement="left">运行</n-divider>
<n-form label-placement="top"> <n-form label-placement="top">
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item> <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-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><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?.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>传输协议</label><strong>{{ detailStatus?.transport || '—' }}</strong></div>
<div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div> <div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div>
</div> </div>
@@ -99,6 +160,31 @@
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div> <div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre> <pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</n-tab-pane> </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> </n-tabs>
</div> </div>
<template #footer> <template #footer>
@@ -113,7 +199,8 @@
<div class="cf-modal__body"> <div class="cf-modal__body">
<n-divider title-placement="left">程序安装</n-divider> <n-divider title-placement="left">程序安装</n-divider>
<div class="cf-binary"> <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><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div> </div>
<n-form label-placement="top"> <n-form label-placement="top">
@@ -122,7 +209,9 @@
</n-form-item> </n-form-item>
</n-form> </n-form>
<div class="cf-code-toolbar"> <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="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div> </div>
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p> <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"> <n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }} {{ tokenTestResult }}
</n-alert> </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-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top"> <n-form label-placement="top">
@@ -164,13 +253,33 @@
</div> </div>
</template> </template>
</n-modal> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' 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 { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { api, asList } from '../api/client' 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 FonuCard from '../components/FonuCard.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue' import StatusBadge from '../components/StatusBadge.vue'
@@ -179,13 +288,19 @@ import { copyText } from '../utils/clipboard'
import type { StatusKind } from '../utils/status' import type { StatusKind } from '../utils/status'
const message = useMessage() const message = useMessage()
const router = useRouter()
const tunnels = ref<CfTunnel[]>([]) const tunnels = ref<CfTunnel[]>([])
const runtimeStatuses = ref<Record<number, CfRuntimeStatus>>({})
const driftByTunnel = ref<Record<number, CfDriftResult>>({})
const binaryVersion = ref('') const binaryVersion = ref('')
const latestBinaryVersion = ref('')
const mirrors = ref<CfMirror[]>([]) const mirrors = ref<CfMirror[]>([])
const candidates = ref<string[]>([]) const zones = ref<CfZone[]>([])
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const binaryLoading = ref(false) const binaryLoading = ref(false)
const binaryChecking = ref(false)
const binaryRollbackLoading = ref(false)
const globalLoading = ref(false) const globalLoading = ref(false)
const settingsVisible = ref(false) const settingsVisible = ref(false)
const settingsLoading = 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 settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
const editorVisible = ref(false) const editorVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
const editing = ref<CfTunnel | null>(null)
const detailTunnel = ref<CfTunnel | null>(null) const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null) const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('') const detailConfig = ref('')
const detailLogs = ref('') const detailLogs = ref('')
const detailLoading = ref(false) 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 form = ref<CfTunnelPayload>(emptyForm())
const manualRule = ref({ subdomain: '', domain: '', type: 'http', target: '127.0.0.1:80' })
const protocolOptions = [ const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' }, { label: 'HTTP/2(国内推荐)', value: 'http2' },
@@ -229,7 +355,13 @@ const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' }, { label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' }, { 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 mirrorOptions = computed(() => {
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }] const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
return list.map((item) => ({ label: item.name, value: item.id })) 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', origin_service: 'http://127.0.0.1:80',
network: emptyNetwork(), network: emptyNetwork(),
auto_start: false, auto_start: false,
managed: true,
} }
} }
@@ -254,6 +387,26 @@ async function load() {
loading.value = true loading.value = true
try { try {
tunnels.value = asList(await api.listCfTunnels()) 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) { } catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败') message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally { } 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) { async function toggleGlobal(value: boolean) {
globalLoading.value = true globalLoading.value = true
try { 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() { async function loadBinary() {
try { try {
const result = await api.getCfBinary() const result = await api.getCfBinary()
@@ -285,32 +496,41 @@ async function loadBinary() {
} }
} }
async function loadCandidates() { async function loadZones() {
try { try {
candidates.value = (await api.getCfIngressCandidates()).hostnames || [] const result = await api.getCfZones()
zones.value = asList(result.zones)
} catch { } catch {
candidates.value = [] zones.value = []
} }
} }
function openCreate() { function resetManualRule() {
editing.value = null 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() form.value = emptyForm()
resetManualRule()
editorVisible.value = true editorVisible.value = true
} }
function openEdit(item: CfTunnel) { function openConfig(item: CfTunnel) {
editing.value = item void router.push(`/cloudflare/${item.id}/config`)
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
} }
async function saveTunnel() { async function saveTunnel() {
@@ -322,21 +542,28 @@ async function saveTunnel() {
message.warning('请先在「应用配置」中填写 Account ID 与 API Token') message.warning('请先在「应用配置」中填写 Account ID 与 API Token')
return return
} }
if (!form.value.hostnames.length) { if (!form.value.managed) {
message.warning('请至少选择一个暴露域名') const hostname = manualHostname()
return 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 = { const payload: CfTunnelPayload = {
...form.value, ...form.value,
mode: editing.value ? form.value.mode : 'account-local', mode: 'account-local',
token: '', token: '',
network: { ...appSettings.value.network }, network: { ...appSettings.value.network },
} }
saving.value = true saving.value = true
try { try {
const result = editing.value const result = await api.createCfTunnel(payload)
? await api.updateCfTunnel(editing.value.id, payload)
: await api.createCfTunnel(payload)
editorVisible.value = false editorVisible.value = false
if (result.dns_warning) { if (result.dns_warning) {
message.warning(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) { async function openDetail(item: CfTunnel) {
detailTunnel.value = item detailTunnel.value = item
detailDrift.value = null
detailDiagnostics.value = null
detailAccess.value = null
detailVisible.value = true detailVisible.value = true
await refreshDetail() 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() { async function copyConfig() {
if (!detailConfig.value) return if (!detailConfig.value) return
try { try {
@@ -420,6 +729,7 @@ async function copyConfig() {
} }
async function openSettings() { async function openSettings() {
preflightVisible.value = false
settingsVisible.value = true settingsVisible.value = true
await loadAppSettings() await loadAppSettings()
} }
@@ -463,6 +773,7 @@ async function saveSettings() {
const result = await api.saveCfSettings(settingsForm.value) const result = await api.saveCfSettings(settingsForm.value)
appSettings.value = result appSettings.value = result
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } } settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
await loadZones()
message.success('应用配置已保存') message.success('应用配置已保存')
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '保存应用配置失败') message.error(error instanceof Error ? error.message : '保存应用配置失败')
@@ -474,7 +785,7 @@ async function saveSettings() {
async function downloadBinary() { async function downloadBinary() {
binaryLoading.value = true binaryLoading.value = true
try { 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 || '' binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装') message.success('cloudflared 已下载并安装')
} catch (error) { } 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) { function modeText(mode: string) {
if (mode === 'account-local') return 'API 管理' if (mode === 'account-local') return 'API 管理'
if (mode === 'token-remote') return '云端规则' if (mode === 'token-remote') return '云端规则'
@@ -512,7 +858,7 @@ onMounted(() => {
void load() void load()
void loadBinary() void loadBinary()
void loadAppSettings() void loadAppSettings()
void loadCandidates() void loadZones()
}) })
useVisibilityPolling(() => load(), 5000) useVisibilityPolling(() => load(), 5000)
@@ -536,6 +882,26 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-global__text span { color: var(--fonu-text-secondary); } .cf-global__text span { color: var(--fonu-text-secondary); }
.cf-alert { margin-bottom: var(--fonu-space-4); } .cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); } .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 { .cf-row {
display: flex; display: flex;
align-items: center; 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__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; } .cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-mode-alert { margin-bottom: 4px; } .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-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 { 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); } .cf-stat label { font-size: 12px; color: var(--fonu-text-muted); }
+9
View File
@@ -746,6 +746,15 @@ const healthItems = computed(() => [
icon: GlobeOutline, icon: GlobeOutline,
tone: 'green', 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>>(() => [ const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
+101 -3
View File
@@ -66,6 +66,26 @@
/> />
</n-tab-pane> </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"> <n-tab-pane name="remote-nginx" tab="公网 Nginx">
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" /> <LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
<div class="remote-nginx-bar"> <div class="remote-nginx-bar">
@@ -178,7 +198,7 @@ import {
} from 'naive-ui' } from 'naive-ui'
import { api, asList } from '../api/client' import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling' 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 EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue' import LoadError from '../components/LoadError.vue'
@@ -282,7 +302,7 @@ const LogToolbar = defineComponent({
const message = useMessage() const message = useMessage()
const route = useRoute() 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] type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab { function resolveTab(queryTab: unknown): LogTab {
@@ -408,6 +428,13 @@ const frpKeyword = ref('')
const frpLevel = ref('') const frpLevel = ref('')
const frpPage = ref(1) const frpPage = ref(1)
const loadingFrp = ref(false) 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 frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' } const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较 // 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
@@ -512,6 +539,60 @@ function frpRowClassName(row: FrpLogEntry) {
return 'log-row' 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 解析,前端只展示原始行 // 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
const nginxTargets = ref<import('../api/types').FrpServer[]>([]) const nginxTargets = ref<import('../api/types').FrpServer[]>([])
const nginxTargetId = ref<number | null>(null) const nginxTargetId = ref<number | null>(null)
@@ -780,7 +861,7 @@ async function loadSystemLogs() {
async function loadAll() { async function loadAll() {
pageError.value = '' pageError.value = ''
try { try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()]) await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs(), loadCfTunnels()])
} catch (error) { } catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行' pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} }
@@ -815,6 +896,9 @@ watch(tab, (name) => {
if (name === 'access') loadAccess() if (name === 'access') loadAccess()
else if (name === 'nginx') loadErrorLogs() else if (name === 'nginx') loadErrorLogs()
else if (name === 'system') loadSystemLogs() else if (name === 'system') loadSystemLogs()
else if (name === 'cloudflare') {
void loadCfTunnels().then(() => loadCfLogs())
}
}) })
onMounted(() => { onMounted(() => {
@@ -929,6 +1013,20 @@ onMounted(() => {
margin-bottom: var(--fonu-space-2); 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 { .remote-nginx-bar__server {
width: 200px; width: 200px;
} }
+201 -17
View File
@@ -104,7 +104,7 @@
:data="tableRules" :data="tableRules"
:bordered="false" :bordered="false"
size="small" size="small"
:scroll-x="canReorder ? 1460 : 1420" :scroll-x="canReorder ? 1610 : 1570"
:row-key="(r: ProxyRule) => r.id" :row-key="(r: ProxyRule) => r.id"
:row-props="rowProps" :row-props="rowProps"
/> />
@@ -130,6 +130,28 @@
</div> </div>
</template> </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"> <n-modal v-model:show="showDetailPanel" :mask-closable="true" transform-origin="center">
<div v-if="selectedRule" class="proxy-detail-modal"> <div v-if="selectedRule" class="proxy-detail-modal">
<div class="proxy-detail-modal__header"> <div class="proxy-detail-modal__header">
@@ -206,6 +228,10 @@
</n-tag> </n-tag>
</span> </span>
</div> </div>
<div class="overview-strip__item">
<span class="overview-strip__label">出口</span>
<span class="overview-strip__value">{{ exitLabel(selectedRule) }}</span>
</div>
</div> </div>
<div class="overview-security"> <div class="overview-security">
@@ -453,6 +479,33 @@
</div> </div>
</n-form-item> </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-list form-switch-list--compact">
<div class="form-switch-row"> <div class="form-switch-row">
<div class="form-switch-row__text"> <div class="form-switch-row__text">
@@ -859,7 +912,7 @@ import {
WarningOutline, WarningOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { api, asList } from '../api/client' 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 { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
@@ -876,6 +929,9 @@ import { renderTableRowActions } from '../utils/tableActions'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
const rules = ref<ProxyRule[]>([]) const rules = ref<ProxyRule[]>([])
const cfTunnels = ref<CfTunnel[]>([])
const discoveryVisible = ref(false)
const discoveryResults = ref<DiscoveredService[]>([])
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const loadError = ref('') const loadError = ref('')
@@ -930,6 +986,15 @@ const httpsOptions = [
{ label: 'HTTP', value: 'off' }, { 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 = () => ({ const defaultSecurityForm = () => ({
ip_blacklist_text: '', ip_blacklist_text: '',
ip_blacklist_mode: false, ip_blacklist_mode: false,
@@ -960,6 +1025,9 @@ const form = reactive({
http_redirect: true, http_redirect: true,
enabled: true, enabled: true,
name: '', name: '',
exit_local: true,
exit_cloudflare: false,
cf_tunnel_id: null as number | null,
...defaultSecurityForm(), ...defaultSecurityForm(),
}) })
@@ -1374,6 +1442,17 @@ function ruleTitle(rule: ProxyRule): string {
return ruleName(rule) 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 { function listenLabel(rule: ProxyRule): string {
const stacks = [] const stacks = []
if (rule.listen_ipv4) stacks.push('IPv4') if (rule.listen_ipv4) stacks.push('IPv4')
@@ -1399,7 +1478,14 @@ function hostAccessUrl(rule: ProxyRule, hostPart: string): string {
} }
function ruleAccessUrls(rule: ProxyRule): 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' import { copyText } from '../utils/clipboard'
@@ -1548,6 +1634,26 @@ function renderProtocol(row: ProxyRule): VNode {
return h('div', { class: 'proto-tags' }, tags) 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) { function rowProps(row: ProxyRule) {
return { return {
class: showDetailPanel.value && selectedRuleId.value === row.id ? 'proxy-row--active' : '', class: showDetailPanel.value && selectedRuleId.value === row.id ? 'proxy-row--active' : '',
@@ -1599,6 +1705,12 @@ const columns = computed<DataTableColumns<ProxyRule>>(() => {
width: 88, width: 88,
render: (row) => renderProtocol(row), render: (row) => renderProtocol(row),
}, },
{
title: '出口',
key: 'exits',
width: 150,
render: (row) => renderExits(row),
},
{ {
title: '安全', title: '安全',
key: 'security', key: 'security',
@@ -1825,6 +1937,11 @@ async function load() {
loadError.value = '' loadError.value = ''
try { try {
rules.value = asList(await api.listProxies()) rules.value = asList(await api.listProxies())
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
} catch (error) { } catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行' loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} finally { } finally {
@@ -1842,9 +1959,19 @@ function resetForm() {
form.http_redirect = true form.http_redirect = true
form.enabled = true form.enabled = true
form.name = '' form.name = ''
form.exit_local = true
form.exit_cloudflare = false
form.cf_tunnel_id = null
Object.assign(form, defaultSecurityForm()) 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) { function loadSecurityToForm(rule?: ProxyRule | null) {
const sec = rule?.security ?? {} const sec = rule?.security ?? {}
form.ip_blacklist_text = (sec.ip_blacklist ?? []).join('\n') form.ip_blacklist_text = (sec.ip_blacklist ?? []).join('\n')
@@ -1923,6 +2050,16 @@ function buildPayload(): ProxySavePayload {
if (!form.listen_ipv4 && !form.listen_ipv6) { if (!form.listen_ipv4 && !form.listen_ipv6) {
throw new Error('至少需要启用 IPv4 或 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 { return {
upstream: form.upstream, upstream: form.upstream,
listen_port: form.listen_port, listen_port: form.listen_port,
@@ -1934,6 +2071,8 @@ function buildPayload(): ProxySavePayload {
enabled: form.enabled, enabled: form.enabled,
name: form.name.trim(), name: form.name.trim(),
security: buildSecurityPayload(), security: buildSecurityPayload(),
exits,
cf_tunnel_id: form.exit_cloudflare ? form.cf_tunnel_id! : 0,
} }
} }
@@ -1941,15 +2080,9 @@ async function scanServices() {
scanning.value = true scanning.value = true
try { try {
const items = await api.scanDiscovery('127.0.0.1') const items = await api.scanDiscovery('127.0.0.1')
const found = items.find((i) => i.detected) discoveryResults.value = asList(items)
if (found) { discoveryVisible.value = true
resetForm() if (!items.some((item) => item.detected)) message.info('未检测到常见服务,可检查端口或手动填写目标地址')
form.upstream = found.upstream
showModal.value = true
message.success(`检测到 ${found.name},请填写前端域名`)
} else {
message.info('未检测到常见服务')
}
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '扫描失败') message.error(error instanceof Error ? error.message : '扫描失败')
} finally { } 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() { function openCreate() {
editing.value = null editing.value = null
formTab.value = 'basic' formTab.value = 'basic'
@@ -1973,7 +2118,11 @@ async function toggleRuleEnabled(row: ProxyRule, enabled: boolean) {
try { try {
const updated = await api.updateProxy(row.id, { enabled }) const updated = await api.updateProxy(row.id, { enabled })
rules.value = rules.value.map((rule) => (rule.id === updated.id ? updated : rule)) 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) { } catch (error) {
rules.value = rules.value.map((rule) => (rule.id === row.id ? { ...rule, enabled: prev } : rule)) rules.value = rules.value.map((rule) => (rule.id === row.id ? { ...rule, enabled: prev } : rule))
const msg = error instanceof Error ? error.message : '更新失败' const msg = error instanceof Error ? error.message : '更新失败'
@@ -2002,6 +2151,7 @@ function openDuplicate(rule: ProxyRule) {
enabled: rule.enabled, enabled: rule.enabled,
name: duplicateName(rule), name: duplicateName(rule),
}) })
loadExitsToForm(rule)
loadSecurityToForm(rule) loadSecurityToForm(rule)
syncSecurityExpanded() syncSecurityExpanded()
formTab.value = 'basic' formTab.value = 'basic'
@@ -2024,6 +2174,7 @@ function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic'
enabled: rule.enabled, enabled: rule.enabled,
name: rule.name ?? '', name: rule.name ?? '',
}) })
loadExitsToForm(rule)
loadSecurityToForm(rule) loadSecurityToForm(rule)
syncSecurityExpanded() syncSecurityExpanded()
if (tab === 'nginx') { if (tab === 'nginx') {
@@ -2097,11 +2248,13 @@ async function save() {
return return
} }
if (editing.value) { if (editing.value) {
await api.updateProxy(editing.value.id, payload) const result = await api.updateProxy(editing.value.id, payload)
message.success('规则已保存') if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已保存')
} else { } else {
await api.createProxy(payload) const result = await api.createProxy(payload)
message.success('规则已创建') if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已创建')
} }
showModal.value = false showModal.value = false
await load() await load()
@@ -3287,6 +3440,37 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
line-height: 1.5; 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) { .proxy-modal__pane :deep(.n-form-item .n-form-item-blank) {
display: block; display: block;
width: 100%; width: 100%;
+21 -4
View File
@@ -19,10 +19,21 @@
<p v-else-if="error" class="status-page__error">{{ error }}</p> <p v-else-if="error" class="status-page__error">{{ error }}</p>
<div v-if="page && page.routes.length" class="status-page__list"> <div v-if="page && page.routes.length" class="status-page__list">
<div v-for="route in page.routes" :key="route.domain" class="status-row"> <div v-for="route in page.routes" :key="route.domain" class="status-route">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" /> <div class="status-row">
<span class="status-row__domain mono">{{ route.domain }}</span> <StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span> <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>
</div> </div>
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p> <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); 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__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-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 { 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); } .status-page__hint--foot { margin-top: var(--fonu-space-5); }
</style> </style>