mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
feat(cloudflare): add DNS pointing integration
Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
This commit is contained in:
@@ -22,6 +22,10 @@ sidebar: false
|
|||||||
|
|
||||||
## [unreleased]
|
## [unreleased]
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|
||||||
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
|
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ OpenResty (Agent, TLS/WAF)
|
|||||||
|
|
||||||
| 组件 | 职责 | 详细设计参考 |
|
| 组件 | 职责 | 详细设计参考 |
|
||||||
| --------------- | ---------------------------------------------------------------------- | ------------ |
|
| --------------- | ---------------------------------------------------------------------- | ------------ |
|
||||||
| **Server** | 管理端 UI/API、控制面状态持久化、配置编译渲染、发布版本控制、Pages 部署包存储、访问日志入库与业务流量聚合、Uptime Kuma 监控同步与登录验证码防护 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) / [Uptime Kuma 监控同步设计](./kuma-design.md) / [登录验证码设计](./login-captcha.md) |
|
| **Server** | 管理端 UI/API、控制面状态持久化、配置编译渲染、发布版本控制、Pages 部署包存储、Cloudflare A 记录指向、访问日志入库与业务流量聚合、Uptime Kuma 监控同步与登录验证码防护 | [Agent 与发布模型](./agent-design.md) / [Cloudflare DNS 指向设计](./cloudflare-pointing.md) / [边缘可观测与业务流量统计](./observability-design.md) / [Uptime Kuma 监控同步设计](./kuma-design.md) / [登录验证码设计](./login-captcha.md) |
|
||||||
| **Agent** | 周期心跳与 WS 同步、静态资源包拉取与解压、OpenResty 配置写入/校验/重载与自愈;观测仅上报访问明细与主机/健康读数,不做业务预聚合 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) |
|
| **Agent** | 周期心跳与 WS 同步、静态资源包拉取与解压、OpenResty 配置写入/校验/重载与自愈;观测仅上报访问明细与主机/健康读数,不做业务预聚合 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) |
|
||||||
| **OpenResty** | 接收真实流量,执行 WAF 过滤、PoW 防护、Basic Auth 认证与静态/反代服务 | [WAF 设计](./waf-design.md) / [Pages 设计](./pages-design.md) |
|
| **OpenResty** | 接收真实流量,执行 WAF 过滤、PoW 防护、Basic Auth 认证与静态/反代服务 | [WAF 设计](./waf-design.md) / [Pages 设计](./pages-design.md) |
|
||||||
| **Relay** | 部署于边缘节点,管理 `frps` 守护进程生命周期,接受心跳派发的穿透中继配置 | [内网穿透设计](./tunnel-design.md) |
|
| **Relay** | 部署于边缘节点,管理 `frps` 守护进程生命周期,接受心跳派发的穿透中继配置 | [内网穿透设计](./tunnel-design.md) |
|
||||||
@@ -81,6 +81,7 @@ OpenResty (Agent, TLS/WAF)
|
|||||||
* 边缘节点协议走 `/api/v1/agent|relay|tunnel/*`,分别使用 `X-Agent-Token` / `X-Tunnel-Token` 鉴权。
|
* 边缘节点协议走 `/api/v1/agent|relay|tunnel/*`,分别使用 `X-Agent-Token` / `X-Tunnel-Token` 鉴权。
|
||||||
* 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。
|
* 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。
|
||||||
* 统一接收 Pages 本地上传、Remote URL 与公开 GitHub Release 预构建产物,完成来源检查、受限下载、归档校验和不可变 deployment;manual 上传生成待显式激活的 candidate,持久来源 sync 才 create-or-load 并原子激活。Server 向 Agent 提供受控的 latest 下载接口;内部 scanner 负责 GitHub latest 的限量检查、租约恢复、可选自动发布与孤儿上传记录补偿,通用任务管理入口不能修改该排程。未来仓库源码构建由独立 Server build executor 扩展,Agent 不执行第三方拉取或构建命令。
|
* 统一接收 Pages 本地上传、Remote URL 与公开 GitHub Release 预构建产物,完成来源检查、受限下载、归档校验和不可变 deployment;manual 上传生成待显式激活的 candidate,持久来源 sync 才 create-or-load 并原子激活。Server 向 Agent 提供受控的 latest 下载接口;内部 scanner 负责 GitHub latest 的限量检查、租约恢复、可选自动发布与孤儿上传记录补偿,通用任务管理入口不能修改该排程。未来仓库源码构建由独立 Server build executor 扩展,Agent 不执行第三方拉取或构建命令。
|
||||||
|
* 提供可选的 Cloudflare DNS 指向控制面:以 ZoneDomain 为成员维护分组期望状态,通过 Asynq 将单条 A 记录幂等同步到当前生效节点 IPv4;节点 IP 变化只做 best-effort 入队,一期不执行自动故障切换。
|
||||||
* 后台集成 Uptime Kuma 监控同步服务,自动为可用站点维护 HTTP 探测任务。
|
* 后台集成 Uptime Kuma 监控同步服务,自动为可用站点维护 HTTP 探测任务。
|
||||||
* 启动入口为根目录 `main.go` + `internal/cmd/`(`api` / `worker` / `scheduler` / `all`);OpenFlare 业务在 `internal/apps/openflare/`,边缘协议处理在 `internal/apps/openflare/{agent,relay,flared}/`。
|
* 启动入口为根目录 `main.go` + `internal/cmd/`(`api` / `worker` / `scheduler` / `all`);OpenFlare 业务在 `internal/apps/openflare/`,边缘协议处理在 `internal/apps/openflare/{agent,relay,flared}/`。
|
||||||
* *详细设计请参阅:[Agent 与发布模型设计](./agent-design.md) 以及 [Uptime Kuma 监控同步设计](./kuma-design.md)*
|
* *详细设计请参阅:[Agent 与发布模型设计](./agent-design.md) 以及 [Uptime Kuma 监控同步设计](./kuma-design.md)*
|
||||||
@@ -154,6 +155,24 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
|
|||||||
* **原则**:Agent 只上报事实,Server 解释事实;业务流量唯一真相为访问日志。`openresty_tx` 与「已提供数据」不得双轨并存。
|
* **原则**:Agent 只上报事实,Server 解释事实;业务流量唯一真相为访问日志。`openresty_tx` 与「已提供数据」不得双轨并存。
|
||||||
* *传输模型、示例与采集频率详见:[观测数据传输模型](./observability-transport-model.md);字段收敛与迁移详见:[边缘可观测与业务流量统计](./observability-design.md)*
|
* *传输模型、示例与采集频率详见:[观测数据传输模型](./observability-transport-model.md);字段收敛与迁移详见:[边缘可观测与业务流量统计](./observability-design.md)*
|
||||||
|
|
||||||
|
### 5. Cloudflare DNS 指向流
|
||||||
|
|
||||||
|
```text
|
||||||
|
管理员配置连接/分组/成员 -> Server 持久化期望状态 -> Asynq 同步任务
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Cloudflare Zone / DNS API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
单条 A 记录 -> active_node IPv4
|
||||||
|
|
||||||
|
节点 IP 手动更新或 Agent 心跳变化 --------------------> 按节点 best-effort 入队
|
||||||
|
```
|
||||||
|
|
||||||
|
* Cloudflare 模块只管理其缓存或接管的唯一同名 A 记录,不把 Zone 核心扩展为权威 DNS 控制面;同名多 A 时停止同步并要求管理员先在 Cloudflare 清理。
|
||||||
|
* 分组备用节点与生效节点为后续故障切换预留,一期固定使用主节点,不根据心跳离线状态自动切换。
|
||||||
|
* *连接、模型、幂等同步与分期边界详见:[Cloudflare DNS 指向设计](./cloudflare-pointing.md)。*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
@@ -161,6 +180,7 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
|
|||||||
当前系统核心实体包括:
|
当前系统核心实体包括:
|
||||||
|
|
||||||
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
|
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
|
||||||
|
* **Cloudflare DNS 指向**:`of_cf_connections` (全局连接), `of_cf_pointing_groups` (主/备/生效节点与默认橙云), `of_cf_pointing_members` (ZoneDomain 成员、记录缓存与同步状态). 详见 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
|
||||||
* **Pages 静态托管**:`of_pages_projects` (Pages项目), `of_pages_project_sources` / `of_pages_project_source_runtime` (可变来源配置与运行态), `of_pages_deployments` (不可变部署), `of_pages_deployment_files` (部署文件清单).
|
* **Pages 静态托管**:`of_pages_projects` (Pages项目), `of_pages_project_sources` / `of_pages_project_source_runtime` (可变来源配置与运行态), `of_pages_deployments` (不可变部署), `of_pages_deployment_files` (部署文件清单).
|
||||||
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||||
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
|
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
|
||||||
@@ -176,6 +196,7 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
|
|||||||
| Agent 主动拉取 | Server 不需要 SSH 权限,降低安全风险;支持 HTTP 与 WebSocket 双协议灵活切换 |
|
| Agent 主动拉取 | Server 不需要 SSH 权限,降低安全风险;支持 HTTP 与 WebSocket 双协议灵活切换 |
|
||||||
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
|
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
|
||||||
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
|
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
|
||||||
|
| Cloudflare 指向独立于 Zone 核心 | ZoneDomain 只提供明确 FQDN;Cloudflare 模块以库表期望状态驱动单 A 记录,不扩大 Zone 为通用 DNS 控制面 |
|
||||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
|
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
|
||||||
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
|
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
|
||||||
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
|
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
|
||||||
@@ -189,12 +210,13 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
|
|||||||
修改系统架构或开发新功能前,请按以下顺序阅读:
|
修改系统架构或开发新功能前,请按以下顺序阅读:
|
||||||
|
|
||||||
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
|
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
|
||||||
3. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
|
2. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
|
||||||
4. **细分领域设计**:
|
3. **细分领域设计**:
|
||||||
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
|
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
|
||||||
|
* Cloudflare DNS 指向开发:阅读 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
|
||||||
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
|
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
|
||||||
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
|
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
|
||||||
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
|
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
|
||||||
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
|
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
|
||||||
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
|
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
|
||||||
5. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
|
4. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
|||||||
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
|
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
|
||||||
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;默认 static 扩展名 + 源站头/Set-Cookie 闸门 + 默认 Edge TTL(对标 CF 默认模型) | [边缘缓存策略设计](./edge-cache-design.md) |
|
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;默认 static 扩展名 + 源站头/Set-Cookie 闸门 + 默认 Edge TTL(对标 CF 默认模型) | [边缘缓存策略设计](./edge-cache-design.md) |
|
||||||
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
|
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
|
||||||
|
| **Cloudflare DNS 指向** | 以 ZoneDomain 为粒度,将单条 Cloudflare A 记录幂等指向边缘节点 IPv4;支持连接配置、分组、成员橙云与异步同步,一期不含自动故障切换 | [Cloudflare DNS 指向设计](./cloudflare-pointing.md) |
|
||||||
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
|
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
|
||||||
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
|
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
|
||||||
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
|
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
|
||||||
|
|||||||
+1030
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
# Cloudflare DNS 指向实现计划
|
||||||
|
|
||||||
|
> 状态:代码实施完成(2026-08-04;范围内自动化验证完成;全量前端仅保留任务开始前已存在的 Zone 文案断言失败)
|
||||||
|
|
||||||
|
> **执行方式**:使用 `superpowers:executing-plans` 在当前会话按任务逐项实施;每项遵循测试先行(RED → GREEN → REFACTOR)。
|
||||||
|
|
||||||
|
## 1. 目标与背景 (Goal & Context)
|
||||||
|
|
||||||
|
* **需求背景**:落实提交 `21fb303e` 中的 Cloudflare DNS 指向设计,让管理员以 ZoneDomain 为粒度,将明确 FQDN 的单条 A 记录幂等指向 OpenFlare 边缘节点 IPv4,避免在 Cloudflare 控制台重复手工操作。
|
||||||
|
* **开发范围 (Scope)**:
|
||||||
|
* 全局一份 Cloudflare 连接,支持从现有 Cloudflare DNS 账号导入或独立录入 API Token。
|
||||||
|
* 指向分组、成员、主/备/生效节点、成员橙云、同步状态与错误信息。
|
||||||
|
* Cloudflare Zone/DNS Record HTTP 客户端与单成员幂等 reconcile。
|
||||||
|
* 手动同步、成员/分组变更同步、节点 IP 变化 best-effort 入队。
|
||||||
|
* 管理 API、Swagger、前端总览/设置/分组列表/分组详情和侧边栏入口。
|
||||||
|
* **Out of Scope**:自动故障切换/回切、AAAA、多 A 负载、CNAME、定时全量对账、非 Cloudflare DNS 厂商、多 Cloudflare 账号并行。
|
||||||
|
|
||||||
|
## 2. 设计与决策 (Design & Decisions)
|
||||||
|
|
||||||
|
### 核心对象/数据模型
|
||||||
|
|
||||||
|
* `of_cf_connections`:全局连接;`source` 为 `dns_account` 或 `standalone`,独立 Token 使用现有 `enc:v1:` 密文格式,响应永不暴露凭据。
|
||||||
|
* `of_cf_pointing_groups`:分组名、主节点、可选备用节点、生效节点、默认橙云和启用状态;一期 `active_node_id = primary_node_id`。
|
||||||
|
* `of_cf_pointing_members`:分组、全局唯一 `zone_domain_id`、成员橙云、Cloudflare Zone/Record ID 缓存、期望 IP 与同步状态。
|
||||||
|
* PostgreSQL/SQLite 使用同版本 Goose DDL,不建立物理外键,关系字段显式索引,数据库默认值与 Go 零值一致。
|
||||||
|
|
||||||
|
### API 与鉴权设计
|
||||||
|
|
||||||
|
* 前缀 `/api/v1/d/cloudflare`,统一使用 `apiutil.AdminMiddlewares()`。
|
||||||
|
* 连接:`GET/PUT /connection`、`POST /connection/verify`、`POST /connection/clear`。
|
||||||
|
* 总览:`GET /overview`。
|
||||||
|
* 分组:`GET/POST /groups`、`GET /groups/:id`、`POST /groups/:id/update|delete|sync`。
|
||||||
|
* 成员:`GET/POST /groups/:id/members`、`POST /groups/:id/members/:memberId/update|remove|sync`。
|
||||||
|
* 可用域名:`GET /domains/available`。
|
||||||
|
* 成功统一 HTTP 200 + `response.OK`;失败通过 `response.Abort*` 交由全局中间件写出。
|
||||||
|
|
||||||
|
### 数据流与架构图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
UI[Cloudflare 管理页面] --> API[/api/v1/d/cloudflare]
|
||||||
|
API --> Logic[cloudflare 业务逻辑]
|
||||||
|
Logic --> Repo[repository]
|
||||||
|
Repo --> DB[(PG / SQLite)]
|
||||||
|
Logic --> Queue[Asynq]
|
||||||
|
Queue --> Worker[Cloudflare 同步 Handler]
|
||||||
|
Worker --> Reconcile[成员 Reconcile]
|
||||||
|
Reconcile --> CF[Cloudflare Zone / DNS API]
|
||||||
|
Reconcile --> Repo
|
||||||
|
Node[节点手动更新或心跳] --> Queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计决策权衡
|
||||||
|
|
||||||
|
* 使用标准库 `net/http` 自建最小 Cloudflare 客户端,避免引入覆盖面过大的 SDK;接口只暴露 verify、Zone 查找和 A 记录 CRUD,便于 mock。
|
||||||
|
* 单成员同步采用进程内 keyed mutex 防止同一 Worker 进程并发双写;数据库状态在调用远端前标记 `syncing`,完成后写回 `ok/error`。
|
||||||
|
* 整组、按节点和常规变更统一通过 Asynq 投递单成员任务;请求路径只做校验和状态变更,避免管理 API 被远端网络延迟阻塞。连接测试是唯一同步调用 Cloudflare 的管理操作。
|
||||||
|
* 删除成员/分组默认先删除已缓存或唯一同名 A 记录,再删除本地记录;远端删除失败时保留本地成员并返回可读错误,避免失去重试依据。
|
||||||
|
* 将 TLS 包内的敏感字段加解密提取为 `internal/apps/openflare/credential`,保持既有密文兼容并让 Cloudflare 复用,避免业务包重复实现凭据存储。
|
||||||
|
|
||||||
|
## 3. 具体修改文件清单 (Proposed Changes)
|
||||||
|
|
||||||
|
### Task 1:凭据共享与数据库模型
|
||||||
|
|
||||||
|
**测试先行**:验证旧明文、`enc:v1:` 密文、无 SessionSecret 和缺少密钥时的兼容行为;验证迁移能创建三张表和唯一索引。
|
||||||
|
|
||||||
|
* **[NEW]** `internal/apps/openflare/credential/sensitive.go`、`sensitive_test.go`:提供 `Seal` / `Open`。
|
||||||
|
* **[MODIFY]** `internal/apps/openflare/tls/sensitive.go` 及调用点:委派到共享凭据包,保留 TLS 对外行为。
|
||||||
|
* **[NEW]** `internal/model/openflare_cloudflare.go`:连接、分组、成员实体和同步状态常量。
|
||||||
|
* **[NEW]** `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608040001_create_cloudflare_pointing.sql`。
|
||||||
|
* **[MODIFY]** `internal/infra/persistence/migrator/migrator_test.go`:检查表、索引和唯一约束。
|
||||||
|
|
||||||
|
### Task 2:Repository 与 Cloudflare HTTP 客户端
|
||||||
|
|
||||||
|
**测试先行**:覆盖连接 upsert/clear、分组/成员 CRUD、可用域名、按 active node 查询成员;使用 `httptest.Server` 覆盖 Token verify、Zone 查找、A 记录 list/create/update/delete、API 错误和 429 `Retry-After`。
|
||||||
|
|
||||||
|
* **[NEW]** `internal/repository/openflare_cloudflare.go`、`openflare_cloudflare_test.go`:唯一持久化入口和必要事务。
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/client.go`、`client_test.go`:最小 Cloudflare API 接口与 HTTP 实现。
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/types.go`、`errs.go`:输入/输出 DTO、内部状态和用户可见错误常量。
|
||||||
|
|
||||||
|
### Task 3:Reconcile、业务逻辑与异步任务
|
||||||
|
|
||||||
|
**测试先行**:覆盖 Token 来源解析、0/1/多条同名 A、缓存 Record ID 失效回退、非法 IPv4、成员默认橙云初始化、成员更新、移出删除远端、分组变更入队、按节点 IP 变更入队和同成员串行执行。
|
||||||
|
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/reconcile.go`、`reconcile_test.go`:单成员期望状态计算与幂等同步。
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/logics.go`、`logics_test.go`:连接、总览、分组、成员业务编排。
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/tasks.go`、`tasks_test.go`:`cloudflare:sync_member`、`sync_group`、`sync_by_node` Handler、Meta、payload 校验与投递函数。
|
||||||
|
* **[MODIFY]** `internal/infra/task/handlers/register.go`:集中注册 Cloudflare 任务。
|
||||||
|
* **[MODIFY]** `internal/apps/openflare/node/logics.go`、`internal/apps/openflare/agent/logics.go`:节点 IP 真正变化后 best-effort 投递,不阻断原流程;失败记录日志。
|
||||||
|
|
||||||
|
### Task 4:管理 API 与 Swagger
|
||||||
|
|
||||||
|
**测试先行**:使用 Gin 测试覆盖管理员路由、参数绑定、404/409/未就绪映射、Token 响应脱敏和主要成功响应。
|
||||||
|
|
||||||
|
* **[NEW]** `internal/apps/openflare/cloudflare/routers.go`、`routers_test.go`:Handlers 与 Swagger 注释。
|
||||||
|
* **[NEW]** `internal/router/v1/openflare/register_cloudflare.go`。
|
||||||
|
* **[MODIFY]** `internal/router/v1/openflare/v1.go`:注册 Cloudflare 路由委派。
|
||||||
|
* **[GENERATED]** `docs/docs.go`、`docs/swagger.json`、`docs/swagger.yaml`:运行 `make swagger` 生成。
|
||||||
|
|
||||||
|
### Task 5:前端服务、导航与页面
|
||||||
|
|
||||||
|
**测试先行**:覆盖 service 路径/载荷、未就绪引导、连接配置不回显 Token、分组创建、成员添加/橙云更新、同步与删除确认。
|
||||||
|
|
||||||
|
* **[NEW]** `frontend/lib/services/openflare/cloudflare.service.ts`。
|
||||||
|
* **[MODIFY]** `frontend/lib/services/openflare/types.ts`、`index.ts`:类型、导出和 `openflareServices.cloudflare`。
|
||||||
|
* **[MODIFY]** `frontend/lib/navigation/openflare-nav.ts`:网站管理组增加 Cloudflare 入口与子路由高亮。
|
||||||
|
* **[NEW]** `frontend/app/(main)/cloudflare/page.tsx`:总览和就绪门禁。
|
||||||
|
* **[NEW]** `frontend/app/(main)/cloudflare/settings/page.tsx`:DNS 账号导入/独立 Token 配置与连接测试。
|
||||||
|
* **[NEW]** `frontend/app/(main)/cloudflare/groups/page.tsx`:分组列表、创建、同步、删除确认。
|
||||||
|
* **[NEW]** `frontend/app/(main)/cloudflare/groups/[id]/page.tsx` 及邻近 `components/`:分组配置、成员列表、添加/更新/同步/移除。
|
||||||
|
* **[NEW]** `frontend/tests/cloudflare/*.test.ts(x)`:服务和关键交互测试。
|
||||||
|
|
||||||
|
### Task 6:设计边界、变更日志与收尾
|
||||||
|
|
||||||
|
* **[MODIFY]** `docs/design/architecture.md`、`docs/design/index.md`:补充 Cloudflare 可选控制面能力与阅读入口。
|
||||||
|
* **[MODIFY]** `docs/changelog/index.md`:在 `[Unreleased]` 添加中文用户可见条目。
|
||||||
|
* **[MODIFY]** `docs/plan/index.md`:登记本计划;完成时保留计划并标记状态。
|
||||||
|
|
||||||
|
## 4. 验证计划 (Verification Plan)
|
||||||
|
|
||||||
|
### 自动化单元测试
|
||||||
|
|
||||||
|
* `go test ./internal/apps/openflare/credential ./internal/apps/openflare/cloudflare ./internal/repository ./internal/infra/persistence/migrator ./internal/apps/openflare/node ./internal/apps/openflare/agent`
|
||||||
|
* `pnpm --dir frontend test -- --run frontend/tests/cloudflare`
|
||||||
|
* `go test ./...`
|
||||||
|
|
||||||
|
### 生成与质量门禁
|
||||||
|
|
||||||
|
* `make license`
|
||||||
|
* `make swagger`
|
||||||
|
* `make format`
|
||||||
|
* `make code-check`
|
||||||
|
|
||||||
|
### 手动验收路径
|
||||||
|
|
||||||
|
1. 在 `/cloudflare/settings` 选择现有 Cloudflare DNS 账号或录入独立 Token,测试连接成功。
|
||||||
|
2. 在 `/cloudflare/groups` 新建分组,选择具有合法 IPv4 的 edge 节点。
|
||||||
|
3. 在详情页加入 ZoneDomain,观察状态从 `pending/syncing` 变为 `ok`,Cloudflare 上出现单条 A 记录。
|
||||||
|
4. 修改成员橙云并同步,确认远端 `proxied` 与期望一致。
|
||||||
|
5. 构造同名多 A,确认同步失败并提示先在 Cloudflare 清理。
|
||||||
|
6. 移出成员,确认默认删除本模块管理的远端 A;修改节点 IP 后确认相关成员重新入队。
|
||||||
|
|
||||||
|
## 5. 实施结果与验证记录
|
||||||
|
|
||||||
|
* 已完成共享凭据加密、双数据库迁移、repository、Cloudflare HTTP 客户端、成员 reconcile、三类异步任务、管理 API、节点 IP 变化联动、前端服务与四级管理页面。
|
||||||
|
* 删除远端记录时,缓存 Record ID 失效会回退到唯一同名 A;停用分组内修改成员只标记 `pending`,不投递必然失败的同步任务。
|
||||||
|
* 已运行 `make license`、`make swagger`、`make format`;Swagger 已生成 Cloudflare 管理接口。
|
||||||
|
* `go test ./...` 通过。
|
||||||
|
* `make code-check` 通过,包含架构守卫、golangci-lint、TypeScript 与 ESLint。
|
||||||
|
* `pnpm exec vitest run tests/cloudflare` 通过(2 个测试文件、2 个测试)。
|
||||||
|
* 前端全量 Vitest 为 20/21 个测试文件、106/107 个测试通过;唯一失败为既存 `tests/zone/zone-page.test.tsx` 仍断言页面展示“唯一访问者”,与本功能无关且在本任务基线中已存在。
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
## 已完成的计划
|
## 已完成的计划
|
||||||
|
|
||||||
|
* [Cloudflare DNS 指向](./20260804-cloudflare-pointing.md):已完成连接配置、分组与成员管理、单 A 记录幂等同步、节点 IP 变化联动、异步任务和管理页面。
|
||||||
|
|
||||||
* [model / repository 分层治理](./20260724-model-repository-layering.md):model 无 IO;repository 唯一持久化;已完成 OpenFlare/平台 CRUD 迁入 repository。
|
* [model / repository 分层治理](./20260724-model-repository-layering.md):model 无 IO;repository 唯一持久化;已完成 OpenFlare/平台 CRUD 迁入 repository。
|
||||||
|
|
||||||
* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。
|
* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。
|
||||||
|
|||||||
+1030
File diff suppressed because it is too large
Load Diff
@@ -295,6 +295,148 @@ definitions:
|
|||||||
- solutions
|
- solutions
|
||||||
- token
|
- token
|
||||||
type: object
|
type: object
|
||||||
|
cloudflare.AvailableDomain:
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
zone_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.ConnectionInput:
|
||||||
|
properties:
|
||||||
|
api_token:
|
||||||
|
type: string
|
||||||
|
dns_account_id:
|
||||||
|
type: integer
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
cloudflare.ConnectionView:
|
||||||
|
properties:
|
||||||
|
configured:
|
||||||
|
type: boolean
|
||||||
|
dns_account_id:
|
||||||
|
type: integer
|
||||||
|
ready:
|
||||||
|
type: boolean
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
verified_at:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
cloudflare.GroupDetail:
|
||||||
|
properties:
|
||||||
|
group:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupItem'
|
||||||
|
members:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberItem'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
cloudflare.GroupInput:
|
||||||
|
properties:
|
||||||
|
backup_node_id:
|
||||||
|
type: integer
|
||||||
|
default_proxied:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
primary_node_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.GroupItem:
|
||||||
|
properties:
|
||||||
|
active_node:
|
||||||
|
$ref: '#/definitions/cloudflare.NodeOption'
|
||||||
|
backup_node:
|
||||||
|
$ref: '#/definitions/cloudflare.NodeOption'
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
default_proxied:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
member_count:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
primary_node:
|
||||||
|
$ref: '#/definitions/cloudflare.NodeOption'
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
cloudflare.MemberCreateInput:
|
||||||
|
properties:
|
||||||
|
proxied:
|
||||||
|
type: boolean
|
||||||
|
zone_domain_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.MemberItem:
|
||||||
|
properties:
|
||||||
|
desired_ip:
|
||||||
|
type: string
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
group_id:
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
last_error:
|
||||||
|
type: string
|
||||||
|
proxied:
|
||||||
|
type: boolean
|
||||||
|
sync_status:
|
||||||
|
type: string
|
||||||
|
synced_at:
|
||||||
|
type: string
|
||||||
|
zone_domain_id:
|
||||||
|
type: integer
|
||||||
|
zone_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.MemberUpdateInput:
|
||||||
|
properties:
|
||||||
|
proxied:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
cloudflare.NodeOption:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
ip:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
cloudflare.Overview:
|
||||||
|
properties:
|
||||||
|
connection:
|
||||||
|
$ref: '#/definitions/cloudflare.ConnectionView'
|
||||||
|
error_count:
|
||||||
|
type: integer
|
||||||
|
group_count:
|
||||||
|
type: integer
|
||||||
|
member_count:
|
||||||
|
type: integer
|
||||||
|
ok_count:
|
||||||
|
type: integer
|
||||||
|
pending_count:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.SyncReceipt:
|
||||||
|
properties:
|
||||||
|
task_id:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
config_version.CleanupInput:
|
config_version.CleanupInput:
|
||||||
properties:
|
properties:
|
||||||
keep_count:
|
keep_count:
|
||||||
@@ -7693,6 +7835,472 @@ paths:
|
|||||||
summary: 清理配置下发日志
|
summary: 清理配置下发日志
|
||||||
tags:
|
tags:
|
||||||
- openflare-apply-log
|
- openflare-apply-log
|
||||||
|
/api/v1/d/cloudflare/connection:
|
||||||
|
get:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.ConnectionView'
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取 Cloudflare 连接
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 连接参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.ConnectionInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.ConnectionView'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 保存 Cloudflare 连接
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/connection/clear:
|
||||||
|
post:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 清除 Cloudflare 连接
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/connection/verify:
|
||||||
|
post:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.ConnectionView'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 测试 Cloudflare 连接
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/domains/available:
|
||||||
|
get:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/cloudflare.AvailableDomain'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取可加入 Cloudflare 指向的域名
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups:
|
||||||
|
get:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupItem'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取 Cloudflare 指向分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 分组参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupItem'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 创建 Cloudflare 指向分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupDetail'
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取 Cloudflare 指向分组详情
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/delete:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 删除 Cloudflare 指向分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberItem'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取 Cloudflare 指向分组成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberCreateInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberItem'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"409":
|
||||||
|
description: Conflict
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 添加 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员 ID
|
||||||
|
in: path
|
||||||
|
name: memberId
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 移出 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员 ID
|
||||||
|
in: path
|
||||||
|
name: memberId
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.SyncReceipt'
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 同步 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员 ID
|
||||||
|
in: path
|
||||||
|
name: memberId
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberUpdateInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberItem'
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 更新 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/sync:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.SyncReceipt'
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 同步 Cloudflare 指向分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/update:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 分组参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.GroupItem'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 更新 Cloudflare 指向分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/overview:
|
||||||
|
get:
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.Overview'
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取 Cloudflare 指向总览
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
/api/v1/d/config-versions:
|
/api/v1/d/config-versions:
|
||||||
get:
|
get:
|
||||||
description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限
|
description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import type {
|
||||||
|
CloudflareGroup,
|
||||||
|
CloudflareGroupPayload,
|
||||||
|
NodeItem,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export function GroupDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
group,
|
||||||
|
nodes,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
group?: CloudflareGroup | null;
|
||||||
|
nodes: NodeItem[];
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: (payload: CloudflareGroupPayload) => void;
|
||||||
|
}) {
|
||||||
|
const edgeNodes = useMemo(
|
||||||
|
() => nodes.filter((node) => node.node_type === 'edge_node'),
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [primaryNodeID, setPrimaryNodeID] = useState('');
|
||||||
|
const [backupNodeID, setBackupNodeID] = useState('none');
|
||||||
|
const [defaultProxied, setDefaultProxied] = useState(true);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setName(group?.name ?? '');
|
||||||
|
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
|
||||||
|
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
|
||||||
|
setDefaultProxied(group?.default_proxied ?? true);
|
||||||
|
setEnabled(group?.enabled ?? true);
|
||||||
|
}, [group, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id='cf-group-name'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel>
|
||||||
|
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||||
|
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||||
|
<SelectValue placeholder='选择带 IPv4 的边缘节点' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{edgeNodes.map((node) => (
|
||||||
|
<SelectItem
|
||||||
|
key={node.id}
|
||||||
|
value={String(node.id)}
|
||||||
|
disabled={!node.ip}
|
||||||
|
>
|
||||||
|
{node.name} · {node.ip || '未配置 IP'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel>
|
||||||
|
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||||
|
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value='none'>不设置</SelectItem>
|
||||||
|
{edgeNodes
|
||||||
|
.filter((node) => String(node.id) !== primaryNodeID)
|
||||||
|
.map((node) => (
|
||||||
|
<SelectItem key={node.id} value={String(node.id)}>
|
||||||
|
{node.name} · {node.ip || '未配置 IP'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field orientation='horizontal'>
|
||||||
|
<FieldLabel htmlFor='cf-default-proxied'>
|
||||||
|
新成员默认开启橙云
|
||||||
|
</FieldLabel>
|
||||||
|
<Switch
|
||||||
|
id='cf-default-proxied'
|
||||||
|
checked={defaultProxied}
|
||||||
|
onCheckedChange={setDefaultProxied}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field orientation='horizontal'>
|
||||||
|
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel>
|
||||||
|
<Switch
|
||||||
|
id='cf-enabled'
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pending || !name.trim() || !primaryNodeID}
|
||||||
|
onClick={() =>
|
||||||
|
onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
primary_node_id: Number(primaryNodeID),
|
||||||
|
backup_node_id:
|
||||||
|
backupNodeID === 'none' ? null : Number(backupNodeID),
|
||||||
|
default_proxied: defaultProxied,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export function MemberAddDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
domains,
|
||||||
|
defaultProxied,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
domains: CloudflareAvailableDomain[];
|
||||||
|
defaultProxied: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: (zoneDomainID: number, proxied: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [domainID, setDomainID] = useState('');
|
||||||
|
const [proxied, setProxied] = useState(defaultProxied);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDomainID('');
|
||||||
|
setProxied(defaultProxied);
|
||||||
|
}, [defaultProxied, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加域名成员</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-domain'>Zone 域名</FieldLabel>
|
||||||
|
<Select value={domainID} onValueChange={setDomainID}>
|
||||||
|
<SelectTrigger id='cf-domain' className='w-full'>
|
||||||
|
<SelectValue placeholder='选择可用域名' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||||
|
{domain.domain}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field orientation='horizontal'>
|
||||||
|
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
|
||||||
|
<Switch
|
||||||
|
id='cf-member-proxied'
|
||||||
|
checked={proxied}
|
||||||
|
onCheckedChange={setProxied}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pending || !domainID}
|
||||||
|
onClick={() => onSubmit(Number(domainID), proxied)}
|
||||||
|
>
|
||||||
|
添加并同步
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Cloud, Plus, RefreshCw, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import {
|
||||||
|
CloudflareService,
|
||||||
|
cloudflareQueryKey,
|
||||||
|
NodeService,
|
||||||
|
type CloudflareGroupPayload,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { getErrorMessage } from '../../../websites/components/website-utils';
|
||||||
|
import { GroupDialog } from '../../components/group-dialog';
|
||||||
|
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||||
|
|
||||||
|
export default function CloudflareGroupDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const groupID = Number(params.id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
||||||
|
queryFn: () => CloudflareService.getGroup(groupID),
|
||||||
|
enabled: Number.isInteger(groupID) && groupID > 0,
|
||||||
|
});
|
||||||
|
const domainsQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'domains', 'available'],
|
||||||
|
queryFn: () => CloudflareService.listAvailableDomains(),
|
||||||
|
});
|
||||||
|
const nodesQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'nodes'],
|
||||||
|
queryFn: () => NodeService.listNodes(),
|
||||||
|
});
|
||||||
|
const invalidate = async () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
|
||||||
|
const updateGroupMutation = useMutation({
|
||||||
|
mutationFn: (payload: CloudflareGroupPayload) =>
|
||||||
|
CloudflareService.updateGroup(groupID, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('分组配置已更新');
|
||||||
|
setEditOpen(false);
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
domainID,
|
||||||
|
proxied,
|
||||||
|
}: {
|
||||||
|
domainID: number;
|
||||||
|
proxied: boolean;
|
||||||
|
}) =>
|
||||||
|
CloudflareService.createMember(groupID, {
|
||||||
|
zone_domain_id: domainID,
|
||||||
|
proxied,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('域名已加入并排队同步');
|
||||||
|
setAddOpen(false);
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const proxiedMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
memberID,
|
||||||
|
proxied,
|
||||||
|
}: {
|
||||||
|
memberID: number;
|
||||||
|
proxied: boolean;
|
||||||
|
}) => CloudflareService.updateMember(groupID, memberID, proxied),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('橙云设置已更新并排队同步');
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: (memberID: number) =>
|
||||||
|
CloudflareService.syncMember(groupID, memberID),
|
||||||
|
onSuccess: () => toast.success('成员同步任务已入队'),
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (memberID: number) =>
|
||||||
|
CloudflareService.removeMember(groupID, memberID),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('成员及远端 A 记录已删除');
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailQuery.isLoading)
|
||||||
|
return (
|
||||||
|
<div className='w-full py-6 px-1'>
|
||||||
|
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (detailQuery.isError || !detailQuery.data)
|
||||||
|
return (
|
||||||
|
<div className='w-full py-6 px-1'>
|
||||||
|
<ErrorInline
|
||||||
|
message={getErrorMessage(detailQuery.error)}
|
||||||
|
onRetry={() => void detailQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const { group, members } = detailQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Cloud className='size-5 text-primary' />
|
||||||
|
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||||
|
{group.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href='/cloudflare/groups'>返回分组</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant='outline' size='sm' onClick={() => setEditOpen(true)}>
|
||||||
|
<Settings data-icon='inline-start' />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus data-icon='inline-start' />
|
||||||
|
添加域名
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>当前指向</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
生效节点 {group.active_node.name} · {group.active_node.ip}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-wrap gap-2'>
|
||||||
|
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||||
|
{group.enabled ? '同步已启用' : '同步已停用'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
备用节点 {group.backup_node?.name ?? '未设置'}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Cloud />
|
||||||
|
<AlertTitle>远端记录所有权</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
移出成员会立即删除本模块缓存或唯一同名 A 记录;存在多条同名 A
|
||||||
|
时会拒绝操作并要求先手动清理。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>域名成员</CardTitle>
|
||||||
|
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className='py-8 text-center text-sm text-muted-foreground'>
|
||||||
|
暂无域名成员。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>域名</TableHead>
|
||||||
|
<TableHead>期望 IP</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>橙云</TableHead>
|
||||||
|
<TableHead className='text-right'>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<TableRow key={member.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className='font-medium'>{member.domain}</div>
|
||||||
|
{member.last_error ? (
|
||||||
|
<p className='max-w-md text-xs text-destructive'>
|
||||||
|
{member.last_error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{member.desired_ip || '待同步'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
member.sync_status === 'ok'
|
||||||
|
? 'default'
|
||||||
|
: member.sync_status === 'error'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.sync_status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={member.proxied}
|
||||||
|
onCheckedChange={(proxied) =>
|
||||||
|
proxiedMutation.mutate({
|
||||||
|
memberID: member.id,
|
||||||
|
proxied,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => syncMutation.mutate(member.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw data-icon='inline-start' />
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => removeMutation.mutate(member.id)}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
移出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<GroupDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
group={group}
|
||||||
|
nodes={nodesQuery.data ?? []}
|
||||||
|
pending={updateGroupMutation.isPending}
|
||||||
|
onSubmit={(payload) => updateGroupMutation.mutate(payload)}
|
||||||
|
/>
|
||||||
|
<MemberAddDialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
domains={domainsQuery.data ?? []}
|
||||||
|
defaultProxied={group.default_proxied}
|
||||||
|
pending={addMutation.isPending}
|
||||||
|
onSubmit={(domainID, proxied) =>
|
||||||
|
addMutation.mutate({ domainID, proxied })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Cloud, Plus, RefreshCw, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import {
|
||||||
|
CloudflareService,
|
||||||
|
cloudflareQueryKey,
|
||||||
|
NodeService,
|
||||||
|
type CloudflareGroup,
|
||||||
|
type CloudflareGroupPayload,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { getErrorMessage } from '../../websites/components/website-utils';
|
||||||
|
import { GroupDialog } from '../components/group-dialog';
|
||||||
|
|
||||||
|
export default function CloudflareGroupsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'groups'],
|
||||||
|
queryFn: () => CloudflareService.listGroups(),
|
||||||
|
});
|
||||||
|
const nodesQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'nodes'],
|
||||||
|
queryFn: () => NodeService.listNodes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = async () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (payload: CloudflareGroupPayload) =>
|
||||||
|
CloudflareService.createGroup(payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('指向分组已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => CloudflareService.syncGroup(id),
|
||||||
|
onSuccess: () => toast.success('分组同步任务已入队'),
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => CloudflareService.deleteGroup(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('指向分组及远端记录已删除');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Cloud className='size-5 text-primary' />
|
||||||
|
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||||
|
Cloudflare 指向分组
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href='/cloudflare'>返回总览</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus data-icon='inline-start' />
|
||||||
|
新增分组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupsQuery.isLoading ? (
|
||||||
|
<LoadingStateWithBorder icon={Cloud} description='加载指向分组中...' />
|
||||||
|
) : groupsQuery.isError ? (
|
||||||
|
<ErrorInline
|
||||||
|
message={getErrorMessage(groupsQuery.error)}
|
||||||
|
onRetry={() => void groupsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : (groupsQuery.data ?? []).length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='py-12 text-center text-sm text-muted-foreground'>
|
||||||
|
暂无指向分组。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className='grid gap-4 lg:grid-cols-2'>
|
||||||
|
{(groupsQuery.data ?? []).map((group) => (
|
||||||
|
<Card key={group.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{group.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{group.active_node.name} · {group.active_node.ip}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||||
|
{group.enabled ? '已启用' : '已停用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-col gap-4'>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
成员 {group.member_count} 个 · 新成员默认
|
||||||
|
{group.default_proxied ? '开启' : '关闭'}橙云
|
||||||
|
</p>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Button asChild size='sm'>
|
||||||
|
<Link href={`/cloudflare/groups/${group.id}`}>
|
||||||
|
<Settings data-icon='inline-start' />
|
||||||
|
管理
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => syncMutation.mutate(group.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw data-icon='inline-start' />
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setDeleteTarget(group)}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GroupDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
nodes={nodesQuery.data ?? []}
|
||||||
|
pending={createMutation.isPending}
|
||||||
|
onSubmit={(payload) => createMutation.mutate(payload)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除指向分组</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A
|
||||||
|
记录。此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() =>
|
||||||
|
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, Cloud, FolderCog, Settings } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import {
|
||||||
|
CloudflareService,
|
||||||
|
cloudflareQueryKey,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { getErrorMessage } from '../websites/components/website-utils';
|
||||||
|
|
||||||
|
export default function CloudflarePage() {
|
||||||
|
const overviewQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'overview'],
|
||||||
|
queryFn: () => CloudflareService.getOverview(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = overviewQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Cloud className='size-5 text-primary' />
|
||||||
|
<h1 className='text-2xl font-semibold tracking-tight'>Cloudflare</h1>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href='/cloudflare/settings'>
|
||||||
|
<Settings data-icon='inline-start' />
|
||||||
|
连接设置
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size='sm'>
|
||||||
|
<Link href='/cloudflare/groups'>
|
||||||
|
<FolderCog data-icon='inline-start' />
|
||||||
|
指向分组
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overviewQuery.isLoading ? (
|
||||||
|
<LoadingStateWithBorder
|
||||||
|
icon={Cloud}
|
||||||
|
description='加载 Cloudflare 总览中...'
|
||||||
|
/>
|
||||||
|
) : overviewQuery.isError ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='pt-6'>
|
||||||
|
<ErrorInline
|
||||||
|
message={getErrorMessage(overviewQuery.error)}
|
||||||
|
onRetry={() => void overviewQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : !overview?.connection.ready ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>Cloudflare 连接尚未就绪</AlertTitle>
|
||||||
|
<AlertDescription className='flex flex-col items-start gap-3'>
|
||||||
|
<span>
|
||||||
|
请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
|
||||||
|
并完成连接测试。
|
||||||
|
</span>
|
||||||
|
<Button asChild size='sm'>
|
||||||
|
<Link href='/cloudflare/settings'>配置连接</Link>
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
|
||||||
|
{[
|
||||||
|
['指向分组', overview.group_count],
|
||||||
|
['域名成员', overview.member_count],
|
||||||
|
['同步正常', overview.ok_count],
|
||||||
|
['等待同步', overview.pending_count],
|
||||||
|
['同步错误', overview.error_count],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Card key={label}>
|
||||||
|
<CardHeader className='pb-2'>
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className='text-2xl'>{value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>同步边界</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
OpenFlare 数据库是本模块的期望状态来源。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-col gap-3 text-sm text-muted-foreground'>
|
||||||
|
<p>
|
||||||
|
同步会覆盖本模块接管的同名 A 记录;如果 Cloudflare 中存在多条同名
|
||||||
|
A,需先手动清理。
|
||||||
|
</p>
|
||||||
|
<p>成员移出或分组删除时,默认同时删除本模块管理的远端 A 记录。</p>
|
||||||
|
<div>
|
||||||
|
<Badge variant='secondary'>一期限制</Badge>
|
||||||
|
<span className='ml-2'>
|
||||||
|
一期不提供自动故障切换,备用节点仅保存配置。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import {
|
||||||
|
CloudflareService,
|
||||||
|
cloudflareQueryKey,
|
||||||
|
DnsAccountService,
|
||||||
|
type CloudflareConnectionSource,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { getErrorMessage } from '../../websites/components/website-utils';
|
||||||
|
|
||||||
|
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
|
||||||
|
|
||||||
|
export default function CloudflareSettingsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [source, setSource] =
|
||||||
|
useState<CloudflareConnectionSource>('dns_account');
|
||||||
|
const [dnsAccountID, setDNSAccountID] = useState('');
|
||||||
|
const [apiToken, setAPIToken] = useState('');
|
||||||
|
|
||||||
|
const connectionQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'connection'],
|
||||||
|
queryFn: () => CloudflareService.getConnection(),
|
||||||
|
});
|
||||||
|
const accountsQuery = useQuery({
|
||||||
|
queryKey: dnsAccountsQueryKey,
|
||||||
|
queryFn: () => DnsAccountService.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloudflareAccounts = useMemo(
|
||||||
|
() =>
|
||||||
|
(accountsQuery.data ?? []).filter(
|
||||||
|
(account) => account.type === 'cloudflare',
|
||||||
|
),
|
||||||
|
[accountsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connection = connectionQuery.data;
|
||||||
|
if (!connection?.configured) return;
|
||||||
|
if (connection.source) setSource(connection.source);
|
||||||
|
if (connection.dns_account_id)
|
||||||
|
setDNSAccountID(String(connection.dns_account_id));
|
||||||
|
}, [connectionQuery.data]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
CloudflareService.saveConnection({
|
||||||
|
source,
|
||||||
|
dns_account_id: source === 'dns_account' ? Number(dnsAccountID) : 0,
|
||||||
|
api_token: source === 'standalone' ? apiToken : '',
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Cloudflare 连接配置已保存');
|
||||||
|
setAPIToken('');
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyMutation = useMutation({
|
||||||
|
mutationFn: () => CloudflareService.verifyConnection(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Cloudflare 连接验证成功');
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
mutationFn: () => CloudflareService.clearConnection(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Cloudflare 连接已清除');
|
||||||
|
setDNSAccountID('');
|
||||||
|
setAPIToken('');
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Cloud className='size-5 text-primary' />
|
||||||
|
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||||
|
Cloudflare 连接设置
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href='/cloudflare'>返回总览</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectionQuery.isError ? (
|
||||||
|
<ErrorInline
|
||||||
|
message={getErrorMessage(connectionQuery.error)}
|
||||||
|
onRetry={() => void connectionQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>凭据来源</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
|
||||||
|
或页面中回显。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-source'>连接来源</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={source}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSource(value as CloudflareConnectionSource)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='cf-source' className='w-full'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value='dns_account'>
|
||||||
|
导入现有 DNS 账号
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='standalone'>独立 API Token</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{source === 'dns_account' ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-dns-account'>
|
||||||
|
Cloudflare DNS 账号
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={dnsAccountID} onValueChange={setDNSAccountID}>
|
||||||
|
<SelectTrigger id='cf-dns-account' className='w-full'>
|
||||||
|
<SelectValue placeholder='选择 DNS 账号' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{cloudflareAccounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={String(account.id)}>
|
||||||
|
{account.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>
|
||||||
|
仅显示类型为 cloudflare 的 DNS 账号。可前往{' '}
|
||||||
|
<Link href='/dns-accounts'>DNS 账号</Link> 新增。
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-api-token'>API Token</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id='cf-api-token'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={apiToken}
|
||||||
|
onChange={(event) => setAPIToken(event.target.value)}
|
||||||
|
placeholder={
|
||||||
|
connectionQuery.data?.configured
|
||||||
|
? '留空不会回显现有 Token;保存将替换'
|
||||||
|
: '输入 Cloudflare API Token'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
saveMutation.isPending ||
|
||||||
|
(source === 'dns_account' ? !dnsAccountID : !apiToken.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save data-icon='inline-start' />
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => verifyMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
!connectionQuery.data?.configured || verifyMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldCheck data-icon='inline-start' />
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => clearMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
!connectionQuery.data?.configured || clearMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
清除连接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<ShieldCheck />
|
||||||
|
<AlertTitle>当前状态</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{connectionQuery.data?.ready
|
||||||
|
? 'Token 已验证,Cloudflare 指向同步可以执行。'
|
||||||
|
: '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
|
|||||||
{ title: '域名列表', url: '/websites', childUrls: ['/websites/detail'] },
|
{ title: '域名列表', url: '/websites', childUrls: ['/websites/detail'] },
|
||||||
{ title: 'TLS证书', url: '/certificates' },
|
{ title: 'TLS证书', url: '/certificates' },
|
||||||
{ title: 'DNS账号', url: '/dns-accounts' },
|
{ title: 'DNS账号', url: '/dns-accounts' },
|
||||||
|
{
|
||||||
|
title: 'Cloudflare',
|
||||||
|
url: '/cloudflare',
|
||||||
|
childUrls: ['/cloudflare/settings', '/cloudflare/groups'],
|
||||||
|
},
|
||||||
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
|
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -109,6 +114,7 @@ export const openflareWebsiteSubNav = [
|
|||||||
{ title: '网站列表', url: '/websites' },
|
{ title: '网站列表', url: '/websites' },
|
||||||
{ title: '证书', url: '/certificates' },
|
{ title: '证书', url: '/certificates' },
|
||||||
{ title: 'DNS 账号', url: '/dns-accounts' },
|
{ title: 'DNS 账号', url: '/dns-accounts' },
|
||||||
|
{ title: 'Cloudflare', url: '/cloudflare' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const nonConsoleRoutePrefixes = [
|
const nonConsoleRoutePrefixes = [
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { OpenFlareBaseService } from './base.service';
|
||||||
|
import type {
|
||||||
|
CloudflareAvailableDomain,
|
||||||
|
CloudflareConnection,
|
||||||
|
CloudflareConnectionPayload,
|
||||||
|
CloudflareGroup,
|
||||||
|
CloudflareGroupDetail,
|
||||||
|
CloudflareGroupPayload,
|
||||||
|
CloudflareMember,
|
||||||
|
CloudflareMemberCreatePayload,
|
||||||
|
CloudflareOverview,
|
||||||
|
CloudflareSyncReceipt,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const cloudflareQueryKey = ['openflare', 'cloudflare'] as const;
|
||||||
|
|
||||||
|
export class CloudflareService extends OpenFlareBaseService {
|
||||||
|
protected static override readonly basePath = '/api/v1/d/cloudflare';
|
||||||
|
|
||||||
|
static getConnection(): Promise<CloudflareConnection> {
|
||||||
|
return this.get<CloudflareConnection>('/connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
static saveConnection(
|
||||||
|
payload: CloudflareConnectionPayload,
|
||||||
|
): Promise<CloudflareConnection> {
|
||||||
|
return this.put<CloudflareConnection>('/connection', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
static verifyConnection(): Promise<CloudflareConnection> {
|
||||||
|
return this.post<CloudflareConnection>('/connection/verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
static clearConnection(): Promise<void> {
|
||||||
|
return this.post<void>('/connection/clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getOverview(): Promise<CloudflareOverview> {
|
||||||
|
return this.get<CloudflareOverview>('/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
static listGroups(): Promise<CloudflareGroup[]> {
|
||||||
|
return this.get<CloudflareGroup[]>('/groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getGroup(id: number): Promise<CloudflareGroupDetail> {
|
||||||
|
return this.get<CloudflareGroupDetail>(`/groups/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createGroup(
|
||||||
|
payload: CloudflareGroupPayload,
|
||||||
|
): Promise<CloudflareGroup> {
|
||||||
|
return this.post<CloudflareGroup>('/groups', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateGroup(
|
||||||
|
id: number,
|
||||||
|
payload: CloudflareGroupPayload,
|
||||||
|
): Promise<CloudflareGroup> {
|
||||||
|
return this.post<CloudflareGroup>(`/groups/${id}/update`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
static deleteGroup(id: number): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${id}/delete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static syncGroup(id: number): Promise<CloudflareSyncReceipt> {
|
||||||
|
return this.post<CloudflareSyncReceipt>(`/groups/${id}/sync`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static listAvailableDomains(): Promise<CloudflareAvailableDomain[]> {
|
||||||
|
return this.get<CloudflareAvailableDomain[]>('/domains/available');
|
||||||
|
}
|
||||||
|
|
||||||
|
static createMember(
|
||||||
|
groupId: number,
|
||||||
|
payload: CloudflareMemberCreatePayload,
|
||||||
|
): Promise<CloudflareMember> {
|
||||||
|
return this.post<CloudflareMember>(`/groups/${groupId}/members`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateMember(
|
||||||
|
groupId: number,
|
||||||
|
memberId: number,
|
||||||
|
proxied: boolean,
|
||||||
|
): Promise<CloudflareMember> {
|
||||||
|
return this.post<CloudflareMember>(
|
||||||
|
`/groups/${groupId}/members/${memberId}/update`,
|
||||||
|
{ proxied },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeMember(groupId: number, memberId: number): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${groupId}/members/${memberId}/remove`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static syncMember(
|
||||||
|
groupId: number,
|
||||||
|
memberId: number,
|
||||||
|
): Promise<CloudflareSyncReceipt> {
|
||||||
|
return this.post<CloudflareSyncReceipt>(
|
||||||
|
`/groups/${groupId}/members/${memberId}/sync`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ export { AccessLogService } from './access-log.service';
|
|||||||
export { OptionService } from './option.service';
|
export { OptionService } from './option.service';
|
||||||
export { UptimeKumaService } from './uptimekuma.service';
|
export { UptimeKumaService } from './uptimekuma.service';
|
||||||
export { StatusService } from './status.service';
|
export { StatusService } from './status.service';
|
||||||
|
export {
|
||||||
|
CloudflareService,
|
||||||
|
cloudflareQueryKey,
|
||||||
|
} from './cloudflare.service';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ApplyLogCleanupPayload,
|
ApplyLogCleanupPayload,
|
||||||
@@ -143,6 +147,19 @@ export type {
|
|||||||
TlsCertificateFileImportPayload,
|
TlsCertificateFileImportPayload,
|
||||||
TlsCertificateItem,
|
TlsCertificateItem,
|
||||||
TlsCertificateMutationPayload,
|
TlsCertificateMutationPayload,
|
||||||
|
CloudflareAvailableDomain,
|
||||||
|
CloudflareConnection,
|
||||||
|
CloudflareConnectionPayload,
|
||||||
|
CloudflareConnectionSource,
|
||||||
|
CloudflareGroup,
|
||||||
|
CloudflareGroupDetail,
|
||||||
|
CloudflareGroupPayload,
|
||||||
|
CloudflareMember,
|
||||||
|
CloudflareMemberCreatePayload,
|
||||||
|
CloudflareNodeOption,
|
||||||
|
CloudflareOverview,
|
||||||
|
CloudflareSyncReceipt,
|
||||||
|
CloudflareSyncStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { AccessLogService } from './access-log.service';
|
import { AccessLogService } from './access-log.service';
|
||||||
@@ -160,6 +177,7 @@ import { ProxyRouteService } from './proxy-route.service';
|
|||||||
import { TlsCertificateService } from './tls-certificate.service';
|
import { TlsCertificateService } from './tls-certificate.service';
|
||||||
import { WafService } from './waf.service';
|
import { WafService } from './waf.service';
|
||||||
import { ZoneDomainService, ZoneService } from './zone.service';
|
import { ZoneDomainService, ZoneService } from './zone.service';
|
||||||
|
import { CloudflareService } from './cloudflare.service';
|
||||||
|
|
||||||
export const openflareServices = {
|
export const openflareServices = {
|
||||||
node: NodeService,
|
node: NodeService,
|
||||||
@@ -178,4 +196,5 @@ export const openflareServices = {
|
|||||||
option: OptionService,
|
option: OptionService,
|
||||||
uptimeKuma: UptimeKumaService,
|
uptimeKuma: UptimeKumaService,
|
||||||
status: StatusService,
|
status: StatusService,
|
||||||
|
cloudflare: CloudflareService,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1347,6 +1347,93 @@ export interface ZoneStats {
|
|||||||
series: ZoneStatsPoint[];
|
series: ZoneStatsPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloudflareConnectionSource = 'dns_account' | 'standalone';
|
||||||
|
export type CloudflareSyncStatus = 'pending' | 'syncing' | 'ok' | 'error';
|
||||||
|
|
||||||
|
export interface CloudflareConnection {
|
||||||
|
configured: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
source: CloudflareConnectionSource | '';
|
||||||
|
dns_account_id: number | null;
|
||||||
|
status: string;
|
||||||
|
verified_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareConnectionPayload {
|
||||||
|
source: CloudflareConnectionSource;
|
||||||
|
dns_account_id: number;
|
||||||
|
api_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareNodeOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
primary_node: CloudflareNodeOption;
|
||||||
|
backup_node: CloudflareNodeOption | null;
|
||||||
|
active_node: CloudflareNodeOption;
|
||||||
|
default_proxied: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
member_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareGroupPayload {
|
||||||
|
name: string;
|
||||||
|
primary_node_id: number;
|
||||||
|
backup_node_id: number | null;
|
||||||
|
default_proxied: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareMember {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
zone_domain_id: number;
|
||||||
|
domain: string;
|
||||||
|
zone_id: number;
|
||||||
|
proxied: boolean;
|
||||||
|
desired_ip: string;
|
||||||
|
sync_status: CloudflareSyncStatus;
|
||||||
|
last_error: string;
|
||||||
|
synced_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareMemberCreatePayload {
|
||||||
|
zone_domain_id: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareGroupDetail {
|
||||||
|
group: CloudflareGroup;
|
||||||
|
members: CloudflareMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareAvailableDomain {
|
||||||
|
id: number;
|
||||||
|
zone_id: number;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareOverview {
|
||||||
|
connection: CloudflareConnection;
|
||||||
|
group_count: number;
|
||||||
|
member_count: number;
|
||||||
|
ok_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
error_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareSyncReceipt {
|
||||||
|
task_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TlsCertificateItem {
|
export interface TlsCertificateItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import CloudflarePage from '@/app/(main)/cloudflare/page';
|
||||||
|
import { CloudflareService } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
CloudflareService: { ...actual.CloudflareService, getOverview: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cloudflare overview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(CloudflareService.getOverview).mockResolvedValue({
|
||||||
|
connection: {
|
||||||
|
configured: false,
|
||||||
|
ready: false,
|
||||||
|
source: '',
|
||||||
|
dns_account_id: null,
|
||||||
|
status: '',
|
||||||
|
verified_at: null,
|
||||||
|
},
|
||||||
|
group_count: 0,
|
||||||
|
member_count: 0,
|
||||||
|
ok_count: 0,
|
||||||
|
pending_count: 0,
|
||||||
|
error_count: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the token readiness gate and phase-one limitation', async () => {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(CloudflarePage),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Cloudflare 连接尚未就绪')).toBeVisible();
|
||||||
|
expect(screen.getByText(/一期不提供自动故障切换/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import apiClient from '@/lib/services/core/api-client';
|
||||||
|
import { CloudflareService } from '@/lib/services/openflare/cloudflare.service';
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/core/api-client', () => ({
|
||||||
|
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
function response<T>(data: T) {
|
||||||
|
return {
|
||||||
|
data: { error_msg: '', data },
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: {} },
|
||||||
|
} as AxiosResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CloudflareService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(apiClient.get).mockReset();
|
||||||
|
vi.mocked(apiClient.post).mockReset();
|
||||||
|
vi.mocked(apiClient.put).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the Cloudflare management API paths', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue(response([]));
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(
|
||||||
|
response({ task_id: 'task-1' }),
|
||||||
|
);
|
||||||
|
vi.mocked(apiClient.put).mockResolvedValue(response({ configured: true }));
|
||||||
|
|
||||||
|
await CloudflareService.saveConnection({
|
||||||
|
source: 'standalone',
|
||||||
|
dns_account_id: 0,
|
||||||
|
api_token: 'secret',
|
||||||
|
});
|
||||||
|
await CloudflareService.listGroups();
|
||||||
|
await CloudflareService.syncMember(7, 9);
|
||||||
|
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/connection',
|
||||||
|
expect.objectContaining({ source: 'standalone' }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups',
|
||||||
|
expect.objectContaining({ params: undefined }),
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/9/sync',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
|
||||||
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
|
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterWithAccessToken registers an agent on a reserved node token.
|
// RegisterWithAccessToken registers an agent on a reserved node token.
|
||||||
@@ -118,6 +120,11 @@ func HeartbeatNode(ctx context.Context, authNode *model.OpenFlareNode, payload N
|
|||||||
if err := repository.UpdateOpenFlareNodeFields(ctx, authNode, fields...); err != nil {
|
if err := repository.UpdateOpenFlareNodeFields(ctx, authNode, fields...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if previous.IP != authNode.IP {
|
||||||
|
if _, dispatchErr := cf.DispatchNodeSync(ctx, authNode.ID, "cloudflare_agent_ip_update"); dispatchErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] enqueue heartbeat node sync failed: node_id=%d error=%v", authNode.ID, dispatchErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshAccessTokenCache(ctx, authNode)
|
RefreshAccessTokenCache(ctx, authNode)
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package cloudflare manages Cloudflare DNS pointing for OpenFlare domains.
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/httppool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAPIBaseURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
defaultHTTPTimeout = 20 * time.Second
|
||||||
|
maxRequestAttempts = 3
|
||||||
|
maxResponseBodyBytes = 1 << 20
|
||||||
|
defaultRetryDelay = 200 * time.Millisecond
|
||||||
|
maxRetryAfterSeconds = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Zone is a Cloudflare DNS zone.
|
||||||
|
type Zone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSRecord is a Cloudflare DNS record.
|
||||||
|
type DNSRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordInput is the desired Cloudflare DNS record payload.
|
||||||
|
type RecordInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client describes the Cloudflare operations used by pointing reconciliation.
|
||||||
|
type Client interface {
|
||||||
|
VerifyToken(context.Context) error
|
||||||
|
FindZone(context.Context, string) (*Zone, error)
|
||||||
|
GetRecord(context.Context, string, string) (*DNSRecord, error)
|
||||||
|
ListARecords(context.Context, string, string) ([]DNSRecord, error)
|
||||||
|
CreateARecord(context.Context, string, RecordInput) (*DNSRecord, error)
|
||||||
|
UpdateARecord(context.Context, string, string, RecordInput) (*DNSRecord, error)
|
||||||
|
DeleteRecord(context.Context, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPClient implements Client with Cloudflare's v4 HTTP API.
|
||||||
|
type HTTPClient struct {
|
||||||
|
token string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOption configures HTTPClient.
|
||||||
|
type ClientOption func(*HTTPClient)
|
||||||
|
|
||||||
|
// WithBaseURL overrides the Cloudflare API base URL.
|
||||||
|
func WithBaseURL(baseURL string) ClientOption {
|
||||||
|
return func(client *HTTPClient) { client.baseURL = strings.TrimRight(baseURL, "/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient overrides the HTTP transport.
|
||||||
|
func WithHTTPClient(httpClient *http.Client) ClientOption {
|
||||||
|
return func(client *HTTPClient) { client.httpClient = httpClient }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPClient creates a Cloudflare HTTP client.
|
||||||
|
func NewHTTPClient(token string, options ...ClientOption) *HTTPClient {
|
||||||
|
client := &HTTPClient{
|
||||||
|
token: strings.TrimSpace(token),
|
||||||
|
baseURL: defaultAPIBaseURL,
|
||||||
|
httpClient: httppool.NewClient(defaultHTTPTimeout),
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(client)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiEnvelope[T any] struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Errors []apiError `json:"errors"`
|
||||||
|
Result T `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken verifies that the configured API token is active.
|
||||||
|
func (client *HTTPClient) VerifyToken(ctx context.Context) error {
|
||||||
|
var result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := client.do(ctx, http.MethodGet, "/user/tokens/verify", nil, nil, &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Status != "active" {
|
||||||
|
return errors.New("cloudflare API Token 未激活")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindZone returns the exact Cloudflare zone name.
|
||||||
|
func (client *HTTPClient) FindZone(ctx context.Context, name string) (*Zone, error) {
|
||||||
|
query := url.Values{"name": {strings.TrimSpace(name)}, "status": {"active"}, "per_page": {"2"}}
|
||||||
|
var zones []Zone
|
||||||
|
if err := client.do(ctx, http.MethodGet, "/zones", query, nil, &zones); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(zones) != 1 {
|
||||||
|
return nil, fmt.Errorf("cloudflare 中未找到唯一 Zone %s", name)
|
||||||
|
}
|
||||||
|
return &zones[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecord returns a DNS record by ID.
|
||||||
|
func (client *HTTPClient) GetRecord(ctx context.Context, zoneID, recordID string) (*DNSRecord, error) {
|
||||||
|
var record DNSRecord
|
||||||
|
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
|
||||||
|
if err := client.do(ctx, http.MethodGet, path, nil, nil, &record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListARecords lists exact-name A records.
|
||||||
|
func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) ([]DNSRecord, error) {
|
||||||
|
query := url.Values{"type": {"A"}, "name": {strings.TrimSpace(name)}, "per_page": {"100"}}
|
||||||
|
var records []DNSRecord
|
||||||
|
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
|
||||||
|
if err := client.do(ctx, http.MethodGet, path, query, nil, &records); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateARecord creates an A record.
|
||||||
|
func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) {
|
||||||
|
var record DNSRecord
|
||||||
|
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
|
||||||
|
if err := client.do(ctx, http.MethodPost, path, nil, input, &record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateARecord replaces an A record.
|
||||||
|
func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) {
|
||||||
|
var record DNSRecord
|
||||||
|
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
|
||||||
|
if err := client.do(ctx, http.MethodPut, path, nil, input, &record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecord deletes a DNS record.
|
||||||
|
func (client *HTTPClient) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
|
||||||
|
return client.do(ctx, http.MethodDelete, path, nil, nil, &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *HTTPClient) do(ctx context.Context, method, path string, query url.Values, body, result any) error {
|
||||||
|
encodedBody, err := encodeRequestBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
requestURL := buildRequestURL(client.baseURL, path, query)
|
||||||
|
for attempt := 0; attempt < maxRequestAttempts; attempt++ {
|
||||||
|
statusCode, retryHeader, responseBody, requestErr := client.send(ctx, method, requestURL, encodedBody)
|
||||||
|
if requestErr != nil {
|
||||||
|
return requestErr
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusTooManyRequests && attempt < maxRequestAttempts-1 {
|
||||||
|
if waitErr := waitForRetry(ctx, retryAfter(retryHeader)); waitErr != nil {
|
||||||
|
return waitErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return decodeAPIResponse(statusCode, responseBody, result)
|
||||||
|
}
|
||||||
|
return errors.New("cloudflare API 请求超过重试次数")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRequestBody(body any) ([]byte, error) {
|
||||||
|
if body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
encodedBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Cloudflare request: %w", err)
|
||||||
|
}
|
||||||
|
return encodedBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRequestURL(baseURL, path string, query url.Values) string {
|
||||||
|
requestURL := baseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
requestURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
return requestURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *HTTPClient) send(ctx context.Context, method, requestURL string, body []byte) (int, string, []byte, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", nil, fmt.Errorf("create Cloudflare request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+client.token)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", nil, fmt.Errorf("cloudflare API 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes))
|
||||||
|
closeErr := response.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return 0, "", nil, fmt.Errorf("read Cloudflare response: %w", readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return 0, "", nil, fmt.Errorf("close Cloudflare response: %w", closeErr)
|
||||||
|
}
|
||||||
|
return response.StatusCode, response.Header.Get("Retry-After"), responseBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAPIResponse(statusCode int, responseBody []byte, result any) error {
|
||||||
|
var envelope apiEnvelope[json.RawMessage]
|
||||||
|
if err := json.Unmarshal(responseBody, &envelope); err != nil {
|
||||||
|
return fmt.Errorf("decode Cloudflare response: %w", err)
|
||||||
|
}
|
||||||
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices || !envelope.Success {
|
||||||
|
message := "cloudflare API 请求失败"
|
||||||
|
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
|
||||||
|
message = envelope.Errors[0].Message
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
if result == nil || len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(envelope.Result, result); err != nil {
|
||||||
|
return fmt.Errorf("decode Cloudflare result: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryAfter(value string) time.Duration {
|
||||||
|
seconds, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil || seconds <= 0 {
|
||||||
|
return defaultRetryDelay
|
||||||
|
}
|
||||||
|
if seconds > maxRetryAfterSeconds {
|
||||||
|
seconds = maxRetryAfterSeconds
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPClientVerifyTokenAndManageARecord(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer test-token", got)
|
||||||
|
}
|
||||||
|
writeCFTestResponse(t, w, map[string]any{"status": "active"})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.Query().Get("name"); got != "example.com" {
|
||||||
|
t.Errorf("zone name = %q, want example.com", got)
|
||||||
|
}
|
||||||
|
writeCFTestResponse(t, w, []map[string]any{{"id": "zone-1", "name": "example.com"}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/zones/zone-1/dns_records", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
writeCFTestResponse(t, w, []map[string]any{})
|
||||||
|
case http.MethodPost:
|
||||||
|
var input RecordInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
t.Fatalf("Decode(create) error = %v", err)
|
||||||
|
}
|
||||||
|
if input.Type != "A" || input.Name != "api.example.com" || input.Content != "203.0.113.10" || !input.Proxied {
|
||||||
|
t.Errorf("create input = %+v", input)
|
||||||
|
}
|
||||||
|
writeCFTestResponse(t, w, map[string]any{"id": "record-1", "type": "A", "name": input.Name, "content": input.Content, "proxied": input.Proxied})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/zones/zone-1/dns_records/record-1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
writeCFTestResponse(t, w, map[string]any{"id": "record-1", "type": "A", "name": "api.example.com", "content": "203.0.113.11", "proxied": false})
|
||||||
|
case http.MethodDelete:
|
||||||
|
writeCFTestResponse(t, w, map[string]any{"id": "record-1"})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
client := NewHTTPClient("test-token", WithBaseURL(server.URL), WithHTTPClient(server.Client()))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := client.VerifyToken(ctx); err != nil {
|
||||||
|
t.Fatalf("VerifyToken() error = %v", err)
|
||||||
|
}
|
||||||
|
zone, err := client.FindZone(ctx, "example.com")
|
||||||
|
if err != nil || zone.ID != "zone-1" {
|
||||||
|
t.Fatalf("FindZone() = %+v, %v", zone, err)
|
||||||
|
}
|
||||||
|
records, err := client.ListARecords(ctx, zone.ID, "api.example.com")
|
||||||
|
if err != nil || len(records) != 0 {
|
||||||
|
t.Fatalf("ListARecords() = %+v, %v", records, err)
|
||||||
|
}
|
||||||
|
record, err := client.CreateARecord(ctx, zone.ID, RecordInput{Type: "A", Name: "api.example.com", Content: "203.0.113.10", Proxied: true, TTL: 1})
|
||||||
|
if err != nil || record.ID != "record-1" {
|
||||||
|
t.Fatalf("CreateARecord() = %+v, %v", record, err)
|
||||||
|
}
|
||||||
|
if _, err := client.UpdateARecord(ctx, zone.ID, record.ID, RecordInput{Type: "A", Name: record.Name, Content: "203.0.113.11", TTL: 300}); err != nil {
|
||||||
|
t.Fatalf("UpdateARecord() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteRecord(ctx, zone.ID, record.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteRecord() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCFTestResponse(t *testing.T, w http.ResponseWriter, result any) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{"success": true, "errors": []any{}, "result": result}); err != nil {
|
||||||
|
t.Fatalf("Encode(response) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
const (
|
||||||
|
errConnectionNotConfigured = "尚未配置 Cloudflare 连接"
|
||||||
|
errConnectionSourceInvalid = "无效的 Cloudflare 连接来源"
|
||||||
|
errStandaloneInputRequired = "请填写 Cloudflare API Token"
|
||||||
|
errStandaloneInputInvalid = "配置的 Cloudflare API Token 无效"
|
||||||
|
errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号"
|
||||||
|
errGroupNameRequired = "分组名称不能为空"
|
||||||
|
errGroupNodeSame = "主节点和备用节点不能相同"
|
||||||
|
errNodeInvalid = "请选择有效的边缘节点"
|
||||||
|
errNodeIPv4Required = "生效节点必须配置合法 IPv4"
|
||||||
|
errGroupDisabled = "指向分组已停用"
|
||||||
|
errMemberExists = "该域名已加入其他指向分组"
|
||||||
|
errMultipleARecords = "检测到 Cloudflare 中存在多条同名 A 记录,请先手动清理"
|
||||||
|
errSyncFailed = "Cloudflare DNS 同步失败"
|
||||||
|
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
||||||
|
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
||||||
|
)
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientFactory = func(token string) Client { return NewHTTPClient(token) }
|
||||||
|
|
||||||
|
// SetClientFactoryForTest replaces Cloudflare client construction for tests.
|
||||||
|
func SetClientFactoryForTest(factory func(string) Client) func() {
|
||||||
|
previous := clientFactory
|
||||||
|
clientFactory = factory
|
||||||
|
return func() { clientFactory = previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnection returns the global connection state without its token.
|
||||||
|
func GetConnection(ctx context.Context) (*ConnectionView, error) {
|
||||||
|
item, err := repository.GetCFConnection(ctx)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return &ConnectionView{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return connectionView(item), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConnection stores a DNS-account or standalone Cloudflare credential source.
|
||||||
|
func SaveConnection(ctx context.Context, input ConnectionInput) (*ConnectionView, error) {
|
||||||
|
source := strings.TrimSpace(input.Source)
|
||||||
|
item := &model.CFConnection{Source: source}
|
||||||
|
switch source {
|
||||||
|
case model.CFConnectionSourceDNSAccount:
|
||||||
|
account, err := repository.GetDNSAccountByID(ctx, input.DNSAccountID)
|
||||||
|
if err != nil || !strings.EqualFold(strings.TrimSpace(account.Type), "cloudflare") {
|
||||||
|
return nil, errors.New(errDNSAccountInvalid)
|
||||||
|
}
|
||||||
|
item.DNSAccountID = &account.ID
|
||||||
|
case model.CFConnectionSourceStandalone:
|
||||||
|
token := strings.TrimSpace(input.APIToken)
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New(errStandaloneInputRequired)
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]string{"api_token": token})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errStandaloneInputInvalid)
|
||||||
|
}
|
||||||
|
sealed, err := credential.Seal(string(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errStandaloneInputInvalid)
|
||||||
|
}
|
||||||
|
item.Authorization = sealed
|
||||||
|
default:
|
||||||
|
return nil, errors.New(errConnectionSourceInvalid)
|
||||||
|
}
|
||||||
|
if err := repository.UpsertCFConnection(ctx, item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return connectionView(item), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearConnection removes the configured Cloudflare credential.
|
||||||
|
func ClearConnection(ctx context.Context) error {
|
||||||
|
return repository.DeleteCFConnection(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyConnection verifies and marks the configured token ready.
|
||||||
|
func VerifyConnection(ctx context.Context) (*ConnectionView, error) {
|
||||||
|
item, err := repository.GetCFConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New(errConnectionNotConfigured)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
token, err := resolveToken(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = clientFactory(token).VerifyToken(ctx); err != nil {
|
||||||
|
item.Status = model.CFConnectionStatusError
|
||||||
|
item.VerifiedAt = nil
|
||||||
|
if persistErr := repository.UpsertCFConnection(ctx, item); persistErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] persist failed verification status failed: error=%v", persistErr)
|
||||||
|
}
|
||||||
|
return nil, errors.New(errStandaloneInputInvalid)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
item.Status = model.CFConnectionStatusReady
|
||||||
|
item.VerifiedAt = &now
|
||||||
|
if err = repository.UpsertCFConnection(ctx, item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return connectionView(item), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectionView(item *model.CFConnection) *ConnectionView {
|
||||||
|
return &ConnectionView{
|
||||||
|
Configured: true,
|
||||||
|
Ready: item.Status == model.CFConnectionStatusReady,
|
||||||
|
Source: item.Source, DNSAccountID: item.DNSAccountID,
|
||||||
|
Status: item.Status, VerifiedAt: item.VerifiedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToken(ctx context.Context, item *model.CFConnection) (string, error) {
|
||||||
|
if item == nil {
|
||||||
|
return "", errors.New(errConnectionNotConfigured)
|
||||||
|
}
|
||||||
|
stored := item.Authorization
|
||||||
|
if item.Source == model.CFConnectionSourceDNSAccount {
|
||||||
|
if item.DNSAccountID == nil {
|
||||||
|
return "", errors.New(errDNSAccountInvalid)
|
||||||
|
}
|
||||||
|
account, err := repository.GetDNSAccountByID(ctx, *item.DNSAccountID)
|
||||||
|
if err != nil || !strings.EqualFold(strings.TrimSpace(account.Type), "cloudflare") {
|
||||||
|
return "", errors.New(errDNSAccountInvalid)
|
||||||
|
}
|
||||||
|
stored = account.Authorization
|
||||||
|
} else if item.Source != model.CFConnectionSourceStandalone {
|
||||||
|
return "", errors.New(errConnectionSourceInvalid)
|
||||||
|
}
|
||||||
|
opened, err := credential.Open(stored)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New(errStandaloneInputInvalid)
|
||||||
|
}
|
||||||
|
var authorization map[string]string
|
||||||
|
if err = json.Unmarshal([]byte(opened), &authorization); err != nil || strings.TrimSpace(authorization["api_token"]) == "" {
|
||||||
|
return "", errors.New(errStandaloneInputInvalid)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(authorization["api_token"]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNodeOptions lists edge nodes selectable by pointing groups.
|
||||||
|
func ListNodeOptions(ctx context.Context) ([]NodeOption, error) {
|
||||||
|
nodes, err := repository.ListOpenFlareNodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]NodeOption, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.NodeType != "edge_node" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, nodeOption(&node))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups returns pointing group summaries.
|
||||||
|
func ListGroups(ctx context.Context) ([]GroupItem, error) {
|
||||||
|
groups, err := repository.ListCFPointingGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]GroupItem, 0, len(groups))
|
||||||
|
for i := range groups {
|
||||||
|
item, buildErr := buildGroupItem(ctx, &groups[i])
|
||||||
|
if buildErr != nil {
|
||||||
|
return nil, buildErr
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup creates a pointing group with its primary node active.
|
||||||
|
func CreateGroup(ctx context.Context, input GroupInput) (*GroupItem, error) {
|
||||||
|
group, err := groupFromInput(ctx, nil, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = repository.CreateCFPointingGroup(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildGroupItem(ctx, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup updates a pointing group and queues reconciliation when enabled.
|
||||||
|
func UpdateGroup(ctx context.Context, id uint, input GroupInput) (*GroupItem, error) {
|
||||||
|
existing, err := repository.GetCFPointingGroup(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group, err := groupFromInput(ctx, existing, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = repository.SaveCFPointingGroup(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = repository.MarkCFPointingGroupMembersPending(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if group.Enabled {
|
||||||
|
if _, err = DispatchGroupSync(ctx, id, "cloudflare_group_update"); err != nil {
|
||||||
|
return nil, errors.New(errTaskDispatchFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildGroupItem(ctx, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input GroupInput) (*model.CFPointingGroup, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New(errGroupNameRequired)
|
||||||
|
}
|
||||||
|
if input.BackupNodeID != nil && *input.BackupNodeID == input.PrimaryNodeID {
|
||||||
|
return nil, errors.New(errGroupNodeSame)
|
||||||
|
}
|
||||||
|
primary, err := validEdgeNode(ctx, input.PrimaryNodeID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.BackupNodeID != nil {
|
||||||
|
if _, err = validEdgeNode(ctx, *input.BackupNodeID, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
existing = &model.CFPointingGroup{}
|
||||||
|
}
|
||||||
|
existing.Name = name
|
||||||
|
existing.PrimaryNodeID = primary.ID
|
||||||
|
existing.ActiveNodeID = primary.ID
|
||||||
|
existing.BackupNodeID = input.BackupNodeID
|
||||||
|
existing.DefaultProxied = input.DefaultProxied
|
||||||
|
existing.Enabled = input.Enabled
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEdgeNode(ctx context.Context, id uint, requireIPv4 bool) (*model.OpenFlareNode, error) {
|
||||||
|
node, err := repository.GetOpenFlareNodeByID(ctx, id)
|
||||||
|
if err != nil || node.NodeType != "edge_node" {
|
||||||
|
return nil, errors.New(errNodeInvalid)
|
||||||
|
}
|
||||||
|
if requireIPv4 && net.ParseIP(strings.TrimSpace(node.IP)).To4() == nil {
|
||||||
|
return nil, errors.New(errNodeIPv4Required)
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupItem, error) {
|
||||||
|
primary, err := repository.GetOpenFlareNodeByID(ctx, group.PrimaryNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
active, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count, err := repository.CountCFPointingMembersByGroupID(ctx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := &GroupItem{ID: group.ID, Name: group.Name, PrimaryNode: nodeOption(primary), ActiveNode: nodeOption(active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt}
|
||||||
|
if group.BackupNodeID != nil {
|
||||||
|
backup, backupErr := repository.GetOpenFlareNodeByID(ctx, *group.BackupNodeID)
|
||||||
|
if backupErr != nil {
|
||||||
|
return nil, backupErr
|
||||||
|
}
|
||||||
|
option := nodeOption(backup)
|
||||||
|
item.BackupNode = &option
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeOption(node *model.OpenFlareNode) NodeOption {
|
||||||
|
return NodeOption{ID: node.ID, Name: node.Name, IP: node.IP}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroup returns a group and its members.
|
||||||
|
func GetGroup(ctx context.Context, id uint) (*GroupDetail, error) {
|
||||||
|
group, err := repository.GetCFPointingGroup(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, err := buildGroupItem(ctx, group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
members, err := listMemberItems(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &GroupDetail{Group: *item, Members: members}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMember adds a ZoneDomain and queues its first synchronization.
|
||||||
|
func CreateMember(ctx context.Context, groupID uint, input MemberCreateInput) (*MemberItem, error) {
|
||||||
|
group, err := repository.GetCFPointingGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
domain, err := repository.GetZoneDomainByID(ctx, input.ZoneDomainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err = repository.GetCFPointingMemberByZoneDomainID(ctx, domain.ID); err == nil {
|
||||||
|
return nil, errors.New(errMemberExists)
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proxied := group.DefaultProxied
|
||||||
|
if input.Proxied != nil {
|
||||||
|
proxied = *input.Proxied
|
||||||
|
}
|
||||||
|
member := &model.CFPointingMember{GroupID: groupID, ZoneDomainID: domain.ID, Proxied: proxied, SyncStatus: model.CFMemberSyncPending}
|
||||||
|
if err = repository.CreateCFPointingMember(ctx, member); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if group.Enabled {
|
||||||
|
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_create"); err != nil {
|
||||||
|
return nil, errors.New(errTaskDispatchFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memberItem(member, domain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMember updates orange-cloud state and queues reconciliation.
|
||||||
|
func UpdateMember(ctx context.Context, groupID, memberID uint, input MemberUpdateInput) (*MemberItem, error) {
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, groupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member.Proxied = input.Proxied
|
||||||
|
member.SyncStatus = model.CFMemberSyncPending
|
||||||
|
member.LastError = ""
|
||||||
|
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group, err := repository.GetCFPointingGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if group.Enabled {
|
||||||
|
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_update"); err != nil {
|
||||||
|
return nil, errors.New(errTaskDispatchFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return memberItem(member, domain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember deletes the managed remote A record before removing local state.
|
||||||
|
func RemoveMember(ctx context.Context, groupID, memberID uint) error {
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, groupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = DeleteManagedRecord(ctx, member.ID); err != nil {
|
||||||
|
return errors.New(errDeleteRemoteFailed)
|
||||||
|
}
|
||||||
|
return repository.DeleteCFPointingMember(ctx, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup removes every managed remote A record and then local state.
|
||||||
|
func DeleteGroup(ctx context.Context, groupID uint) error {
|
||||||
|
if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
members, err := repository.ListCFPointingMembersByGroupID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, member := range members {
|
||||||
|
if err = DeleteManagedRecord(ctx, member.ID); err != nil {
|
||||||
|
return errors.New(errDeleteRemoteFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repository.DeleteCFPointingGroupAndMembers(ctx, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailableDomains returns ZoneDomains not yet assigned to a group.
|
||||||
|
func ListAvailableDomains(ctx context.Context) ([]AvailableDomain, error) {
|
||||||
|
domains, err := repository.ListAvailableCFZoneDomains(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]AvailableDomain, 0, len(domains))
|
||||||
|
for _, domain := range domains {
|
||||||
|
items = append(items, AvailableDomain{ID: domain.ID, ZoneID: domain.ZoneID, Domain: domain.Domain})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMemberItems(ctx context.Context, groupID uint) ([]MemberItem, error) {
|
||||||
|
members, err := repository.ListCFPointingMembersByGroupID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]MemberItem, 0, len(members))
|
||||||
|
for i := range members {
|
||||||
|
domain, domainErr := repository.GetZoneDomainByID(ctx, members[i].ZoneDomainID)
|
||||||
|
if domainErr != nil {
|
||||||
|
return nil, domainErr
|
||||||
|
}
|
||||||
|
items = append(items, *memberItem(&members[i], domain))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func memberItem(member *model.CFPointingMember, domain *model.ZoneDomain) *MemberItem {
|
||||||
|
return &MemberItem{ID: member.ID, GroupID: member.GroupID, ZoneDomainID: member.ZoneDomainID, Domain: domain.Domain, ZoneID: domain.ZoneID, Proxied: member.Proxied, DesiredIP: member.DesiredIP, SyncStatus: member.SyncStatus, LastError: member.LastError, SyncedAt: member.SyncedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverview returns readiness and aggregate sync counts.
|
||||||
|
func GetOverview(ctx context.Context) (*Overview, error) {
|
||||||
|
connection, err := GetConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups, err := repository.ListCFPointingGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overview := &Overview{Connection: *connection, GroupCount: len(groups)}
|
||||||
|
for _, group := range groups {
|
||||||
|
members, listErr := repository.ListCFPointingMembersByGroupID(ctx, group.ID)
|
||||||
|
if listErr != nil {
|
||||||
|
return nil, listErr
|
||||||
|
}
|
||||||
|
for _, member := range members {
|
||||||
|
overview.MemberCount++
|
||||||
|
switch member.SyncStatus {
|
||||||
|
case model.CFMemberSyncOK:
|
||||||
|
overview.OKCount++
|
||||||
|
case model.CFMemberSyncError:
|
||||||
|
overview.ErrorCount++
|
||||||
|
default:
|
||||||
|
overview.PendingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overview, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
memberLockStripeCount = 64
|
||||||
|
memberLastErrorColumn = "last_error"
|
||||||
|
memberSyncStatusColumn = "sync_status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var memberLocks [memberLockStripeCount]sync.Mutex
|
||||||
|
|
||||||
|
// ReconcileMember makes one Cloudflare A record match the local desired state.
|
||||||
|
func ReconcileMember(ctx context.Context, memberID uint) error {
|
||||||
|
lock := &memberLocks[memberID%memberLockStripeCount]
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncing, memberLastErrorColumn: ""}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := reconcileMember(ctx, memberID); err != nil {
|
||||||
|
if updateErr := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncError, memberLastErrorColumn: err.Error()}); updateErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] persist member sync error failed: member_id=%d error=%v", memberID, updateErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileMember(ctx context.Context, memberID uint) error {
|
||||||
|
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !state.Group.Enabled {
|
||||||
|
return errors.New(errGroupDisabled)
|
||||||
|
}
|
||||||
|
ip := strings.TrimSpace(state.Node.IP)
|
||||||
|
if net.ParseIP(ip).To4() == nil {
|
||||||
|
return errors.New(errNodeIPv4Required)
|
||||||
|
}
|
||||||
|
connection, err := repository.GetCFConnection(ctx)
|
||||||
|
if err != nil || connection.Status != model.CFConnectionStatusReady {
|
||||||
|
return errors.New(errConnectionNotConfigured)
|
||||||
|
}
|
||||||
|
token, err := resolveToken(ctx, connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := clientFactory(token)
|
||||||
|
zoneID := state.Member.CFZoneID
|
||||||
|
if zoneID == "" {
|
||||||
|
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
||||||
|
if findErr != nil {
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
|
zoneID = zone.ID
|
||||||
|
}
|
||||||
|
input := RecordInput{Type: "A", Name: state.Domain.Domain, Content: ip, Proxied: state.Member.Proxied, TTL: 300}
|
||||||
|
if input.Proxied {
|
||||||
|
input.TTL = 1
|
||||||
|
}
|
||||||
|
recordID := state.Member.CFRecordID
|
||||||
|
if recordID != "" {
|
||||||
|
if _, getErr := client.GetRecord(ctx, zoneID, recordID); getErr == nil {
|
||||||
|
record, updateErr := client.UpdateARecord(ctx, zoneID, recordID, input)
|
||||||
|
if updateErr != nil {
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var record *DNSRecord
|
||||||
|
switch len(records) {
|
||||||
|
case 0:
|
||||||
|
record, err = client.CreateARecord(ctx, zoneID, input)
|
||||||
|
case 1:
|
||||||
|
record, err = client.UpdateARecord(ctx, zoneID, records[0].ID, input)
|
||||||
|
default:
|
||||||
|
return errors.New(errMultipleARecords)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip string) error {
|
||||||
|
now := time.Now()
|
||||||
|
return repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{
|
||||||
|
"cf_zone_id": zoneID, "cf_record_id": recordID, "desired_ip": ip,
|
||||||
|
memberSyncStatusColumn: model.CFMemberSyncOK, memberLastErrorColumn: "", "synced_at": &now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteManagedRecord deletes the cached or uniquely discoverable A record.
|
||||||
|
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||||
|
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
connection, err := repository.GetCFConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := resolveToken(ctx, connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := clientFactory(token)
|
||||||
|
zoneID := state.Member.CFZoneID
|
||||||
|
if zoneID == "" {
|
||||||
|
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
||||||
|
if findErr != nil {
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
|
zoneID = zone.ID
|
||||||
|
}
|
||||||
|
if state.Member.CFRecordID != "" {
|
||||||
|
if deleteErr := client.DeleteRecord(ctx, zoneID, state.Member.CFRecordID); deleteErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(records) > 1 {
|
||||||
|
return errors.New(errMultipleARecords)
|
||||||
|
}
|
||||||
|
return client.DeleteRecord(ctx, zoneID, records[0].ID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
records []DNSRecord
|
||||||
|
created *RecordInput
|
||||||
|
updated *RecordInput
|
||||||
|
deleted []string
|
||||||
|
deleteErrors map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) VerifyToken(context.Context) error { return nil }
|
||||||
|
func (client *fakeClient) FindZone(context.Context, string) (*Zone, error) {
|
||||||
|
return &Zone{ID: "zone-1", Name: "example.com"}, nil
|
||||||
|
}
|
||||||
|
func (client *fakeClient) GetRecord(context.Context, string, string) (*DNSRecord, error) {
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
func (client *fakeClient) ListARecords(context.Context, string, string) ([]DNSRecord, error) {
|
||||||
|
return client.records, nil
|
||||||
|
}
|
||||||
|
func (client *fakeClient) CreateARecord(_ context.Context, _ string, input RecordInput) (*DNSRecord, error) {
|
||||||
|
client.created = &input
|
||||||
|
return &DNSRecord{ID: "record-created", Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||||
|
}
|
||||||
|
func (client *fakeClient) UpdateARecord(_ context.Context, _, id string, input RecordInput) (*DNSRecord, error) {
|
||||||
|
client.updated = &input
|
||||||
|
return &DNSRecord{ID: id, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||||
|
}
|
||||||
|
func (client *fakeClient) DeleteRecord(_ context.Context, _, recordID string) error {
|
||||||
|
client.deleted = append(client.deleted, recordID)
|
||||||
|
return client.deleteErrors[recordID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCloudflareLogicDB(t *testing.T) (context.Context, uint) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gorm.Open() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.AutoMigrate(
|
||||||
|
&model.CFConnection{}, &model.CFPointingGroup{}, &model.CFPointingMember{},
|
||||||
|
&model.Zone{}, &model.ZoneDomain{}, &model.OpenFlareNode{}, &model.DNSAccount{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
db.SetDB(conn)
|
||||||
|
t.Cleanup(func() { db.SetDB(nil) })
|
||||||
|
ctx := context.Background()
|
||||||
|
sealed, err := credential.Seal(`{"api_token":"test-token"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credential.Seal() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.UpsertCFConnection(ctx, &model.CFConnection{Source: model.CFConnectionSourceStandalone, Authorization: sealed, Status: model.CFConnectionStatusReady}); err != nil {
|
||||||
|
t.Fatalf("UpsertCFConnection() error = %v", err)
|
||||||
|
}
|
||||||
|
zone := model.Zone{Domain: "example.com"}
|
||||||
|
node := model.OpenFlareNode{Name: "edge", NodeID: "node-1", NodeType: "edge_node", IP: "203.0.113.10"}
|
||||||
|
if err := conn.Create(&zone).Error; err != nil {
|
||||||
|
t.Fatalf("Create(zone) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.Create(&node).Error; err != nil {
|
||||||
|
t.Fatalf("Create(node) error = %v", err)
|
||||||
|
}
|
||||||
|
domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "api.example.com"}
|
||||||
|
if err := conn.Create(&domain).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain) error = %v", err)
|
||||||
|
}
|
||||||
|
group := model.CFPointingGroup{Name: "primary", PrimaryNodeID: node.ID, ActiveNodeID: node.ID, DefaultProxied: true, Enabled: true}
|
||||||
|
if err := conn.Create(&group).Error; err != nil {
|
||||||
|
t.Fatalf("Create(group) error = %v", err)
|
||||||
|
}
|
||||||
|
member := model.CFPointingMember{GroupID: group.ID, ZoneDomainID: domain.ID, Proxied: true, SyncStatus: model.CFMemberSyncPending}
|
||||||
|
if err := conn.Create(&member).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member) error = %v", err)
|
||||||
|
}
|
||||||
|
return ctx, member.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMemberCreatesMissingARecord(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
fake := &fakeClient{}
|
||||||
|
restore := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
if err := ReconcileMember(ctx, memberID); err != nil {
|
||||||
|
t.Fatalf("ReconcileMember() error = %v", err)
|
||||||
|
}
|
||||||
|
if fake.created == nil || fake.created.Content != "203.0.113.10" || !fake.created.Proxied || fake.created.TTL != 1 {
|
||||||
|
t.Errorf("CreateARecord input = %+v", fake.created)
|
||||||
|
}
|
||||||
|
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
if member.SyncStatus != model.CFMemberSyncOK || member.CFRecordID != "record-created" || member.DesiredIP != "203.0.113.10" {
|
||||||
|
t.Errorf("reconciled member = %+v", member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMemberRejectsMultipleSameNameARecords(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
fake := &fakeClient{records: []DNSRecord{{ID: "one"}, {ID: "two"}}}
|
||||||
|
restore := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
if err := ReconcileMember(ctx, memberID); err == nil {
|
||||||
|
t.Fatal("ReconcileMember() error = nil, want duplicate record error")
|
||||||
|
}
|
||||||
|
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
if member.SyncStatus != model.CFMemberSyncError || member.LastError == "" {
|
||||||
|
t.Errorf("failed member = %+v", member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateMemberCopiesGroupDefaultProxied(t *testing.T) {
|
||||||
|
ctx, existingMemberID := setupCloudflareLogicDB(t)
|
||||||
|
existing, err := repository.GetCFPointingMemberByID(ctx, existingMemberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
group, err := repository.GetCFPointingGroup(ctx, existing.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
zone := model.Zone{Domain: "example.net"}
|
||||||
|
if err := db.DB(ctx).Create(&zone).Error; err != nil {
|
||||||
|
t.Fatalf("Create(zone) error = %v", err)
|
||||||
|
}
|
||||||
|
domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "www.example.net"}
|
||||||
|
if err := db.DB(ctx).Create(&domain).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain) error = %v", err)
|
||||||
|
}
|
||||||
|
restore := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { return "task-1", nil })
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
member, err := CreateMember(ctx, group.ID, MemberCreateInput{ZoneDomainID: domain.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMember() error = %v", err)
|
||||||
|
}
|
||||||
|
if !member.Proxied {
|
||||||
|
t.Error("CreateMember() proxied = false, want group default true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteManagedRecordFallsBackWhenCachedRecordIsStale(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{
|
||||||
|
"cf_zone_id": "zone-1",
|
||||||
|
"cf_record_id": "stale-record",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateCFPointingMemberColumns() error = %v", err)
|
||||||
|
}
|
||||||
|
fake := &fakeClient{
|
||||||
|
records: []DNSRecord{{ID: "actual-record"}},
|
||||||
|
deleteErrors: map[string]error{"stale-record": errors.New("not found")},
|
||||||
|
}
|
||||||
|
restore := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
if err := DeleteManagedRecord(ctx, memberID); err != nil {
|
||||||
|
t.Fatalf("DeleteManagedRecord() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.deleted) != 2 || fake.deleted[0] != "stale-record" || fake.deleted[1] != "actual-record" {
|
||||||
|
t.Errorf("deleted record IDs = %v, want [stale-record actual-record]", fake.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMemberDoesNotDispatchWhenGroupIsDisabled(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
group, err := repository.GetCFPointingGroup(ctx, member.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
group.Enabled = false
|
||||||
|
if err = repository.SaveCFPointingGroup(ctx, group); err != nil {
|
||||||
|
t.Fatalf("SaveCFPointingGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
dispatchCount := 0
|
||||||
|
restore := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
|
||||||
|
dispatchCount++
|
||||||
|
return "task-1", nil
|
||||||
|
})
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
updated, err := UpdateMember(ctx, group.ID, memberID, MemberUpdateInput{Proxied: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateMember() error = %v", err)
|
||||||
|
}
|
||||||
|
if updated.SyncStatus != model.CFMemberSyncPending {
|
||||||
|
t.Errorf("UpdateMember() sync status = %q, want %q", updated.SyncStatus, model.CFMemberSyncPending)
|
||||||
|
}
|
||||||
|
if dispatchCount != 0 {
|
||||||
|
t.Errorf("dispatch count = %d, want 0", dispatchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func abortLogic(c *gin.Context, err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
response.AbortNotFound(c, "Cloudflare 资源不存在")
|
||||||
|
case err.Error() == errMemberExists:
|
||||||
|
response.AbortConflict(c, err.Error())
|
||||||
|
case err.Error() == errTaskDispatchFailed:
|
||||||
|
response.AbortInternal(c, err.Error())
|
||||||
|
default:
|
||||||
|
response.AbortBadRequest(c, err.Error())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionHandler returns Cloudflare connection readiness.
|
||||||
|
// @Summary 获取 Cloudflare 连接
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.ConnectionView}
|
||||||
|
// @Router /api/v1/d/cloudflare/connection [get]
|
||||||
|
func GetConnectionHandler(c *gin.Context) {
|
||||||
|
item, err := GetConnection(c.Request.Context())
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConnectionHandler saves a Cloudflare credential source.
|
||||||
|
// @Summary 保存 Cloudflare 连接
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param body body cloudflare.ConnectionInput true "连接参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.ConnectionView}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/connection [put]
|
||||||
|
func SaveConnectionHandler(c *gin.Context) {
|
||||||
|
var input ConnectionInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := SaveConnection(c.Request.Context(), input)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyConnectionHandler verifies the configured Cloudflare token.
|
||||||
|
// @Summary 测试 Cloudflare 连接
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.ConnectionView}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/connection/verify [post]
|
||||||
|
func VerifyConnectionHandler(c *gin.Context) {
|
||||||
|
item, err := VerifyConnection(c.Request.Context())
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearConnectionHandler clears the Cloudflare credential source.
|
||||||
|
// @Summary 清除 Cloudflare 连接
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/connection/clear [post]
|
||||||
|
func ClearConnectionHandler(c *gin.Context) {
|
||||||
|
if abortLogic(c, ClearConnection(c.Request.Context())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverviewHandler returns Cloudflare pointing health.
|
||||||
|
// @Summary 获取 Cloudflare 指向总览
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.Overview}
|
||||||
|
// @Router /api/v1/d/cloudflare/overview [get]
|
||||||
|
func OverviewHandler(c *gin.Context) {
|
||||||
|
item, err := GetOverview(c.Request.Context())
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroupsHandler lists pointing groups.
|
||||||
|
// @Summary 获取 Cloudflare 指向分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any{data=[]cloudflare.GroupItem}
|
||||||
|
// @Router /api/v1/d/cloudflare/groups [get]
|
||||||
|
func ListGroupsHandler(c *gin.Context) {
|
||||||
|
items, err := ListGroups(c.Request.Context())
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroupHandler creates a pointing group.
|
||||||
|
// @Summary 创建 Cloudflare 指向分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param body body cloudflare.GroupInput true "分组参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.GroupItem}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups [post]
|
||||||
|
func CreateGroupHandler(c *gin.Context) {
|
||||||
|
var input GroupInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := CreateGroup(c.Request.Context(), input)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupHandler returns one pointing group and its members.
|
||||||
|
// @Summary 获取 Cloudflare 指向分组详情
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.GroupDetail}
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id} [get]
|
||||||
|
func GetGroupHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := GetGroup(c.Request.Context(), id)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroupHandler updates a pointing group.
|
||||||
|
// @Summary 更新 Cloudflare 指向分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param body body cloudflare.GroupInput true "分组参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.GroupItem}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/update [post]
|
||||||
|
func UpdateGroupHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input GroupInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := UpdateGroup(c.Request.Context(), id, input)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroupHandler deletes a pointing group and its managed remote A records.
|
||||||
|
// @Summary 删除 Cloudflare 指向分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/delete [post]
|
||||||
|
func DeleteGroupHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, DeleteGroup(c.Request.Context(), id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncGroupHandler queues a full group synchronization.
|
||||||
|
// @Summary 同步 Cloudflare 指向分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.SyncReceipt}
|
||||||
|
// @Failure 500 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/sync [post]
|
||||||
|
func SyncGroupHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := DispatchGroupSync(c.Request.Context(), id, "cloudflare_manual_group_sync")
|
||||||
|
if err != nil {
|
||||||
|
response.AbortInternal(c, errTaskDispatchFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembersHandler lists group members.
|
||||||
|
// @Summary 获取 Cloudflare 指向分组成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Success 200 {object} response.Any{data=[]cloudflare.MemberItem}
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members [get]
|
||||||
|
func ListMembersHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := GetGroup(c.Request.Context(), id)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item.Members))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMemberHandler adds a ZoneDomain to a pointing group.
|
||||||
|
// @Summary 添加 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param body body cloudflare.MemberCreateInput true "成员参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 409 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members [post]
|
||||||
|
func CreateMemberHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberCreateInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := CreateMember(c.Request.Context(), id, input)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberHandler updates a member's orange-cloud state.
|
||||||
|
// @Summary 更新 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param memberId path int true "成员 ID"
|
||||||
|
// @Param body body cloudflare.MemberUpdateInput true "成员参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/update [post]
|
||||||
|
func UpdateMemberHandler(c *gin.Context) {
|
||||||
|
groupID, memberID, ok := memberParams(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberUpdateInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := UpdateMember(c.Request.Context(), groupID, memberID, input)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMemberHandler removes a member and its managed remote A record.
|
||||||
|
// @Summary 移出 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param memberId path int true "成员 ID"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove [post]
|
||||||
|
func RemoveMemberHandler(c *gin.Context) {
|
||||||
|
groupID, memberID, ok := memberParams(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, RemoveMember(c.Request.Context(), groupID, memberID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncMemberHandler queues one member synchronization.
|
||||||
|
// @Summary 同步 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param memberId path int true "成员 ID"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.SyncReceipt}
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync [post]
|
||||||
|
func SyncMemberHandler(c *gin.Context) {
|
||||||
|
_, memberID, ok := memberParams(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := DispatchMemberSync(c.Request.Context(), memberID, "cloudflare_manual_member_sync")
|
||||||
|
if err != nil {
|
||||||
|
response.AbortInternal(c, errTaskDispatchFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailableDomainsHandler lists ZoneDomains not assigned to another group.
|
||||||
|
// @Summary 获取可加入 Cloudflare 指向的域名
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} response.Any{data=[]cloudflare.AvailableDomain}
|
||||||
|
// @Router /api/v1/d/cloudflare/domains/available [get]
|
||||||
|
func ListAvailableDomainsHandler(c *gin.Context) {
|
||||||
|
items, err := ListAvailableDomains(c.Request.Context())
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
func memberParams(c *gin.Context) (uint, uint, bool) {
|
||||||
|
groupID, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
memberID, ok := apiutil.NamedIDParam(c, "memberId")
|
||||||
|
return groupID, memberID, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConnectionHandlersNeverReturnAPIToken(t *testing.T) {
|
||||||
|
setupCloudflareLogicDB(t)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(response.ErrorHandlerMiddleware())
|
||||||
|
router.PUT("/connection", SaveConnectionHandler)
|
||||||
|
router.GET("/connection", GetConnectionHandler)
|
||||||
|
|
||||||
|
save := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/connection", strings.NewReader(`{"source":"standalone","api_token":"top-secret-token"}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(save, request)
|
||||||
|
if save.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT /connection status = %d, body = %s", save.Code, save.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(save.Body.String(), "top-secret-token") || strings.Contains(save.Body.String(), "api_token") {
|
||||||
|
t.Fatalf("PUT /connection leaked token: %s", save.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
get := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/connection", nil))
|
||||||
|
if get.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /connection status = %d, body = %s", get.Code, get.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(get.Body.String(), "top-secret-token") || strings.Contains(get.Body.String(), "api_token") {
|
||||||
|
t.Fatalf("GET /connection leaked token: %s", get.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SyncMemberTask is the Asynq task type for one Cloudflare member.
|
||||||
|
SyncMemberTask = "cloudflare:sync_member"
|
||||||
|
// SyncGroupTask is the Asynq task type for a Cloudflare group.
|
||||||
|
SyncGroupTask = "cloudflare:sync_group"
|
||||||
|
// SyncByNodeTask is the Asynq task type for members targeting one node.
|
||||||
|
SyncByNodeTask = "cloudflare:sync_by_node"
|
||||||
|
|
||||||
|
// TaskTypeSyncMember is the task metadata type for member synchronization.
|
||||||
|
TaskTypeSyncMember = "of_cloudflare_sync_member"
|
||||||
|
// TaskTypeSyncGroup is the task metadata type for group synchronization.
|
||||||
|
TaskTypeSyncGroup = "of_cloudflare_sync_group"
|
||||||
|
// TaskTypeSyncByNode is the task metadata type for node-triggered synchronization.
|
||||||
|
TaskTypeSyncByNode = "of_cloudflare_sync_by_node"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncMemberMeta describes one-member reconciliation.
|
||||||
|
var SyncMemberMeta = task.TaskMeta{Type: TaskTypeSyncMember, AsynqTask: SyncMemberTask, Name: "Cloudflare 域名同步", Description: "同步单个域名的 Cloudflare A 记录", MaxRetry: 3, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
|
||||||
|
|
||||||
|
// SyncGroupMeta describes group reconciliation.
|
||||||
|
var SyncGroupMeta = task.TaskMeta{Type: TaskTypeSyncGroup, AsynqTask: SyncGroupTask, Name: "Cloudflare 分组同步", Description: "同步指向分组内全部域名", MaxRetry: 2, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
|
||||||
|
|
||||||
|
// SyncByNodeMeta describes node-triggered reconciliation.
|
||||||
|
var SyncByNodeMeta = task.TaskMeta{Type: TaskTypeSyncByNode, AsynqTask: SyncByNodeTask, Name: "Cloudflare 节点同步", Description: "同步当前指向指定节点的全部域名", MaxRetry: 2, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
|
||||||
|
|
||||||
|
// SyncMemberPayload identifies one member.
|
||||||
|
type SyncMemberPayload struct {
|
||||||
|
MemberID uint `json:"member_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncGroupPayload identifies one group.
|
||||||
|
type SyncGroupPayload struct {
|
||||||
|
GroupID uint `json:"group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncByNodePayload identifies one active node.
|
||||||
|
type SyncByNodePayload struct {
|
||||||
|
NodeID uint `json:"node_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var dispatchTaskFn = task.DispatchTask
|
||||||
|
|
||||||
|
// SetDispatchTaskForTest replaces task dispatch for tests.
|
||||||
|
func SetDispatchTaskForTest(fn func(context.Context, string, []byte, string) (string, error)) func() {
|
||||||
|
previous := dispatchTaskFn
|
||||||
|
dispatchTaskFn = fn
|
||||||
|
return func() { dispatchTaskFn = previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchMemberSync queues one member reconciliation.
|
||||||
|
func DispatchMemberSync(ctx context.Context, memberID uint, triggeredBy string) (string, error) {
|
||||||
|
return dispatch(ctx, TaskTypeSyncMember, SyncMemberPayload{MemberID: memberID}, triggeredBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchGroupSync queues a group reconciliation.
|
||||||
|
func DispatchGroupSync(ctx context.Context, groupID uint, triggeredBy string) (string, error) {
|
||||||
|
return dispatch(ctx, TaskTypeSyncGroup, SyncGroupPayload{GroupID: groupID}, triggeredBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchNodeSync queues reconciliation for members targeting a node.
|
||||||
|
func DispatchNodeSync(ctx context.Context, nodeID uint, triggeredBy string) (string, error) {
|
||||||
|
return dispatch(ctx, TaskTypeSyncByNode, SyncByNodePayload{NodeID: nodeID}, triggeredBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatch(ctx context.Context, taskType string, payload any, triggeredBy string) (string, error) {
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dispatchTaskFn(ctx, taskType, encoded, triggeredBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncMemberTaskHandler reconciles one member.
|
||||||
|
type SyncMemberTaskHandler struct{}
|
||||||
|
|
||||||
|
// ValidatePayload validates a one-member task payload.
|
||||||
|
func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||||
|
var input SyncMemberPayload
|
||||||
|
if err := decodePayload(payload, &input); err != nil || input.MemberID == 0 {
|
||||||
|
return nil, errors.New("无效的 Cloudflare 成员同步参数")
|
||||||
|
}
|
||||||
|
return json.Marshal(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reconciles one member.
|
||||||
|
func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
|
normalized, err := handler.ValidatePayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, task.PermanentError(err.Error())
|
||||||
|
}
|
||||||
|
var input SyncMemberPayload
|
||||||
|
_ = json.Unmarshal(normalized, &input)
|
||||||
|
task.AppendLog(ctx, "正在同步 Cloudflare 成员 ID=%d", input.MemberID)
|
||||||
|
if err = ReconcileMember(ctx, input.MemberID); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", errSyncFailed, err)
|
||||||
|
}
|
||||||
|
return &task.TaskResult{Message: "Cloudflare 域名同步成功"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncGroupTaskHandler reconciles every member in a group.
|
||||||
|
type SyncGroupTaskHandler struct{}
|
||||||
|
|
||||||
|
// ValidatePayload validates a group task payload.
|
||||||
|
func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||||
|
var input SyncGroupPayload
|
||||||
|
if err := decodePayload(payload, &input); err != nil || input.GroupID == 0 {
|
||||||
|
return nil, errors.New("无效的 Cloudflare 分组同步参数")
|
||||||
|
}
|
||||||
|
return json.Marshal(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reconciles every member in a group.
|
||||||
|
func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
|
normalized, err := handler.ValidatePayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, task.PermanentError(err.Error())
|
||||||
|
}
|
||||||
|
var input SyncGroupPayload
|
||||||
|
if err = json.Unmarshal(normalized, &input); err != nil {
|
||||||
|
return nil, task.PermanentError(err.Error())
|
||||||
|
}
|
||||||
|
members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID)
|
||||||
|
return executeBatchSync(ctx, members, err, "分组")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncByNodeTaskHandler reconciles every member targeting a node.
|
||||||
|
type SyncByNodeTaskHandler struct{}
|
||||||
|
|
||||||
|
// ValidatePayload validates a node task payload.
|
||||||
|
func (handler *SyncByNodeTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||||
|
var input SyncByNodePayload
|
||||||
|
if err := decodePayload(payload, &input); err != nil || input.NodeID == 0 {
|
||||||
|
return nil, errors.New("无效的 Cloudflare 节点同步参数")
|
||||||
|
}
|
||||||
|
return json.Marshal(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reconciles every member targeting a node.
|
||||||
|
func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
|
normalized, err := handler.ValidatePayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, task.PermanentError(err.Error())
|
||||||
|
}
|
||||||
|
var input SyncByNodePayload
|
||||||
|
if err = json.Unmarshal(normalized, &input); err != nil {
|
||||||
|
return nil, task.PermanentError(err.Error())
|
||||||
|
}
|
||||||
|
members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID)
|
||||||
|
return executeBatchSync(ctx, members, err, "节点")
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeBatchSync(ctx context.Context, members []model.CFPointingMember, listErr error, scope string) (*task.TaskResult, error) {
|
||||||
|
if listErr != nil {
|
||||||
|
return nil, listErr
|
||||||
|
}
|
||||||
|
for _, member := range members {
|
||||||
|
if err := ReconcileMember(ctx, member.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &task.TaskResult{Message: fmt.Sprintf("Cloudflare %s同步完成,共 %d 个域名", scope, len(members))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePayload(payload []byte, target any) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return errors.New("unexpected trailing JSON value")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSyncMemberTaskHandlerRejectsTrailingJSONContent(t *testing.T) {
|
||||||
|
handler := &SyncMemberTaskHandler{}
|
||||||
|
for _, payload := range [][]byte{
|
||||||
|
[]byte(`{"member_id":7} {}`),
|
||||||
|
[]byte(`{"member_id":7}}`),
|
||||||
|
[]byte(`{"member_id":7}]`),
|
||||||
|
} {
|
||||||
|
if normalized, err := handler.ValidatePayload(payload); err == nil {
|
||||||
|
t.Errorf("ValidatePayload(%s) = %s, nil; want non-nil error", payload, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ConnectionInput configures the global Cloudflare credential source.
|
||||||
|
type ConnectionInput struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
DNSAccountID uint `json:"dns_account_id"`
|
||||||
|
APIToken string `json:"api_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionView exposes connection state without credentials.
|
||||||
|
type ConnectionView struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
DNSAccountID *uint `json:"dns_account_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeOption is a selectable edge node.
|
||||||
|
type NodeOption struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupInput creates or updates a pointing group.
|
||||||
|
type GroupInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PrimaryNodeID uint `json:"primary_node_id"`
|
||||||
|
BackupNodeID *uint `json:"backup_node_id"`
|
||||||
|
DefaultProxied bool `json:"default_proxied"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupItem is the admin-facing pointing group summary.
|
||||||
|
type GroupItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PrimaryNode NodeOption `json:"primary_node"`
|
||||||
|
BackupNode *NodeOption `json:"backup_node"`
|
||||||
|
ActiveNode NodeOption `json:"active_node"`
|
||||||
|
DefaultProxied bool `json:"default_proxied"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MemberCount int64 `json:"member_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberCreateInput adds a ZoneDomain to a group. Nil Proxied copies the group default.
|
||||||
|
type MemberCreateInput struct {
|
||||||
|
ZoneDomainID uint `json:"zone_domain_id"`
|
||||||
|
Proxied *bool `json:"proxied"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberUpdateInput updates the effective orange-cloud state.
|
||||||
|
type MemberUpdateInput struct {
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberItem is the admin-facing member state.
|
||||||
|
type MemberItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
GroupID uint `json:"group_id"`
|
||||||
|
ZoneDomainID uint `json:"zone_domain_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
ZoneID uint `json:"zone_id"`
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
DesiredIP string `json:"desired_ip"`
|
||||||
|
SyncStatus string `json:"sync_status"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupDetail combines a group with its members.
|
||||||
|
type GroupDetail struct {
|
||||||
|
Group GroupItem `json:"group"`
|
||||||
|
Members []MemberItem `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableDomain is a ZoneDomain eligible for pointing.
|
||||||
|
type AvailableDomain struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ZoneID uint `json:"zone_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview summarizes Cloudflare pointing readiness and sync health.
|
||||||
|
type Overview struct {
|
||||||
|
Connection ConnectionView `json:"connection"`
|
||||||
|
GroupCount int `json:"group_count"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
OKCount int `json:"ok_count"`
|
||||||
|
PendingCount int `json:"pending_count"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncReceipt identifies a queued asynchronous synchronization.
|
||||||
|
type SyncReceipt struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package credential seals and opens OpenFlare integration credentials.
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix identifies values encrypted with the current credential format.
|
||||||
|
const Prefix = "enc:v1:"
|
||||||
|
|
||||||
|
func encryptionKey() string {
|
||||||
|
if config.Config == nil || strings.TrimSpace(config.Config.App.SessionSecret) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(config.Config.App.SessionSecret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal trims and encrypts plaintext when a session secret is configured.
|
||||||
|
// Plaintext storage is preserved for installations without a session secret.
|
||||||
|
func Seal(plaintext string) (string, error) {
|
||||||
|
plaintext = strings.TrimSpace(plaintext)
|
||||||
|
if plaintext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
key := encryptionKey()
|
||||||
|
if key == "" {
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
encrypted, err := util.Encrypt(key, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Prefix + encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open decrypts a sealed value and accepts legacy plaintext values.
|
||||||
|
func Open(stored string) (string, error) {
|
||||||
|
stored = strings.TrimSpace(stored)
|
||||||
|
if stored == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stored, Prefix) {
|
||||||
|
return stored, nil
|
||||||
|
}
|
||||||
|
key := encryptionKey()
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("cannot decrypt sensitive field without session secret")
|
||||||
|
}
|
||||||
|
return util.Decrypt(key, strings.TrimPrefix(stored, Prefix))
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSealAndOpenSensitiveValue(t *testing.T) {
|
||||||
|
previous := config.Config.App.SessionSecret
|
||||||
|
config.Config.App.SessionSecret = "cloudflare-pointing-test-secret"
|
||||||
|
t.Cleanup(func() { config.Config.App.SessionSecret = previous })
|
||||||
|
|
||||||
|
sealed, err := Seal(`{"api_token":"secret-token"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Seal() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(sealed, Prefix) {
|
||||||
|
t.Fatalf("Seal() = %q, want prefix %q", sealed, Prefix)
|
||||||
|
}
|
||||||
|
if strings.Contains(sealed, "secret-token") {
|
||||||
|
t.Fatalf("Seal() = %q, want token redacted", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
opened, err := Open(sealed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := `{"api_token":"secret-token"}`; opened != want {
|
||||||
|
t.Errorf("Open(Seal(value)) = %q, want %q", opened, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealWithoutSessionSecretKeepsPlaintextCompatibility(t *testing.T) {
|
||||||
|
previous := config.Config.App.SessionSecret
|
||||||
|
config.Config.App.SessionSecret = ""
|
||||||
|
t.Cleanup(func() { config.Config.App.SessionSecret = previous })
|
||||||
|
|
||||||
|
sealed, err := Seal(" legacy-value ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Seal() error = %v", err)
|
||||||
|
}
|
||||||
|
if sealed != "legacy-value" {
|
||||||
|
t.Errorf("Seal() = %q, want %q", sealed, "legacy-value")
|
||||||
|
}
|
||||||
|
|
||||||
|
opened, err := Open(sealed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(plaintext) error = %v", err)
|
||||||
|
}
|
||||||
|
if opened != "legacy-value" {
|
||||||
|
t.Errorf("Open(plaintext) = %q, want %q", opened, "legacy-value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenEncryptedValueRequiresSessionSecret(t *testing.T) {
|
||||||
|
previous := config.Config.App.SessionSecret
|
||||||
|
config.Config.App.SessionSecret = "cloudflare-pointing-test-secret"
|
||||||
|
sealed, err := Seal("secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Seal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Config.App.SessionSecret = ""
|
||||||
|
t.Cleanup(func() { config.Config.App.SessionSecret = previous })
|
||||||
|
if _, err := Open(sealed); err == nil {
|
||||||
|
t.Fatal("Open(encrypted) error = nil, want missing session secret error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
|
||||||
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
|
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,6 +234,7 @@ func UpdateNode(ctx context.Context, id uint, input Input) (*View, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ipManualOverride := resolveNodeIPManualOverride(input, node, ip)
|
ipManualOverride := resolveNodeIPManualOverride(input, node, ip)
|
||||||
|
previousIP := node.IP
|
||||||
node.Name = name
|
node.Name = name
|
||||||
node.IP = ip
|
node.IP = ip
|
||||||
node.IPManualOverride = ipManualOverride
|
node.IPManualOverride = ipManualOverride
|
||||||
@@ -255,6 +258,11 @@ func UpdateNode(ctx context.Context, id uint, input Input) (*View, error) {
|
|||||||
if err = repository.SaveOpenFlareNode(ctx, node); err != nil {
|
if err = repository.SaveOpenFlareNode(ctx, node); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(previousIP) != strings.TrimSpace(node.IP) {
|
||||||
|
if _, dispatchErr := cf.DispatchNodeSync(ctx, node.ID, "cloudflare_node_ip_update"); dispatchErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] enqueue node sync failed: node_id=%d error=%v", node.ID, dispatchErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return buildNodeView(node), nil
|
return buildNodeView(node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
@@ -130,6 +131,27 @@ func TestUpdateNode(t *testing.T) {
|
|||||||
assert.True(t, updated.AutoUpdateEnabled)
|
assert.True(t, updated.AutoUpdateEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNodeDispatchesCloudflareSyncWhenIPChanges(t *testing.T) {
|
||||||
|
cleanup := setupNodeTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
created, err := CreateNode(ctx, Input{Name: "edge-update", IP: "192.0.2.10"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var dispatchedNodeID uint
|
||||||
|
restore := cf.SetDispatchTaskForTest(func(_ context.Context, taskType string, payload []byte, _ string) (string, error) {
|
||||||
|
assert.Equal(t, cf.TaskTypeSyncByNode, taskType)
|
||||||
|
assert.Contains(t, string(payload), `"node_id":`)
|
||||||
|
dispatchedNodeID = created.ID
|
||||||
|
return "task-1", nil
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
_, err = UpdateNode(ctx, created.ID, Input{Name: "edge-update", IP: "192.0.2.11"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.ID, dispatchedNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteNode(t *testing.T) {
|
func TestDeleteNode(t *testing.T) {
|
||||||
cleanup := setupNodeTestDB(t)
|
cleanup := setupNodeTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||||
@@ -128,7 +129,7 @@ func TestCreateCertificateEncryptsPrivateKey(t *testing.T) {
|
|||||||
stored, err := repository.GetTLSCertificateByID(ctx, certificate.ID)
|
stored, err := repository.GetTLSCertificateByID(ctx, certificate.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEqual(t, keyPEM, stored.KeyPEM)
|
assert.NotEqual(t, keyPEM, stored.KeyPEM)
|
||||||
assert.Contains(t, stored.KeyPEM, sensitiveValuePrefix)
|
assert.Contains(t, stored.KeyPEM, credential.Prefix)
|
||||||
|
|
||||||
content, err := GetCertificateContent(ctx, certificate.ID)
|
content, err := GetCertificateContent(ctx, certificate.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -3,40 +3,10 @@
|
|||||||
|
|
||||||
package tls
|
package tls
|
||||||
|
|
||||||
import (
|
import "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
const sensitiveValuePrefix = "enc:v1:"
|
|
||||||
|
|
||||||
func sensitiveEncryptionKey() string {
|
|
||||||
if config.Config == nil || strings.TrimSpace(config.Config.App.SessionSecret) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(config.Config.App.SessionSecret))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func sealSensitive(plaintext string) (string, error) {
|
func sealSensitive(plaintext string) (string, error) {
|
||||||
plaintext = strings.TrimSpace(plaintext)
|
return credential.Seal(plaintext)
|
||||||
if plaintext == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
key := sensitiveEncryptionKey()
|
|
||||||
if key == "" {
|
|
||||||
return plaintext, nil
|
|
||||||
}
|
|
||||||
encrypted, err := util.Encrypt(key, plaintext)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return sensitiveValuePrefix + encrypted, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenKeyPEM decrypts a stored certificate private key for runtime distribution.
|
// OpenKeyPEM decrypts a stored certificate private key for runtime distribution.
|
||||||
@@ -45,16 +15,5 @@ func OpenKeyPEM(stored string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openSensitive(stored string) (string, error) {
|
func openSensitive(stored string) (string, error) {
|
||||||
stored = strings.TrimSpace(stored)
|
return credential.Open(stored)
|
||||||
if stored == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(stored, sensitiveValuePrefix) {
|
|
||||||
return stored, nil
|
|
||||||
}
|
|
||||||
key := sensitiveEncryptionKey()
|
|
||||||
if key == "" {
|
|
||||||
return "", errors.New("cannot decrypt sensitive field without session secret")
|
|
||||||
}
|
|
||||||
return util.Decrypt(key, strings.TrimPrefix(stored, sensitiveValuePrefix))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_connections (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
dns_account_id BIGINT,
|
||||||
|
authorization TEXT NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT '',
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_connections_dns_account_id ON of_cf_connections (dns_account_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_pointing_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
primary_node_id BIGINT NOT NULL,
|
||||||
|
backup_node_id BIGINT,
|
||||||
|
active_node_id BIGINT NOT NULL,
|
||||||
|
default_proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_primary_node_id ON of_cf_pointing_groups (primary_node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_backup_node_id ON of_cf_pointing_groups (backup_node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_active_node_id ON of_cf_pointing_groups (active_node_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_pointing_members (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
group_id BIGINT NOT NULL,
|
||||||
|
zone_domain_id BIGINT NOT NULL,
|
||||||
|
proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
cf_zone_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
cf_record_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
desired_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_members_group_id ON of_cf_pointing_members (group_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_cf_pointing_members_zone_domain_id ON of_cf_pointing_members (zone_domain_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS of_cf_pointing_members;
|
||||||
|
DROP TABLE IF EXISTS of_cf_pointing_groups;
|
||||||
|
DROP TABLE IF EXISTS of_cf_connections;
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_connections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
dns_account_id INTEGER,
|
||||||
|
authorization TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
verified_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_connections_dns_account_id ON of_cf_connections (dns_account_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_pointing_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
primary_node_id INTEGER NOT NULL,
|
||||||
|
backup_node_id INTEGER,
|
||||||
|
active_node_id INTEGER NOT NULL,
|
||||||
|
default_proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_primary_node_id ON of_cf_pointing_groups (primary_node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_backup_node_id ON of_cf_pointing_groups (backup_node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_active_node_id ON of_cf_pointing_groups (active_node_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS of_cf_pointing_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
zone_domain_id INTEGER NOT NULL,
|
||||||
|
proxied BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
cf_zone_id TEXT NOT NULL DEFAULT '',
|
||||||
|
cf_record_id TEXT NOT NULL DEFAULT '',
|
||||||
|
desired_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
synced_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_members_group_id ON of_cf_pointing_members (group_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_cf_pointing_members_zone_domain_id ON of_cf_pointing_members (zone_domain_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS of_cf_pointing_members;
|
||||||
|
DROP TABLE IF EXISTS of_cf_pointing_groups;
|
||||||
|
DROP TABLE IF EXISTS of_cf_connections;
|
||||||
@@ -84,6 +84,15 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
|||||||
if !sqliteDB.Migrator().HasTable("of_zone_domains") {
|
if !sqliteDB.Migrator().HasTable("of_zone_domains") {
|
||||||
t.Error("Migrate() did not create of_zone_domains")
|
t.Error("Migrate() did not create of_zone_domains")
|
||||||
}
|
}
|
||||||
|
for _, table := range []string{
|
||||||
|
"of_cf_connections",
|
||||||
|
"of_cf_pointing_groups",
|
||||||
|
"of_cf_pointing_members",
|
||||||
|
} {
|
||||||
|
if !sqliteDB.Migrator().HasTable(table) {
|
||||||
|
t.Errorf("Migrate() did not create %s", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
if sqliteDB.Migrator().HasTable("of_managed_domains") {
|
if sqliteDB.Migrator().HasTable("of_managed_domains") {
|
||||||
t.Error("Migrate() should drop of_managed_domains after phase-2 cleanup")
|
t.Error("Migrate() should drop of_managed_domains after phase-2 cleanup")
|
||||||
}
|
}
|
||||||
@@ -118,6 +127,17 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
|||||||
if err := sqliteDB.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain.Domain}).Error; err == nil {
|
if err := sqliteDB.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain.Domain}).Error; err == nil {
|
||||||
t.Error("Migrate() allowed duplicate of_zone_domains.domain")
|
t.Error("Migrate() allowed duplicate of_zone_domains.domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := sqliteDB.Exec(`INSERT INTO of_cf_pointing_members
|
||||||
|
(group_id, zone_domain_id, proxied, cf_zone_id, cf_record_id, desired_ip, sync_status, last_error)
|
||||||
|
VALUES (?, ?, ?, '', '', '', 'pending', '')`, 1, domain.ID, false).Error; err != nil {
|
||||||
|
t.Fatalf("Migrate() insert Cloudflare member error = %v", err)
|
||||||
|
}
|
||||||
|
if err := sqliteDB.Exec(`INSERT INTO of_cf_pointing_members
|
||||||
|
(group_id, zone_domain_id, proxied, cf_zone_id, cf_record_id, desired_ip, sync_status, last_error)
|
||||||
|
VALUES (?, ?, ?, '', '', '', 'pending', '')`, 2, domain.ID, false).Error; err == nil {
|
||||||
|
t.Error("Migrate() allowed duplicate of_cf_pointing_members.zone_domain_id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrateClearsStaleSystemConfigCache(t *testing.T) {
|
func TestMigrateClearsStaleSystemConfigCache(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare"
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||||
@@ -52,6 +53,13 @@ func Register() {
|
|||||||
task.RegisterHandler(openflare.UptimeKumaSyncTask, &openflare.UptimeKumaSyncHandler{})
|
task.RegisterHandler(openflare.UptimeKumaSyncTask, &openflare.UptimeKumaSyncHandler{})
|
||||||
task.RegisterTaskMeta(openflare.UptimeKumaSyncMeta)
|
task.RegisterTaskMeta(openflare.UptimeKumaSyncMeta)
|
||||||
|
|
||||||
|
task.RegisterHandler(cf.SyncMemberTask, &cf.SyncMemberTaskHandler{})
|
||||||
|
task.RegisterTaskMeta(cf.SyncMemberMeta)
|
||||||
|
task.RegisterHandler(cf.SyncGroupTask, &cf.SyncGroupTaskHandler{})
|
||||||
|
task.RegisterTaskMeta(cf.SyncGroupMeta)
|
||||||
|
task.RegisterHandler(cf.SyncByNodeTask, &cf.SyncByNodeTaskHandler{})
|
||||||
|
task.RegisterTaskMeta(cf.SyncByNodeMeta)
|
||||||
|
|
||||||
// pages source actions are only dispatched by the Pages domain API/scanner.
|
// pages source actions are only dispatched by the Pages domain API/scanner.
|
||||||
task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{})
|
task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{})
|
||||||
task.RegisterTaskMeta(pages.PagesSourceScanMeta)
|
task.RegisterTaskMeta(pages.PagesSourceScanMeta)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CFConnectionSourceDNSAccount imports credentials from an existing DNS account.
|
||||||
|
CFConnectionSourceDNSAccount = "dns_account"
|
||||||
|
// CFConnectionSourceStandalone stores an independent API token.
|
||||||
|
CFConnectionSourceStandalone = "standalone"
|
||||||
|
|
||||||
|
// CFConnectionStatusReady indicates the credential passed verification.
|
||||||
|
CFConnectionStatusReady = "ready"
|
||||||
|
// CFConnectionStatusError indicates the latest verification failed.
|
||||||
|
CFConnectionStatusError = "error"
|
||||||
|
|
||||||
|
// CFMemberSyncPending indicates synchronization is queued or required.
|
||||||
|
CFMemberSyncPending = "pending"
|
||||||
|
// CFMemberSyncing indicates a worker is reconciling the record.
|
||||||
|
CFMemberSyncing = "syncing"
|
||||||
|
// CFMemberSyncOK indicates the remote record matches the desired state.
|
||||||
|
CFMemberSyncOK = "ok"
|
||||||
|
// CFMemberSyncError indicates the latest reconciliation failed.
|
||||||
|
CFMemberSyncError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CFConnection stores the single Cloudflare API credential source.
|
||||||
|
type CFConnection struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Source string `json:"source" gorm:"size:32;not null;default:''"`
|
||||||
|
DNSAccountID *uint `json:"dns_account_id" gorm:"index:idx_of_cf_connections_dns_account_id"`
|
||||||
|
Authorization string `json:"-" gorm:"type:text;not null;default:''"`
|
||||||
|
Status string `json:"status" gorm:"size:16;not null;default:''"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the Cloudflare connection table name.
|
||||||
|
func (CFConnection) TableName() string { return "of_cf_connections" }
|
||||||
|
|
||||||
|
// CFPointingGroup stores a reusable node target for DNS records.
|
||||||
|
type CFPointingGroup struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
PrimaryNodeID uint `json:"primary_node_id" gorm:"not null;index:idx_of_cf_pointing_groups_primary_node_id"`
|
||||||
|
BackupNodeID *uint `json:"backup_node_id" gorm:"index:idx_of_cf_pointing_groups_backup_node_id"`
|
||||||
|
ActiveNodeID uint `json:"active_node_id" gorm:"not null;index:idx_of_cf_pointing_groups_active_node_id"`
|
||||||
|
DefaultProxied bool `json:"default_proxied" gorm:"not null;default:false"`
|
||||||
|
Enabled bool `json:"enabled" gorm:"not null;default:false"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the Cloudflare pointing group table name.
|
||||||
|
func (CFPointingGroup) TableName() string { return "of_cf_pointing_groups" }
|
||||||
|
|
||||||
|
// CFPointingMember stores one managed ZoneDomain A record.
|
||||||
|
type CFPointingMember struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
GroupID uint `json:"group_id" gorm:"not null;index:idx_of_cf_pointing_members_group_id"`
|
||||||
|
ZoneDomainID uint `json:"zone_domain_id" gorm:"not null;uniqueIndex:idx_of_cf_pointing_members_zone_domain_id"`
|
||||||
|
Proxied bool `json:"proxied" gorm:"not null;default:false"`
|
||||||
|
CFZoneID string `json:"cf_zone_id" gorm:"size:64;not null;default:''"`
|
||||||
|
CFRecordID string `json:"cf_record_id" gorm:"size:64;not null;default:''"`
|
||||||
|
DesiredIP string `json:"desired_ip" gorm:"size:64;not null;default:''"`
|
||||||
|
SyncStatus string `json:"sync_status" gorm:"size:16;not null;default:'pending'"`
|
||||||
|
LastError string `json:"last_error" gorm:"type:text;not null;default:''"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the Cloudflare pointing member table name.
|
||||||
|
func (CFPointingMember) TableName() string { return "of_cf_pointing_members" }
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const singletonCFConnectionID uint = 1
|
||||||
|
|
||||||
|
// CFPointingMemberContext contains all local state needed to reconcile one member.
|
||||||
|
type CFPointingMemberContext struct {
|
||||||
|
Member model.CFPointingMember
|
||||||
|
Group model.CFPointingGroup
|
||||||
|
Domain model.ZoneDomain
|
||||||
|
Zone model.Zone
|
||||||
|
Node model.OpenFlareNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFConnection returns the global Cloudflare connection.
|
||||||
|
func GetCFConnection(ctx context.Context) (*model.CFConnection, error) {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return nil, errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
var item model.CFConnection
|
||||||
|
if err := conn.First(&item, singletonCFConnectionID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertCFConnection creates or replaces the global Cloudflare connection.
|
||||||
|
func UpsertCFConnection(ctx context.Context, item *model.CFConnection) error {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
item.ID = singletonCFConnectionID
|
||||||
|
return conn.Save(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCFConnection clears the global Cloudflare connection.
|
||||||
|
func DeleteCFConnection(ctx context.Context) error {
|
||||||
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
return conn.Delete(&model.CFConnection{}, singletonCFConnectionID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCFPointingGroups lists Cloudflare pointing groups newest first.
|
||||||
|
func ListCFPointingGroups(ctx context.Context) ([]model.CFPointingGroup, error) {
|
||||||
|
var items []model.CFPointingGroup
|
||||||
|
if err := db.DB(ctx).Order("id desc").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFPointingGroup returns a group by ID.
|
||||||
|
func GetCFPointingGroup(ctx context.Context, id uint) (*model.CFPointingGroup, error) {
|
||||||
|
var item model.CFPointingGroup
|
||||||
|
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCFPointingGroup creates a group.
|
||||||
|
func CreateCFPointingGroup(ctx context.Context, item *model.CFPointingGroup) error {
|
||||||
|
return db.DB(ctx).Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCFPointingGroup persists a group.
|
||||||
|
func SaveCFPointingGroup(ctx context.Context, item *model.CFPointingGroup) error {
|
||||||
|
return db.DB(ctx).Save(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCFPointingGroup deletes an empty group.
|
||||||
|
func DeleteCFPointingGroup(ctx context.Context, id uint) error {
|
||||||
|
return db.DB(ctx).Delete(&model.CFPointingGroup{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountCFPointingMembersByGroupID counts members in a group.
|
||||||
|
func CountCFPointingMembersByGroupID(ctx context.Context, groupID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCFPointingMembersByGroupID lists members by group.
|
||||||
|
func ListCFPointingMembersByGroupID(ctx context.Context, groupID uint) ([]model.CFPointingMember, error) {
|
||||||
|
var items []model.CFPointingMember
|
||||||
|
if err := db.DB(ctx).Where("group_id = ?", groupID).Order("id asc").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCFPointingMembersByActiveNodeID lists members whose group currently targets a node.
|
||||||
|
func ListCFPointingMembersByActiveNodeID(ctx context.Context, nodeID uint) ([]model.CFPointingMember, error) {
|
||||||
|
var items []model.CFPointingMember
|
||||||
|
err := db.DB(ctx).Table("of_cf_pointing_members AS members").
|
||||||
|
Select("members.*").
|
||||||
|
Joins("JOIN of_cf_pointing_groups AS groups ON groups.id = members.group_id").
|
||||||
|
Where("groups.active_node_id = ? AND groups.enabled = ?", nodeID, true).
|
||||||
|
Order("members.id asc").Scan(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFPointingMember returns a member scoped to its group.
|
||||||
|
func GetCFPointingMember(ctx context.Context, groupID, memberID uint) (*model.CFPointingMember, error) {
|
||||||
|
var item model.CFPointingMember
|
||||||
|
if err := db.DB(ctx).Where("id = ? AND group_id = ?", memberID, groupID).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFPointingMemberByID returns a member by ID.
|
||||||
|
func GetCFPointingMemberByID(ctx context.Context, id uint) (*model.CFPointingMember, error) {
|
||||||
|
var item model.CFPointingMember
|
||||||
|
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFPointingMemberByZoneDomainID returns the member managing a ZoneDomain.
|
||||||
|
func GetCFPointingMemberByZoneDomainID(ctx context.Context, zoneDomainID uint) (*model.CFPointingMember, error) {
|
||||||
|
var item model.CFPointingMember
|
||||||
|
if err := db.DB(ctx).Where("zone_domain_id = ?", zoneDomainID).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCFPointingMember creates a member.
|
||||||
|
func CreateCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||||
|
return db.DB(ctx).Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCFPointingMember persists a member.
|
||||||
|
func SaveCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||||
|
return db.DB(ctx).Save(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCFPointingMemberColumns updates selected member fields.
|
||||||
|
func UpdateCFPointingMemberColumns(ctx context.Context, id uint, changes map[string]any) error {
|
||||||
|
return db.DB(ctx).Model(&model.CFPointingMember{}).Where("id = ?", id).Updates(changes).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCFPointingMember deletes a member.
|
||||||
|
func DeleteCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||||
|
return db.DB(ctx).Delete(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailableCFZoneDomains returns ZoneDomains not already managed by Cloudflare pointing.
|
||||||
|
func ListAvailableCFZoneDomains(ctx context.Context) ([]model.ZoneDomain, error) {
|
||||||
|
var items []model.ZoneDomain
|
||||||
|
err := db.DB(ctx).Where(`NOT EXISTS (
|
||||||
|
SELECT 1 FROM of_cf_pointing_members AS members
|
||||||
|
WHERE members.zone_domain_id = of_zone_domains.id
|
||||||
|
)`).Order("domain asc").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCFPointingMemberContext loads one member and all referenced local objects.
|
||||||
|
func GetCFPointingMemberContext(ctx context.Context, memberID uint) (*CFPointingMemberContext, error) {
|
||||||
|
member, err := GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group, err := GetCFPointingGroup(ctx, member.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
domain, err := GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
zone, err := GetZoneByID(ctx, domain.ZoneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node, err := GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CFPointingMemberContext{Member: *member, Group: *group, Domain: *domain, Zone: *zone, Node: *node}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetZoneDomainByID returns a ZoneDomain by primary key.
|
||||||
|
func GetZoneDomainByID(ctx context.Context, id uint) (*model.ZoneDomain, error) {
|
||||||
|
var item model.ZoneDomain
|
||||||
|
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkCFPointingGroupMembersPending resets every member after target changes.
|
||||||
|
func MarkCFPointingGroupMembersPending(ctx context.Context, groupID uint) error {
|
||||||
|
return db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).
|
||||||
|
Updates(map[string]any{"sync_status": model.CFMemberSyncPending, "last_error": ""}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCFPointingGroupAndMembers removes a group after its remote records are deleted.
|
||||||
|
func DeleteCFPointingGroupAndMembers(ctx context.Context, groupID uint) error {
|
||||||
|
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("group_id = ?", groupID).Delete(&model.CFPointingMember{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(&model.CFPointingGroup{}, groupID).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupCloudflareRepositoryDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gorm.Open() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.AutoMigrate(
|
||||||
|
&model.CFConnection{}, &model.CFPointingGroup{}, &model.CFPointingMember{},
|
||||||
|
&model.Zone{}, &model.ZoneDomain{}, &model.OpenFlareNode{}, &model.DNSAccount{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
db.SetDB(conn)
|
||||||
|
t.Cleanup(func() { db.SetDB(nil) })
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertCFConnectionKeepsSingleRow(t *testing.T) {
|
||||||
|
setupCloudflareRepositoryDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := &model.CFConnection{Source: model.CFConnectionSourceStandalone, Authorization: "one"}
|
||||||
|
if err := UpsertCFConnection(ctx, first); err != nil {
|
||||||
|
t.Fatalf("UpsertCFConnection(first) error = %v", err)
|
||||||
|
}
|
||||||
|
accountID := uint(9)
|
||||||
|
second := &model.CFConnection{Source: model.CFConnectionSourceDNSAccount, DNSAccountID: &accountID}
|
||||||
|
if err := UpsertCFConnection(ctx, second); err != nil {
|
||||||
|
t.Fatalf("UpsertCFConnection(second) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := GetCFConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFConnection() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != first.ID || got.Source != model.CFConnectionSourceDNSAccount {
|
||||||
|
t.Errorf("GetCFConnection() = %+v, want same row with dns_account source", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAvailableCFZoneDomainsExcludesMembers(t *testing.T) {
|
||||||
|
conn := setupCloudflareRepositoryDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
zone := model.Zone{Domain: "example.com"}
|
||||||
|
if err := conn.Create(&zone).Error; err != nil {
|
||||||
|
t.Fatalf("Create(zone) error = %v", err)
|
||||||
|
}
|
||||||
|
domains := []model.ZoneDomain{
|
||||||
|
{ZoneID: zone.ID, Domain: "api.example.com"},
|
||||||
|
{ZoneID: zone.ID, Domain: "www.example.com"},
|
||||||
|
}
|
||||||
|
if err := conn.Create(&domains).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domains) error = %v", err)
|
||||||
|
}
|
||||||
|
group := model.CFPointingGroup{Name: "edge", PrimaryNodeID: 1, ActiveNodeID: 1, Enabled: true}
|
||||||
|
if err := CreateCFPointingGroup(ctx, &group); err != nil {
|
||||||
|
t.Fatalf("CreateCFPointingGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
member := model.CFPointingMember{GroupID: group.ID, ZoneDomainID: domains[0].ID}
|
||||||
|
if err := CreateCFPointingMember(ctx, &member); err != nil {
|
||||||
|
t.Fatalf("CreateCFPointingMember() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ListAvailableCFZoneDomains(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAvailableCFZoneDomains() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != domains[1].ID {
|
||||||
|
t.Errorf("ListAvailableCFZoneDomains() = %+v, want only %d", got, domains[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package openflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCloudflareRoutes(apiGroup *gin.RouterGroup) {
|
||||||
|
route := apiGroup.Group("/cloudflare")
|
||||||
|
route.Use(apiutil.AdminMiddlewares()...)
|
||||||
|
route.GET("/connection", cf.GetConnectionHandler)
|
||||||
|
route.PUT("/connection", cf.SaveConnectionHandler)
|
||||||
|
route.POST("/connection/verify", cf.VerifyConnectionHandler)
|
||||||
|
route.POST("/connection/clear", cf.ClearConnectionHandler)
|
||||||
|
route.GET("/overview", cf.OverviewHandler)
|
||||||
|
route.GET("/domains/available", cf.ListAvailableDomainsHandler)
|
||||||
|
groups := route.Group("/groups")
|
||||||
|
apiutil.RegisterCollection(groups, "GET", cf.ListGroupsHandler)
|
||||||
|
apiutil.RegisterCollection(groups, "POST", cf.CreateGroupHandler)
|
||||||
|
route.GET("/groups/:id", cf.GetGroupHandler)
|
||||||
|
route.POST("/groups/:id/update", cf.UpdateGroupHandler)
|
||||||
|
route.POST("/groups/:id/delete", cf.DeleteGroupHandler)
|
||||||
|
route.POST("/groups/:id/sync", cf.SyncGroupHandler)
|
||||||
|
route.GET("/groups/:id/members", cf.ListMembersHandler)
|
||||||
|
route.POST("/groups/:id/members", cf.CreateMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/:memberId/update", cf.UpdateMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/:memberId/remove", cf.RemoveMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup) {
|
|||||||
registerNodeRoutes(group)
|
registerNodeRoutes(group)
|
||||||
registerWAFRoutes(group)
|
registerWAFRoutes(group)
|
||||||
registerTLSRoutes(group)
|
registerTLSRoutes(group)
|
||||||
|
registerCloudflareRoutes(group)
|
||||||
registerZoneRoutes(group)
|
registerZoneRoutes(group)
|
||||||
registerConfigVersionRoutes(group)
|
registerConfigVersionRoutes(group)
|
||||||
registerPagesRoutes(group)
|
registerPagesRoutes(group)
|
||||||
|
|||||||
Reference in New Issue
Block a user