feat(cloudflare): 完成隧道接入、运维与 P0 体验优化
This commit is contained in:
+4
-2
@@ -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 三项不做 |
|
||||||
|
|||||||
@@ -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 隧道配置。
|
||||||
|
- 漂移比较。
|
||||||
@@ -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 管理“我的服务如何安全、稳定地访问”。
|
||||||
@@ -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(),
|
||||||
@@ -226,12 +326,31 @@ 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) == "" {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fonu/fonu/internal/acme"
|
"github.com/fonu/fonu/internal/acme"
|
||||||
certstore "github.com/fonu/fonu/internal/certificate"
|
certstore "github.com/fonu/fonu/internal/certificate"
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
"github.com/fonu/fonu/internal/ddns"
|
"github.com/fonu/fonu/internal/ddns"
|
||||||
"github.com/fonu/fonu/internal/logstore"
|
"github.com/fonu/fonu/internal/logstore"
|
||||||
@@ -26,13 +27,14 @@ type StatusHandler struct {
|
|||||||
proxySvc *service.ProxyService
|
proxySvc *service.ProxyService
|
||||||
ddnsSvc *ddns.Service
|
ddnsSvc *ddns.Service
|
||||||
acmeSvc *acme.Service
|
acmeSvc *acme.Service
|
||||||
|
cloudflareSvc *cloudflared.Service
|
||||||
startedAt string
|
startedAt string
|
||||||
nginxPIDFile string
|
nginxPIDFile string
|
||||||
cpuMu sync.Mutex
|
cpuMu sync.Mutex
|
||||||
cpuSample sysinfo.CPUSample
|
cpuSample sysinfo.CPUSample
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
||||||
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
|
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
|
||||||
_, sample := sysinfo.Read(sysinfo.CPUSample{})
|
_, sample := sysinfo.Read(sysinfo.CPUSample{})
|
||||||
return &StatusHandler{
|
return &StatusHandler{
|
||||||
@@ -40,6 +42,7 @@ func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc
|
|||||||
proxySvc: proxySvc,
|
proxySvc: proxySvc,
|
||||||
ddnsSvc: ddnsSvc,
|
ddnsSvc: ddnsSvc,
|
||||||
acmeSvc: acmeSvc,
|
acmeSvc: acmeSvc,
|
||||||
|
cloudflareSvc: cloudflareSvc,
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
nginxPIDFile: nginxPIDFile,
|
nginxPIDFile: nginxPIDFile,
|
||||||
cpuSample: sample,
|
cpuSample: sample,
|
||||||
@@ -71,6 +74,10 @@ type statusResponse struct {
|
|||||||
MemTotalBytes uint64 `json:"mem_total_bytes"`
|
MemTotalBytes uint64 `json:"mem_total_bytes"`
|
||||||
DiskUsedBytes uint64 `json:"disk_used_bytes"`
|
DiskUsedBytes uint64 `json:"disk_used_bytes"`
|
||||||
DiskTotalBytes uint64 `json:"disk_total_bytes"`
|
DiskTotalBytes uint64 `json:"disk_total_bytes"`
|
||||||
|
CloudflareCount int `json:"cloudflare_count"`
|
||||||
|
CloudflareOnline int `json:"cloudflare_online"`
|
||||||
|
CloudflareStatus string `json:"cloudflare_status"`
|
||||||
|
CloudflareMessage string `json:"cloudflare_message,omitempty"`
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
}
|
}
|
||||||
@@ -97,6 +104,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
|
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
|
||||||
h.setCPUSample(cpuSample)
|
h.setCPUSample(cpuSample)
|
||||||
|
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
|
||||||
|
if h.cloudflareSvc != nil {
|
||||||
|
cfSummary = h.cloudflareSvc.Summary(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||||
@@ -131,6 +142,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
MemTotalBytes: resources.MemTotalBytes,
|
MemTotalBytes: resources.MemTotalBytes,
|
||||||
DiskUsedBytes: resources.DiskUsedBytes,
|
DiskUsedBytes: resources.DiskUsedBytes,
|
||||||
DiskTotalBytes: resources.DiskTotalBytes,
|
DiskTotalBytes: resources.DiskTotalBytes,
|
||||||
|
CloudflareCount: cfSummary.Count,
|
||||||
|
CloudflareOnline: cfSummary.Online,
|
||||||
|
CloudflareStatus: cfSummary.Status,
|
||||||
|
CloudflareMessage: cfSummary.Message,
|
||||||
StartedAt: h.startedAt,
|
StartedAt: h.startedAt,
|
||||||
UptimeSeconds: int64(uptime),
|
UptimeSeconds: int64(uptime),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net"
|
"net"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"github.com/fonu/fonu/internal/frp"
|
||||||
"github.com/fonu/fonu/internal/notify"
|
"github.com/fonu/fonu/internal/notify"
|
||||||
"github.com/fonu/fonu/internal/settings"
|
"github.com/fonu/fonu/internal/settings"
|
||||||
@@ -28,13 +30,15 @@ const (
|
|||||||
type StatusPageHandler struct {
|
type StatusPageHandler struct {
|
||||||
settings *settings.Store
|
settings *settings.Store
|
||||||
frp *frp.Service
|
frp *frp.Service
|
||||||
|
cloudflare *cloudflared.Service
|
||||||
limiter *statusPageLimiter
|
limiter *statusPageLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *StatusPageHandler {
|
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
|
||||||
return &StatusPageHandler{
|
return &StatusPageHandler{
|
||||||
settings: settingsStore,
|
settings: settingsStore,
|
||||||
frp: frpSvc,
|
frp: frpSvc,
|
||||||
|
cloudflare: cloudflareSvc,
|
||||||
limiter: newStatusPageLimiter(),
|
limiter: newStatusPageLimiter(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,9 +46,19 @@ func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *S
|
|||||||
type statusPageRoute struct {
|
type statusPageRoute struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
Uptime float64 `json:"uptime_pct"`
|
Uptime float64 `json:"uptime_pct"`
|
||||||
DownMinutes int `json:"down_minutes"`
|
DownMinutes int `json:"down_minutes"`
|
||||||
UnknownMinutes int `json:"unknown_minutes"`
|
UnknownMinutes int `json:"unknown_minutes"`
|
||||||
|
Incidents []statusPageIncident `json:"incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusPageIncident struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusPagePayload struct {
|
type statusPagePayload struct {
|
||||||
@@ -70,7 +84,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
|
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
|
||||||
if !enabled || h.frp == nil {
|
if !enabled || (h.frp == nil && h.cloudflare == nil) {
|
||||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,17 +98,30 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := statusPagePayload{
|
payload := statusPagePayload{
|
||||||
Title: "Fonu 服务状态",
|
Title: "Fonu 服务状态",
|
||||||
CodeRequired: codeRequired,
|
CodeRequired: codeRequired,
|
||||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
Routes: make([]statusPageRoute, 0, len(stats)),
|
Routes: make([]statusPageRoute, 0),
|
||||||
|
}
|
||||||
|
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
|
||||||
|
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
for _, stat := range stats {
|
for _, stat := range stats {
|
||||||
switch stat.State {
|
switch stat.State {
|
||||||
@@ -108,12 +135,64 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
payload.Routes = append(payload.Routes, statusPageRoute{
|
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||||
Domain: stat.Domain,
|
Domain: stat.Domain,
|
||||||
State: stat.State,
|
State: stat.State,
|
||||||
|
Source: "frp",
|
||||||
Uptime: stat.Uptime,
|
Uptime: stat.Uptime,
|
||||||
DownMinutes: stat.DownMinutes,
|
DownMinutes: stat.DownMinutes,
|
||||||
UnknownMinutes: stat.UnknownMinutes,
|
UnknownMinutes: stat.UnknownMinutes,
|
||||||
|
Incidents: mapFRPIncidents(stat.Incidents),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, payload)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, stat := range stats {
|
||||||
|
switch stat.State {
|
||||||
|
case cloudflared.HealthUp:
|
||||||
|
payload.Up++
|
||||||
|
case cloudflared.HealthDown:
|
||||||
|
payload.Down++
|
||||||
|
default:
|
||||||
|
payload.Unknown++
|
||||||
|
}
|
||||||
|
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||||
|
Domain: stat.Domain,
|
||||||
|
State: stat.State,
|
||||||
|
Source: "cloudflare",
|
||||||
|
Uptime: stat.Uptime,
|
||||||
|
DownMinutes: stat.DownMinutes,
|
||||||
|
UnknownMinutes: stat.UnknownMinutes,
|
||||||
|
Incidents: mapCloudflareIncidents(stat.Incidents),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
|
||||||
|
out := make([]statusPageIncident, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, statusPageIncident{
|
||||||
|
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
|
||||||
|
out := make([]statusPageIncident, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, statusPageIncident{
|
||||||
|
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashStatusPageCode(code string) string {
|
func hashStatusPageCode(code string) string {
|
||||||
|
|||||||
+3
-2
@@ -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(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/cloudflared"
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"github.com/fonu/fonu/internal/frp"
|
||||||
"github.com/fonu/fonu/internal/monitor"
|
"github.com/fonu/fonu/internal/monitor"
|
||||||
"github.com/fonu/fonu/internal/traffic"
|
"github.com/fonu/fonu/internal/traffic"
|
||||||
@@ -14,10 +15,60 @@ import (
|
|||||||
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
|
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
|
||||||
type monitorSource struct {
|
type monitorSource struct {
|
||||||
frp *frp.Service
|
frp *frp.Service
|
||||||
|
cloudflare *cloudflared.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
traffic *traffic.Collector
|
traffic *traffic.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return []monitor.CloudflareRouteStatus{}, nil
|
||||||
|
}
|
||||||
|
statuses, err := s.cloudflare.HealthStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
|
||||||
|
for _, item := range statuses {
|
||||||
|
out = append(out, monitor.CloudflareRouteStatus{
|
||||||
|
TunnelID: item.TunnelID,
|
||||||
|
Name: item.Name,
|
||||||
|
Domain: item.Domain,
|
||||||
|
State: item.State,
|
||||||
|
Reason: item.Reason,
|
||||||
|
Detail: item.Detail,
|
||||||
|
Known: item.Known,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
converted := make([]cloudflared.HealthSample, 0, len(samples))
|
||||||
|
for _, sample := range samples {
|
||||||
|
converted = append(converted, cloudflared.HealthSample{
|
||||||
|
TunnelID: sample.TunnelID,
|
||||||
|
Name: sample.Name,
|
||||||
|
Domain: sample.Domain,
|
||||||
|
State: sample.State,
|
||||||
|
Reason: sample.Reason,
|
||||||
|
Detail: sample.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s.cloudflare.RecordHealth(ctx, converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
|
||||||
|
if s.cloudflare == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.cloudflare.PruneHealth(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
|
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
|
||||||
servers, err := s.enabledServers(ctx)
|
servers, err := s.enabledServers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 隧道配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +115,70 @@ 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)))
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp, target); err != nil {
|
previous := target + ".previous"
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
_ = os.Remove(previous)
|
||||||
|
if err := os.Rename(target, previous); err != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, target); err != nil {
|
||||||
|
_ = os.Rename(previous, target)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.BinaryVersion(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
|
||||||
|
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(payload.TagName)
|
||||||
|
if version == "" {
|
||||||
|
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RollbackBinary 恢复上一版 cloudflared 二进制。
|
||||||
|
func (s *Service) RollbackBinary() (string, error) {
|
||||||
|
target := s.binaryPath()
|
||||||
|
previous := target + ".previous"
|
||||||
|
if _, err := os.Stat(previous); err != nil {
|
||||||
|
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
|
||||||
|
}
|
||||||
|
failed := target + ".failed"
|
||||||
|
_ = os.Remove(failed)
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
if err := os.Rename(target, failed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(previous, target); err != nil {
|
||||||
|
_ = os.Rename(failed, target)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Remove(failed)
|
||||||
return s.BinaryVersion(), nil
|
return s.BinaryVersion(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
return a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, nil)
|
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return records[0].ID, nil
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(created.ID) == "" {
|
||||||
|
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
|
||||||
|
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessApp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
|
||||||
|
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
|
||||||
|
var apps []accessApp
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
wanted := strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
for _, app := range apps {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
|
||||||
|
return true, app.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HealthUp = "up"
|
||||||
|
HealthDown = "down"
|
||||||
|
HealthUnknown = "unknown"
|
||||||
|
|
||||||
|
HealthRetention = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
|
||||||
|
type HealthStatus struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
Known bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthSample 交给落库层的状态样本。
|
||||||
|
type HealthSample struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthEvent 一条状态变更记录。
|
||||||
|
type HealthEvent struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthIncident 一段 Cloudflare 隧道不可用区间。
|
||||||
|
type HealthIncident struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
|
||||||
|
type UptimeStat struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Uptime float64 `json:"uptime_pct"`
|
||||||
|
DownMinutes int `json:"down_minutes"`
|
||||||
|
UnknownMinutes int `json:"unknown_minutes"`
|
||||||
|
CoveredMinutes int `json:"covered_minutes"`
|
||||||
|
Incidents []HealthIncident `json:"incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
|
||||||
|
type TunnelSummary struct {
|
||||||
|
Count int
|
||||||
|
Online int
|
||||||
|
Status string
|
||||||
|
Message string
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
|
||||||
|
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
|
||||||
|
settings, err := s.store.GetSettingsRaw(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tunnels, err := s.store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []HealthStatus{}
|
||||||
|
for i := range tunnels {
|
||||||
|
tunnel := tunnels[i]
|
||||||
|
s.attachConfig(&tunnel)
|
||||||
|
status := s.manager.Status(tunnel.ID)
|
||||||
|
for _, domain := range tunnel.Hostnames {
|
||||||
|
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
|
||||||
|
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
|
||||||
|
if !globalEnabled {
|
||||||
|
item.Reason = "disabled"
|
||||||
|
item.Detail = "Cloudflare 隧道全局托管已停用"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
item.Known = true
|
||||||
|
if status.Status == "connected" {
|
||||||
|
item.State = HealthUp
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
item.State = HealthDown
|
||||||
|
item.Reason = "connection"
|
||||||
|
item.Detail = strings.TrimSpace(status.LastError)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if tunnel.AutoStart {
|
||||||
|
item.Known = true
|
||||||
|
item.State = HealthDown
|
||||||
|
item.Reason = "process"
|
||||||
|
item.Detail = strings.TrimSpace(status.LastError)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
item.Reason = "stopped"
|
||||||
|
item.Detail = "隧道未运行,且未启用自动启动"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
|
||||||
|
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.latestHealthStates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
for _, sample := range samples {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
|
||||||
|
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := healthKey(sample.TunnelID, domain)
|
||||||
|
if state, ok := current[key]; ok && state == sample.State {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
|
||||||
|
rows, err := s.store.db.QueryContext(ctx, `
|
||||||
|
SELECT e.tunnel_id, e.domain, e.state
|
||||||
|
FROM cf_health_events e
|
||||||
|
JOIN (
|
||||||
|
SELECT tunnel_id, domain, MAX(id) AS id
|
||||||
|
FROM cf_health_events
|
||||||
|
GROUP BY tunnel_id, domain
|
||||||
|
) m ON m.id = e.id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
states := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var tunnelID int64
|
||||||
|
var domain, state string
|
||||||
|
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
states[healthKey(tunnelID, domain)] = state
|
||||||
|
}
|
||||||
|
return states, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
|
||||||
|
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
|
||||||
|
if window <= 0 {
|
||||||
|
window = 24 * time.Hour
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
since := now.Add(-window)
|
||||||
|
events, err := s.healthEventsSince(ctx, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ComputeHealthUptime(events, since, now), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
|
||||||
|
stamp := since.UTC().Format(time.RFC3339)
|
||||||
|
rows, err := s.store.db.QueryContext(ctx, `
|
||||||
|
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
|
||||||
|
FROM cf_health_events WHERE at >= ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
|
||||||
|
FROM cf_health_events e
|
||||||
|
JOIN (
|
||||||
|
SELECT tunnel_id, domain, MAX(id) AS id
|
||||||
|
FROM cf_health_events WHERE at < ?
|
||||||
|
GROUP BY tunnel_id, domain
|
||||||
|
) m ON m.id = e.id
|
||||||
|
`, stamp, stamp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
events := []HealthEvent{}
|
||||||
|
for rows.Next() {
|
||||||
|
var event HealthEvent
|
||||||
|
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneHealth 清理超过保留期的状态变更。
|
||||||
|
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
|
||||||
|
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
|
||||||
|
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 返回隧道数量、在线数与最近错误。
|
||||||
|
func (s *Service) Summary(ctx context.Context) TunnelSummary {
|
||||||
|
tunnels, err := s.store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelSummary{Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
|
||||||
|
if len(tunnels) == 0 {
|
||||||
|
summary.Status = "none"
|
||||||
|
summary.Message = "未创建隧道"
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
for _, tunnel := range tunnels {
|
||||||
|
status := s.manager.Status(tunnel.ID)
|
||||||
|
if status.Running && status.Status == "connected" {
|
||||||
|
summary.Online++
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tunnel.LastError) != "" {
|
||||||
|
summary.LastError = tunnel.LastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary.Online == 0 {
|
||||||
|
summary.Status = "error"
|
||||||
|
summary.Message = "没有在线隧道"
|
||||||
|
} else if summary.Online < summary.Count {
|
||||||
|
summary.Status = "warning"
|
||||||
|
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
|
||||||
|
} else {
|
||||||
|
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthKey(tunnelID int64, domain string) string {
|
||||||
|
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
|
||||||
|
type healthPoint struct {
|
||||||
|
at time.Time
|
||||||
|
tunnelID int64
|
||||||
|
domain string
|
||||||
|
state string
|
||||||
|
reason, detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
|
||||||
|
byRule := map[string][]healthPoint{}
|
||||||
|
order := make([]string, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
at, err := time.Parse(time.RFC3339, event.At)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := healthKey(event.TunnelID, event.Domain)
|
||||||
|
if _, ok := byRule[key]; !ok {
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
byRule[key] = append(byRule[key], healthPoint{
|
||||||
|
at: at, tunnelID: event.TunnelID, domain: event.Domain,
|
||||||
|
state: event.State, reason: event.Reason, detail: event.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
stats := make([]UptimeStat, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
points := byRule[key]
|
||||||
|
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
|
||||||
|
stat := computeHealthPoints(points, from, to)
|
||||||
|
if stat.Domain != "" {
|
||||||
|
stats = append(stats, stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
|
||||||
|
start := from
|
||||||
|
index := 0
|
||||||
|
state, reason, detail := "", "", ""
|
||||||
|
for index < len(points) && !points[index].at.After(from) {
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
if index >= len(points) {
|
||||||
|
return UptimeStat{}
|
||||||
|
}
|
||||||
|
start = points[index].at
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
|
||||||
|
upMinutes := 0
|
||||||
|
for {
|
||||||
|
end := to
|
||||||
|
ongoing := true
|
||||||
|
if index < len(points) {
|
||||||
|
end = points[index].at
|
||||||
|
ongoing = false
|
||||||
|
}
|
||||||
|
minutes := int(end.Sub(start).Minutes())
|
||||||
|
if minutes < 0 {
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case HealthUp:
|
||||||
|
upMinutes += minutes
|
||||||
|
case HealthDown:
|
||||||
|
stat.DownMinutes += minutes
|
||||||
|
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
|
||||||
|
if !ongoing {
|
||||||
|
incident.To = end.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
stat.Incidents = append(stat.Incidents, incident)
|
||||||
|
default:
|
||||||
|
stat.UnknownMinutes += minutes
|
||||||
|
}
|
||||||
|
stat.CoveredMinutes += minutes
|
||||||
|
stat.State = state
|
||||||
|
if index >= len(points) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start = points[index].at
|
||||||
|
state, reason, detail = points[index].state, points[index].reason, points[index].detail
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
observed := upMinutes + stat.DownMinutes
|
||||||
|
if observed > 0 {
|
||||||
|
stat.Uptime = float64(upMinutes) / float64(observed) * 100
|
||||||
|
} else {
|
||||||
|
stat.Uptime = 100
|
||||||
|
if stat.UnknownMinutes > 0 {
|
||||||
|
stat.Uptime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stat
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/config"
|
||||||
|
"github.com/fonu/fonu/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
|
||||||
|
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := from.Add(2 * time.Hour)
|
||||||
|
events := []HealthEvent{
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
|
||||||
|
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
|
||||||
|
}
|
||||||
|
stats := ComputeHealthUptime(events, from, to)
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("应返回一条统计:%#v", stats)
|
||||||
|
}
|
||||||
|
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
|
||||||
|
t.Fatalf("可用率计算不符:%#v", stats[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||||
|
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
|
||||||
|
if err := svc.RecordHealth(context.Background(), sample); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.RecordHealth(context.Background(), sample); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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 只回显掩码,不返回明文。
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
|
||||||
|
type DriftResult struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
HasDrift bool `json:"has_drift"`
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticCheck 一项诊断结果。
|
||||||
|
type DiagnosticCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
|
||||||
|
type DiagnosticsResult struct {
|
||||||
|
TunnelID int64 `json:"tunnel_id"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Checks []DiagnosticCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightCheck 是接入向导的一项预检结果。
|
||||||
|
type PreflightCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
|
||||||
|
type PreflightResult struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Checks []PreflightCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
|
||||||
|
func (s *Service) Preflight(ctx context.Context) PreflightResult {
|
||||||
|
binary := s.binaryCheck()
|
||||||
|
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
|
||||||
|
row, err := s.store.GetSettingsRaw(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
|
||||||
|
}
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
|
||||||
|
})
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
|
||||||
|
})
|
||||||
|
if row.APITokenEnc == "" {
|
||||||
|
return PreflightResult{Ready: false, Checks: checks}
|
||||||
|
}
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
|
||||||
|
}
|
||||||
|
zones, err := api.ListZones(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
|
||||||
|
}
|
||||||
|
checks = append(checks, PreflightCheck{
|
||||||
|
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
|
||||||
|
})
|
||||||
|
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusBool(ok bool) string {
|
||||||
|
if ok {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolText(ok bool, yes, no string) string {
|
||||||
|
if ok {
|
||||||
|
return yes
|
||||||
|
}
|
||||||
|
return no
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPreflightError(checks []PreflightCheck) bool {
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.Status == "error" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
|
||||||
|
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
|
||||||
|
if !tunnel.Managed {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
rules, err := s.rules.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return DriftResult{}, err
|
||||||
|
}
|
||||||
|
result.Items = compareIngress(expected, actual)
|
||||||
|
result.HasDrift = len(result.Items) > 0
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
|
||||||
|
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !tunnel.Managed {
|
||||||
|
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
|
||||||
|
}
|
||||||
|
return s.syncManagedTunnel(ctx, tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareIngress(expected, actual []IngressRule) []string {
|
||||||
|
expectedMap := ingressMap(expected)
|
||||||
|
actualMap := ingressMap(actual)
|
||||||
|
items := []string{}
|
||||||
|
keys := unionKeys(expectedMap, actualMap)
|
||||||
|
for _, key := range keys {
|
||||||
|
want, wantOK := expectedMap[key]
|
||||||
|
got, gotOK := actualMap[key]
|
||||||
|
switch {
|
||||||
|
case wantOK && !gotOK:
|
||||||
|
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
|
||||||
|
case !wantOK && gotOK:
|
||||||
|
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
|
||||||
|
case want != got:
|
||||||
|
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingressMap(ingress []IngressRule) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, item := range ingress {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(item.Hostname))
|
||||||
|
if key == "" {
|
||||||
|
key = "(catch-all)"
|
||||||
|
}
|
||||||
|
out[key] = strings.TrimSpace(item.Service)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func unionKeys(a, b map[string]string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for key := range a {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
for key := range b {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(seen))
|
||||||
|
for key := range seen {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
|
||||||
|
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticsResult{}, err
|
||||||
|
}
|
||||||
|
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
|
||||||
|
result.Checks = append(result.Checks, s.binaryCheck())
|
||||||
|
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
|
||||||
|
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
|
||||||
|
api := s.apiDiagnostic(ctx, tunnel)
|
||||||
|
result.Checks = append(result.Checks, api)
|
||||||
|
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
|
||||||
|
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
|
||||||
|
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) binaryCheck() DiagnosticCheck {
|
||||||
|
version := s.BinaryVersion()
|
||||||
|
if strings.TrimSpace(version) == "" {
|
||||||
|
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configCheck(path string) DiagnosticCheck {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
if status.Running && status.Status == "connected" {
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
|
||||||
|
}
|
||||||
|
if tunnel.AutoStart {
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
if tunnel.TunnelID == "" {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
|
||||||
|
}
|
||||||
|
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
|
||||||
|
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
|
||||||
|
}
|
||||||
|
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkCheck(name, address string) DiagnosticCheck {
|
||||||
|
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteTestResult 是单条路由源服务的连通性测试结果。
|
||||||
|
type RouteTestResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
LatencyMS float64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService 测试一条路由 service 的 TCP 连通性。
|
||||||
|
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
|
||||||
|
address, err := serviceAddress(service)
|
||||||
|
if err != nil {
|
||||||
|
return RouteTestResult{}, err
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return RouteTestResult{OK: false, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceAddress(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
if !strings.Contains(raw, ":") {
|
||||||
|
return "", fmt.Errorf("service 格式无效:%s", raw)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
port := parsed.Port()
|
||||||
|
if port == "" {
|
||||||
|
switch parsed.Scheme {
|
||||||
|
case "http":
|
||||||
|
port = "80"
|
||||||
|
case "https":
|
||||||
|
port = "443"
|
||||||
|
case "ssh":
|
||||||
|
port = "22"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("service 缺少端口:%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
|
||||||
|
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
|
||||||
|
actual := []IngressRule{
|
||||||
|
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
|
||||||
|
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Service: "http_status:404"},
|
||||||
|
}
|
||||||
|
items := compareIngress(expected, actual)
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareIngressIgnoresOrder(t *testing.T) {
|
||||||
|
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
|
||||||
|
actual := []IngressRule{
|
||||||
|
{Service: "http_status:404"},
|
||||||
|
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
|
||||||
|
}
|
||||||
|
if items := compareIngress(expected, actual); len(items) != 0 {
|
||||||
|
t.Fatalf("顺序不同不应算漂移:%#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
|
"github.com/fonu/fonu/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteView 是配置页展示的一条 ingress 路由。
|
||||||
|
type RouteView struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelRoutes 是配置页需要的路由列表。
|
||||||
|
type TunnelRoutes struct {
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
Routes []RouteView `json:"routes"`
|
||||||
|
CatchAll string `json:"catch_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteInput 是手工隧道路由保存入参。
|
||||||
|
type RouteInput struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
|
||||||
|
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Managed {
|
||||||
|
return s.managedRoutes(ctx, tunnel)
|
||||||
|
}
|
||||||
|
return manualRoutes(tunnel.ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
|
||||||
|
if s.rules == nil {
|
||||||
|
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
|
||||||
|
}
|
||||||
|
rules, err := s.rules.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
|
||||||
|
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
|
||||||
|
for _, host := range rule.Hostnames() {
|
||||||
|
out.Routes = append(out.Routes, RouteView{
|
||||||
|
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualRoutes(configPath string) (TunnelRoutes, error) {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
|
||||||
|
}
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
host, found := strings.CutPrefix(line, "- hostname:")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostname := strings.TrimSpace(host)
|
||||||
|
if hostname == "" || i+1 >= len(lines) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := "*"
|
||||||
|
next := i + 1
|
||||||
|
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
|
||||||
|
path = strings.TrimSpace(value)
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
if next >= len(lines) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Routes = append(out.Routes, RouteView{
|
||||||
|
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out.Routes) > 0 {
|
||||||
|
catchAll, err := catchAllService(data)
|
||||||
|
if err == nil && catchAll != "" {
|
||||||
|
out.CatchAll = catchAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchAllService(data []byte) (string, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.HasPrefix(line, "- service:") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("未找到 catch-all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
|
||||||
|
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
|
||||||
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Managed {
|
||||||
|
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
|
||||||
|
}
|
||||||
|
ingress, hostnames, err := buildManualIngress(routes)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
|
||||||
|
api, err := s.cloudflareAPI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
|
||||||
|
return TunnelRoutes{}, err
|
||||||
|
}
|
||||||
|
return s.Routes(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
ingress := make([]IngressRule, 0, len(routes)+1)
|
||||||
|
hostnames := make([]string, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
|
||||||
|
if err := validate.Domain(hostname); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
|
||||||
|
}
|
||||||
|
if _, ok := seen[hostname]; ok {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
|
||||||
|
}
|
||||||
|
seen[hostname] = struct{}{}
|
||||||
|
service := strings.TrimSpace(route.Service)
|
||||||
|
if service == "" || !strings.Contains(service, "://") {
|
||||||
|
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(route.Path)
|
||||||
|
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
|
||||||
|
if path == "" || path == "*" {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
|
||||||
|
hostnames = append(hostnames, hostname)
|
||||||
|
}
|
||||||
|
ingress = append(ingress, IngressRule{Service: "http_status:404"})
|
||||||
|
return ingress, hostnames, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package cloudflared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManualRoutesFromConfig(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
content := `tunnel: test
|
||||||
|
ingress:
|
||||||
|
- hostname: a.example.com
|
||||||
|
service: http://127.0.0.1:8080
|
||||||
|
- hostname: b.example.com
|
||||||
|
service: http://127.0.0.1:9090
|
||||||
|
- service: http_status:404
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := manualRoutes(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
|
||||||
|
t.Fatalf("手工路由解析不符:%#v", result)
|
||||||
|
}
|
||||||
|
if result.CatchAll != "http_status:404" {
|
||||||
|
t.Fatalf("catch-all 不符:%q", result.CatchAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildManualIngress(t *testing.T) {
|
||||||
|
ingress, hostnames, err := buildManualIngress([]RouteInput{
|
||||||
|
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
|
||||||
|
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
|
||||||
|
t.Fatalf("ingress 结果不符:%#v", ingress)
|
||||||
|
}
|
||||||
|
if ingress[0].Path != "" || ingress[1].Path != "" {
|
||||||
|
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
|
||||||
|
}
|
||||||
|
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
|
||||||
|
t.Fatalf("hostnames 结果不符:%#v", hostnames)
|
||||||
|
}
|
||||||
|
}
|
||||||
+320
-19
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -57,6 +57,38 @@ type HealthRecorder interface {
|
|||||||
PruneRouteHealth(ctx context.Context) error
|
PruneRouteHealth(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloudflareRouteStatus 是 Cloudflare 隧道域名的巡检快照。
|
||||||
|
type CloudflareRouteStatus struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
Known bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudflareHealthSample 是 Cloudflare 隧道的状态变更样本。
|
||||||
|
type CloudflareHealthSample struct {
|
||||||
|
TunnelID int64
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudflareHealthSource 提供 Cloudflare 隧道健康快照。
|
||||||
|
type CloudflareHealthSource interface {
|
||||||
|
CloudflareRoutes(ctx context.Context) ([]CloudflareRouteStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudflareHealthRecorder 持久化 Cloudflare 隧道健康状态。
|
||||||
|
type CloudflareHealthRecorder interface {
|
||||||
|
RecordCloudflareHealth(ctx context.Context, samples []CloudflareHealthSample) error
|
||||||
|
PruneCloudflareHealth(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
|
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
|
||||||
type ServerStatus struct {
|
type ServerStatus struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -120,6 +152,7 @@ type Checker struct {
|
|||||||
driftAlerted map[int64]bool
|
driftAlerted map[int64]bool
|
||||||
driftNotified map[int64]time.Time
|
driftNotified map[int64]time.Time
|
||||||
lastHealthPrune time.Time
|
lastHealthPrune time.Time
|
||||||
|
lastCFHealthPrune time.Time
|
||||||
lastMetricsPrune time.Time
|
lastMetricsPrune time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +184,7 @@ func (c *Checker) Run(ctx context.Context) {
|
|||||||
c.checkServers(ctx)
|
c.checkServers(ctx)
|
||||||
c.checkAgents(ctx)
|
c.checkAgents(ctx)
|
||||||
c.checkDrift(ctx)
|
c.checkDrift(ctx)
|
||||||
|
c.checkCloudflare(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
|
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
|
||||||
@@ -343,6 +377,84 @@ func (c *Checker) checkRoutes(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkCloudflare 把 Cloudflare 隧道纳入现有巡检、通知与健康记录。
|
||||||
|
func (c *Checker) checkCloudflare(ctx context.Context) {
|
||||||
|
source, ok := c.source.(CloudflareHealthSource)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
|
||||||
|
recorder, canRecord := c.source.(CloudflareHealthRecorder)
|
||||||
|
if !canRecord {
|
||||||
|
recordHealth = false
|
||||||
|
}
|
||||||
|
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
|
||||||
|
if !recordHealth && !alertEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routes, err := source.CloudflareRoutes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("巡检 Cloudflare 隧道状态失败", "module", "MONITOR", "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if recordHealth {
|
||||||
|
samples := make([]CloudflareHealthSample, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
if strings.TrimSpace(route.Domain) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
samples = append(samples, CloudflareHealthSample{
|
||||||
|
TunnelID: route.TunnelID,
|
||||||
|
Name: route.Name,
|
||||||
|
Domain: route.Domain,
|
||||||
|
State: route.State,
|
||||||
|
Reason: route.Reason,
|
||||||
|
Detail: route.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := recorder.RecordCloudflareHealth(ctx, samples); err != nil {
|
||||||
|
c.logger.Warn("记录 Cloudflare 隧道健康失败", "module", "MONITOR", "error", err.Error())
|
||||||
|
}
|
||||||
|
c.pruneCloudflareHealthIfDue(ctx, recorder)
|
||||||
|
}
|
||||||
|
if !alertEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
|
||||||
|
if limit <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, route := range routes {
|
||||||
|
if !route.Known || strings.TrimSpace(route.Domain) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, event := c.track(fmt.Sprintf("cloudflare:%d:%s", route.TunnelID, route.Domain), route.State == HealthUp, limit)
|
||||||
|
switch event {
|
||||||
|
case failureEventDown:
|
||||||
|
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道连续不通",
|
||||||
|
fmt.Sprintf("%s 连续 %d 次巡检未连接:%s", route.Domain, limit, detailOr(route.Detail)))
|
||||||
|
case failureEventRecovered:
|
||||||
|
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道已恢复",
|
||||||
|
fmt.Sprintf("%s 已重新连接 Cloudflare 边缘", route.Domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) pruneCloudflareHealthIfDue(ctx context.Context, recorder CloudflareHealthRecorder) {
|
||||||
|
c.mu.Lock()
|
||||||
|
due := c.lastCFHealthPrune.IsZero() || time.Since(c.lastCFHealthPrune) >= 24*time.Hour
|
||||||
|
if due {
|
||||||
|
c.lastCFHealthPrune = time.Now()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !due {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := recorder.PruneCloudflareHealth(ctx); err != nil {
|
||||||
|
c.logger.Warn("清理 Cloudflare 隧道健康记录失败", "module", "MONITOR", "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
|
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
|
||||||
func healthStateOf(route RouteStatus) (string, string) {
|
func healthStateOf(route RouteStatus) (string, string) {
|
||||||
if !route.Known {
|
if !route.Known {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
|
||||||
|
store, _ := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
rule, err := store.Create(ctx, CreateInput{
|
||||||
|
Upstream: "http://192.168.1.10:8080",
|
||||||
|
ListenPort: 8443,
|
||||||
|
ListenIPv4: true,
|
||||||
|
Hosts: []string{"cf.example.com"},
|
||||||
|
Enabled: true,
|
||||||
|
Exits: []string{ExitCloudflare},
|
||||||
|
CFTunnelID: 7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
|
||||||
|
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
|
||||||
|
}
|
||||||
|
localRules, err := store.ListEnabledLocal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(localRules) != 0 {
|
||||||
|
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
updated, err := store.Update(ctx, rule.ID, UpdateInput{
|
||||||
|
Exits: &[]string{ExitLocal, ExitCloudflare},
|
||||||
|
Enabled: &enabled,
|
||||||
|
CFTunnelID: int64Ptr(7),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(updated.Exits) != 2 {
|
||||||
|
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
|
||||||
|
}
|
||||||
|
localRules, err = store.ListEnabledLocal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(localRules) != 1 || localRules[0].ID != rule.ID {
|
||||||
|
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(v int64) *int64 { return &v }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
|
||||||
|
exits, tunnelID, err := normalizeExits(nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
|
||||||
|
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
|
||||||
|
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
|
||||||
|
t.Fatal("Cloudflare 出口缺少隧道时应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
|
||||||
|
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
|
||||||
|
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
|
||||||
|
rule := Rule{}
|
||||||
|
if !rule.UsesExit(ExitLocal) {
|
||||||
|
t.Fatal("空出口应兼容为本机出口")
|
||||||
|
}
|
||||||
|
if rule.UsesExit(ExitCloudflare) {
|
||||||
|
t.Fatal("空出口不应被视为 Cloudflare")
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
-8
@@ -3,14 +3,21 @@ package proxy
|
|||||||
import (
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ type ProxyService struct {
|
|||||||
settings *settings.Store
|
settings *settings.Store
|
||||||
nginx *nginx.Manager
|
nginx *nginx.Manager
|
||||||
notify *notify.Service
|
notify *notify.Service
|
||||||
|
exitSync ExitSync
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExitSync 处理服务发布规则变化后的外部出口同步。
|
||||||
|
type ExitSync interface {
|
||||||
|
SyncRules(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
|
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
|
||||||
@@ -33,6 +39,10 @@ func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
|
|||||||
s.notify = notifySvc
|
s.notify = notifySvc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProxyService) SetExitSync(sync ExitSync) {
|
||||||
|
s.exitSync = sync
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
|
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
|
||||||
return s.store.List(ctx)
|
return s.store.List(ctx)
|
||||||
}
|
}
|
||||||
@@ -42,9 +52,11 @@ func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
|
func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
|
||||||
|
if proxy.HasExit(in.Exits, proxy.ExitLocal) {
|
||||||
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
|
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
|
||||||
return proxy.Rule{}, err
|
return proxy.Rule{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rule, err := s.store.Create(ctx, in)
|
rule, err := s.store.Create(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,6 +65,9 @@ func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.
|
|||||||
if err := s.applyNginx(ctx); err != nil {
|
if err := s.applyNginx(ctx); err != nil {
|
||||||
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
||||||
}
|
}
|
||||||
|
if err := s.syncExits(ctx); err != nil {
|
||||||
|
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
|
||||||
|
}
|
||||||
return rule, nil
|
return rule, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +84,15 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
|
|||||||
if in.Hosts != nil {
|
if in.Hosts != nil {
|
||||||
hosts = *in.Hosts
|
hosts = *in.Hosts
|
||||||
}
|
}
|
||||||
|
exits := current.Exits
|
||||||
|
if in.Exits != nil {
|
||||||
|
exits = *in.Exits
|
||||||
|
}
|
||||||
|
if proxy.HasExit(exits, proxy.ExitLocal) {
|
||||||
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
|
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
|
||||||
return proxy.Rule{}, err
|
return proxy.Rule{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rule, err := s.store.Update(ctx, id, in)
|
rule, err := s.store.Update(ctx, id, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,9 +101,19 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
|
|||||||
if err := s.applyNginx(ctx); err != nil {
|
if err := s.applyNginx(ctx); err != nil {
|
||||||
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
|
||||||
}
|
}
|
||||||
|
if err := s.syncExits(ctx); err != nil {
|
||||||
|
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
|
||||||
|
}
|
||||||
return rule, nil
|
return rule, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProxyService) syncExits(ctx context.Context) error {
|
||||||
|
if s.exitSync == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.exitSync.SyncRules(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
|
func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
|
||||||
return s.store.Reorder(ctx, ids)
|
return s.store.Reorder(ctx, ids)
|
||||||
}
|
}
|
||||||
@@ -95,6 +126,9 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
|
|||||||
if err := s.applyNginx(ctx); err != nil {
|
if err := s.applyNginx(ctx); err != nil {
|
||||||
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
|
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
|
||||||
}
|
}
|
||||||
|
if err := s.syncExits(ctx); err != nil {
|
||||||
|
return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +137,7 @@ func (s *ProxyService) ReloadAll(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProxyService) applyNginx(ctx context.Context) error {
|
func (s *ProxyService) applyNginx(ctx context.Context) error {
|
||||||
rules, err := s.store.ListEnabled(ctx)
|
rules, err := s.store.ListEnabledLocal(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,17 +158,17 @@ func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, e
|
|||||||
replaced := false
|
replaced := false
|
||||||
for _, r := range existing {
|
for _, r := range existing {
|
||||||
if r.ID == candidate.ID {
|
if r.ID == candidate.ID {
|
||||||
if candidate.Enabled {
|
if candidate.LocalEnabled() {
|
||||||
rules = append(rules, candidate)
|
rules = append(rules, candidate)
|
||||||
}
|
}
|
||||||
replaced = true
|
replaced = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r.Enabled {
|
if r.LocalEnabled() {
|
||||||
rules = append(rules, r)
|
rules = append(rules, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !replaced && candidate.Enabled {
|
if !replaced && candidate.LocalEnabled() {
|
||||||
rules = append(rules, candidate)
|
rules = append(rules, candidate)
|
||||||
}
|
}
|
||||||
certs, err := s.loadCertSources(ctx)
|
certs, err := s.loadCertSources(ctx)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -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) }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
<span>隧道</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>副本</span>
|
||||||
|
<span>版本</span>
|
||||||
|
<span>路由</span>
|
||||||
|
<span>调和</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="item in tunnels" :key="item.id" class="cf-table__row">
|
||||||
|
<div class="cf-table__tunnel">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" />
|
<div class="cf-table__meta">
|
||||||
|
<n-tag v-if="item.managed" size="small" type="info" :bordered="false" round>自动托管</n-tag>
|
||||||
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
|
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
|
||||||
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
|
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
|
||||||
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
|
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<StatusBadge
|
||||||
|
:kind="statusKind(runtimeStatus(item).status || item.status)"
|
||||||
|
:text="statusText(runtimeStatus(item).status || item.status)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="cf-table__number">{{ replicaCount(item) }}</div>
|
||||||
|
<div class="cf-meta mono">{{ displayBinaryVersion(binaryVersion) || '—' }}</div>
|
||||||
|
<div><n-tag size="small" :bordered="false" round>{{ routeCount(item) }} 条</n-tag></div>
|
||||||
|
<div>
|
||||||
|
<n-tag size="small" :type="driftTagType(item)" :bordered="false" round>{{ driftText(item) }}</n-tag>
|
||||||
|
</div>
|
||||||
<div class="cf-row__actions">
|
<div class="cf-row__actions">
|
||||||
<n-button size="small" @click="openDetail(item)">详情</n-button>
|
<n-button size="small" @click="openDetail(item)">详情</n-button>
|
||||||
<n-button size="small" @click="openEdit(item)">编辑</n-button>
|
<n-button size="small" @click="openConfig(item)">配置</n-button>
|
||||||
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
|
<n-button v-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 v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
|
||||||
<n-button size="small" @click="restartTunnel(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)">
|
<n-popconfirm @positive-click="removeTunnel(item)">
|
||||||
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
|
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
|
||||||
将停止该隧道并删除本地配置、凭据与日志。继续?
|
将停止该隧道并删除本地配置、凭据与日志。继续?
|
||||||
</n-popconfirm>
|
</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-item label="自动托管">
|
||||||
|
<div class="cf-managed-field">
|
||||||
|
<n-switch v-model:value="form.managed" />
|
||||||
|
<span>启用后,暴露域名与回源由「反向代理」规则中的 Cloudflare 出口自动生成。</span>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
<template v-if="!form.managed">
|
||||||
<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="暴露域名">
|
<div class="cf-rule-row">
|
||||||
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
|
<n-form-item label="子域名(可选)" class="cf-rule-row__subdomain">
|
||||||
|
<n-input v-model:value="manualRule.subdomain" placeholder="例如 999" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="回源 service">
|
<n-form-item label="域" class="cf-rule-row__domain">
|
||||||
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
|
<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-item>
|
||||||
</n-form>
|
</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()
|
||||||
|
if (!hostname) {
|
||||||
|
message.warning('请选择或填写域')
|
||||||
return
|
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); }
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-15
@@ -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,8 +1478,15 @@ function hostAccessUrl(rule: ProxyRule, hostPart: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ruleAccessUrls(rule: ProxyRule): string[] {
|
function ruleAccessUrls(rule: ProxyRule): string[] {
|
||||||
|
const exits = ruleExits(rule)
|
||||||
|
if (exits.includes('local')) {
|
||||||
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
|
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))
|
||||||
|
if (updated.sync_warning) {
|
||||||
|
message.warning(updated.sync_warning)
|
||||||
|
} else {
|
||||||
message.success(enabled ? '已启用' : '已停用')
|
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%;
|
||||||
|
|||||||
@@ -19,11 +19,22 @@
|
|||||||
<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">
|
||||||
|
<div class="status-row">
|
||||||
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
|
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
|
||||||
<span class="status-row__domain mono">{{ route.domain }}</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>
|
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user