diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index dcc92e5e..ad303a7c 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -22,6 +22,10 @@ sidebar: false
## [unreleased]
+### 新增
+
+- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
+
### 改进
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index 8cea6e51..530aa8fe 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -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) |
| **OpenResty** | 接收真实流量,执行 WAF 过滤、PoW 防护、Basic Auth 认证与静态/反代服务 | [WAF 设计](./waf-design.md) / [Pages 设计](./pages-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` 鉴权。
* 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。
* 统一接收 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 探测任务。
* 启动入口为根目录 `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)*
@@ -154,6 +155,24 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
* **原则**:Agent 只上报事实,Server 解释事实;业务流量唯一真相为访问日志。`openresty_tx` 与「已提供数据」不得双轨并存。
* *传输模型、示例与采集频率详见:[观测数据传输模型](./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)。
+* **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` (部署文件清单).
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **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 双协议灵活切换 |
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
+| Cloudflare 指向独立于 Zone 核心 | ZoneDomain 只提供明确 FQDN;Cloudflare 模块以库表期望状态驱动单 A 记录,不扩大 Zone 为通用 DNS 控制面 |
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
@@ -189,12 +210,13 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
修改系统架构或开发新功能前,请按以下顺序阅读:
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
-3. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
-4. **细分领域设计**:
+2. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
+3. **细分领域设计**:
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
+ * Cloudflare DNS 指向开发:阅读 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
-5. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
+4. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
diff --git a/docs/design/index.md b/docs/design/index.md
index b003caa8..3748c65b 100644
--- a/docs/design/index.md
+++ b/docs/design/index.md
@@ -24,6 +24,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;默认 static 扩展名 + 源站头/Set-Cookie 闸门 + 默认 Edge TTL(对标 CF 默认模型) | [边缘缓存策略设计](./edge-cache-design.md) |
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
+| **Cloudflare DNS 指向** | 以 ZoneDomain 为粒度,将单条 Cloudflare A 记录幂等指向边缘节点 IPv4;支持连接配置、分组、成员橙云与异步同步,一期不含自动故障切换 | [Cloudflare DNS 指向设计](./cloudflare-pointing.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) |
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
diff --git a/docs/docs.go b/docs/docs.go
index 7bc465ec..e740189e 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -5839,6 +5839,817 @@ const docTemplate = `{
}
}
},
+ "/api/v1/d/cloudflare/connection": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "保存 Cloudflare 连接",
+ "parameters": [
+ {
+ "description": "连接参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.ConnectionInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/connection/clear": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "清除 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/connection/verify": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "测试 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/domains/available": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取可加入 Cloudflare 指向的域名",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.AvailableDomain"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "创建 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "description": "分组参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.GroupInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组详情",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupDetail"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/delete": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "删除 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "添加 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "成员参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.MemberCreateInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "移出 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "同步 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.SyncReceipt"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "更新 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "成员参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.MemberUpdateInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/sync": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "同步 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.SyncReceipt"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/update": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "更新 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "分组参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.GroupInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/overview": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向总览",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.Overview"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
"/api/v1/d/config-versions": {
"get": {
"security": [
@@ -14285,6 +15096,225 @@ const docTemplate = `{
}
}
},
+ "cloudflare.AvailableDomain": {
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "zone_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.ConnectionInput": {
+ "type": "object",
+ "properties": {
+ "api_token": {
+ "type": "string"
+ },
+ "dns_account_id": {
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.ConnectionView": {
+ "type": "object",
+ "properties": {
+ "configured": {
+ "type": "boolean"
+ },
+ "dns_account_id": {
+ "type": "integer"
+ },
+ "ready": {
+ "type": "boolean"
+ },
+ "source": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "verified_at": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.GroupDetail": {
+ "type": "object",
+ "properties": {
+ "group": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ },
+ "members": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ },
+ "cloudflare.GroupInput": {
+ "type": "object",
+ "properties": {
+ "backup_node_id": {
+ "type": "integer"
+ },
+ "default_proxied": {
+ "type": "boolean"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "primary_node_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.GroupItem": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.MemberCreateInput": {
+ "type": "object",
+ "properties": {
+ "proxied": {
+ "type": "boolean"
+ },
+ "zone_domain_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.MemberItem": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.MemberUpdateInput": {
+ "type": "object",
+ "properties": {
+ "proxied": {
+ "type": "boolean"
+ }
+ }
+ },
+ "cloudflare.NodeOption": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "ip": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.Overview": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.SyncReceipt": {
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string"
+ }
+ }
+ },
"config_version.CleanupInput": {
"type": "object",
"properties": {
diff --git a/docs/plan/20260804-cloudflare-pointing.md b/docs/plan/20260804-cloudflare-pointing.md
new file mode 100644
index 00000000..33cd5ac7
--- /dev/null
+++ b/docs/plan/20260804-cloudflare-pointing.md
@@ -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` 仍断言页面展示“唯一访问者”,与本功能无关且在本任务基线中已存在。
diff --git a/docs/plan/index.md b/docs/plan/index.md
index 79788151..ded4a728 100644
--- a/docs/plan/index.md
+++ b/docs/plan/index.md
@@ -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。
* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。
diff --git a/docs/swagger.json b/docs/swagger.json
index a5a9567b..bb4bb3c9 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -5832,6 +5832,817 @@
}
}
},
+ "/api/v1/d/cloudflare/connection": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "保存 Cloudflare 连接",
+ "parameters": [
+ {
+ "description": "连接参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.ConnectionInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/connection/clear": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "清除 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/connection/verify": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "测试 Cloudflare 连接",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.ConnectionView"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/domains/available": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取可加入 Cloudflare 指向的域名",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.AvailableDomain"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "创建 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "description": "分组参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.GroupInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组详情",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupDetail"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/delete": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "删除 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向分组成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "添加 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "成员参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.MemberCreateInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "移出 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "同步 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.SyncReceipt"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "更新 Cloudflare 指向成员",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "成员 ID",
+ "name": "memberId",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "成员参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.MemberUpdateInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/sync": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "同步 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.SyncReceipt"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/groups/{id}/update": {
+ "post": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "更新 Cloudflare 指向分组",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "分组 ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "分组参数",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/cloudflare.GroupInput"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/response.Any"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/d/cloudflare/overview": {
+ "get": {
+ "security": [
+ {
+ "SessionCookie": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "openflare-cloudflare"
+ ],
+ "summary": "获取 Cloudflare 指向总览",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/response.Any"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/cloudflare.Overview"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
"/api/v1/d/config-versions": {
"get": {
"security": [
@@ -14278,6 +15089,225 @@
}
}
},
+ "cloudflare.AvailableDomain": {
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "zone_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.ConnectionInput": {
+ "type": "object",
+ "properties": {
+ "api_token": {
+ "type": "string"
+ },
+ "dns_account_id": {
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.ConnectionView": {
+ "type": "object",
+ "properties": {
+ "configured": {
+ "type": "boolean"
+ },
+ "dns_account_id": {
+ "type": "integer"
+ },
+ "ready": {
+ "type": "boolean"
+ },
+ "source": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "verified_at": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.GroupDetail": {
+ "type": "object",
+ "properties": {
+ "group": {
+ "$ref": "#/definitions/cloudflare.GroupItem"
+ },
+ "members": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cloudflare.MemberItem"
+ }
+ }
+ }
+ },
+ "cloudflare.GroupInput": {
+ "type": "object",
+ "properties": {
+ "backup_node_id": {
+ "type": "integer"
+ },
+ "default_proxied": {
+ "type": "boolean"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "primary_node_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.GroupItem": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.MemberCreateInput": {
+ "type": "object",
+ "properties": {
+ "proxied": {
+ "type": "boolean"
+ },
+ "zone_domain_id": {
+ "type": "integer"
+ }
+ }
+ },
+ "cloudflare.MemberItem": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.MemberUpdateInput": {
+ "type": "object",
+ "properties": {
+ "proxied": {
+ "type": "boolean"
+ }
+ }
+ },
+ "cloudflare.NodeOption": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "ip": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "cloudflare.Overview": {
+ "type": "object",
+ "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"
+ }
+ }
+ },
+ "cloudflare.SyncReceipt": {
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string"
+ }
+ }
+ },
"config_version.CleanupInput": {
"type": "object",
"properties": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 0459d214..ce4e3e56 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -295,6 +295,148 @@ definitions:
- solutions
- token
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:
properties:
keep_count:
@@ -7693,6 +7835,472 @@ paths:
summary: 清理配置下发日志
tags:
- 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:
get:
description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限
diff --git a/frontend/app/(main)/cloudflare/components/group-dialog.tsx b/frontend/app/(main)/cloudflare/components/group-dialog.tsx
new file mode 100644
index 00000000..569447bd
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/components/group-dialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx b/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx
new file mode 100644
index 00000000..2f7aadf5
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx
new file mode 100644
index 00000000..19a0efe5
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx
@@ -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 (
+
+
+
+ );
+ if (detailQuery.isError || !detailQuery.data)
+ return (
+
+ void detailQuery.refetch()}
+ />
+
+ );
+ const { group, members } = detailQuery.data;
+
+ return (
+
+
+
+
+
+ {group.name}
+
+
+
+
+
+
+
+
+
+
+
+ 当前指向
+
+ 生效节点 {group.active_node.name} · {group.active_node.ip}
+
+
+
+
+ {group.enabled ? '同步已启用' : '同步已停用'}
+
+ 主节点 {group.primary_node.name}
+
+ 备用节点 {group.backup_node?.name ?? '未设置'}
+
+
+
+
+
+
+ 远端记录所有权
+
+ 移出成员会立即删除本模块缓存或唯一同名 A 记录;存在多条同名 A
+ 时会拒绝操作并要求先手动清理。
+
+
+
+
+
+ 域名成员
+ 成员级橙云是同步时的唯一依据。
+
+
+ {members.length === 0 ? (
+
+ 暂无域名成员。
+
+ ) : (
+
+
+
+ 域名
+ 期望 IP
+ 状态
+ 橙云
+ 操作
+
+
+
+ {members.map((member) => (
+
+
+ {member.domain}
+ {member.last_error ? (
+
+ {member.last_error}
+
+ ) : null}
+
+ {member.desired_ip || '待同步'}
+
+
+ {member.sync_status}
+
+
+
+
+ proxiedMutation.mutate({
+ memberID: member.id,
+ proxied,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
updateGroupMutation.mutate(payload)}
+ />
+
+ addMutation.mutate({ domainID, proxied })
+ }
+ />
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/groups/page.tsx b/frontend/app/(main)/cloudflare/groups/page.tsx
new file mode 100644
index 00000000..f007740b
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/groups/page.tsx
@@ -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(
+ 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 (
+
+
+
+
+
+ Cloudflare 指向分组
+
+
+
+
+
+
+
+
+ {groupsQuery.isLoading ? (
+
+ ) : groupsQuery.isError ? (
+
void groupsQuery.refetch()}
+ />
+ ) : (groupsQuery.data ?? []).length === 0 ? (
+
+
+ 暂无指向分组。
+
+
+ ) : (
+
+ {(groupsQuery.data ?? []).map((group) => (
+
+
+
+
+ {group.name}
+
+ {group.active_node.name} · {group.active_node.ip}
+
+
+
+ {group.enabled ? '已启用' : '已停用'}
+
+
+
+
+
+ 成员 {group.member_count} 个 · 新成员默认
+ {group.default_proxied ? '开启' : '关闭'}橙云
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ createMutation.mutate(payload)}
+ />
+
+ !open && setDeleteTarget(null)}
+ >
+
+
+ 删除指向分组
+
+ 将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A
+ 记录。此操作不可撤销。
+
+
+
+ 取消
+
+ deleteTarget && deleteMutation.mutate(deleteTarget.id)
+ }
+ >
+ 确认删除
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/page.tsx b/frontend/app/(main)/cloudflare/page.tsx
new file mode 100644
index 00000000..0ccd62b7
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/page.tsx
@@ -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 (
+
+
+
+
+
Cloudflare
+
+
+
+
+
+
+
+ {overviewQuery.isLoading ? (
+
+ ) : overviewQuery.isError ? (
+
+
+ void overviewQuery.refetch()}
+ />
+
+
+ ) : !overview?.connection.ready ? (
+
+
+ Cloudflare 连接尚未就绪
+
+
+ 请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
+ 并完成连接测试。
+
+
+
+
+ ) : (
+
+ {[
+ ['指向分组', overview.group_count],
+ ['域名成员', overview.member_count],
+ ['同步正常', overview.ok_count],
+ ['等待同步', overview.pending_count],
+ ['同步错误', overview.error_count],
+ ].map(([label, value]) => (
+
+
+ {label}
+ {value}
+
+
+ ))}
+
+ )}
+
+
+
+ 同步边界
+
+ OpenFlare 数据库是本模块的期望状态来源。
+
+
+
+
+ 同步会覆盖本模块接管的同名 A 记录;如果 Cloudflare 中存在多条同名
+ A,需先手动清理。
+
+ 成员移出或分组删除时,默认同时删除本模块管理的远端 A 记录。
+
+ 一期限制
+
+ 一期不提供自动故障切换,备用节点仅保存配置。
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/settings/page.tsx b/frontend/app/(main)/cloudflare/settings/page.tsx
new file mode 100644
index 00000000..1015b173
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/settings/page.tsx
@@ -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('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 (
+
+
+
+
+
+ Cloudflare 连接设置
+
+
+
+
+
+ {connectionQuery.isError ? (
+
void connectionQuery.refetch()}
+ />
+ ) : null}
+
+
+
+ 凭据来源
+
+ Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
+ 或页面中回显。
+
+
+
+
+
+ 连接来源
+
+
+
+ {source === 'dns_account' ? (
+
+
+ Cloudflare DNS 账号
+
+
+
+ 仅显示类型为 cloudflare 的 DNS 账号。可前往{' '}
+ DNS 账号 新增。
+
+
+ ) : (
+
+ API Token
+ setAPIToken(event.target.value)}
+ placeholder={
+ connectionQuery.data?.configured
+ ? '留空不会回显现有 Token;保存将替换'
+ : '输入 Cloudflare API Token'
+ }
+ />
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前状态
+
+ {connectionQuery.data?.ready
+ ? 'Token 已验证,Cloudflare 指向同步可以执行。'
+ : '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'}
+
+
+
+ );
+}
diff --git a/frontend/lib/navigation/openflare-nav.ts b/frontend/lib/navigation/openflare-nav.ts
index 67cbcd77..1c141bbd 100644
--- a/frontend/lib/navigation/openflare-nav.ts
+++ b/frontend/lib/navigation/openflare-nav.ts
@@ -54,6 +54,11 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
{ title: '域名列表', url: '/websites', childUrls: ['/websites/detail'] },
{ title: 'TLS证书', url: '/certificates' },
{ title: 'DNS账号', url: '/dns-accounts' },
+ {
+ title: 'Cloudflare',
+ url: '/cloudflare',
+ childUrls: ['/cloudflare/settings', '/cloudflare/groups'],
+ },
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
],
};
@@ -109,6 +114,7 @@ export const openflareWebsiteSubNav = [
{ title: '网站列表', url: '/websites' },
{ title: '证书', url: '/certificates' },
{ title: 'DNS 账号', url: '/dns-accounts' },
+ { title: 'Cloudflare', url: '/cloudflare' },
] as const;
const nonConsoleRoutePrefixes = [
diff --git a/frontend/lib/services/openflare/cloudflare.service.ts b/frontend/lib/services/openflare/cloudflare.service.ts
new file mode 100644
index 00000000..29fa7bbb
--- /dev/null
+++ b/frontend/lib/services/openflare/cloudflare.service.ts
@@ -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 {
+ return this.get('/connection');
+ }
+
+ static saveConnection(
+ payload: CloudflareConnectionPayload,
+ ): Promise {
+ return this.put('/connection', payload);
+ }
+
+ static verifyConnection(): Promise {
+ return this.post('/connection/verify');
+ }
+
+ static clearConnection(): Promise {
+ return this.post('/connection/clear');
+ }
+
+ static getOverview(): Promise {
+ return this.get('/overview');
+ }
+
+ static listGroups(): Promise {
+ return this.get('/groups');
+ }
+
+ static getGroup(id: number): Promise {
+ return this.get(`/groups/${id}`);
+ }
+
+ static createGroup(
+ payload: CloudflareGroupPayload,
+ ): Promise {
+ return this.post('/groups', payload);
+ }
+
+ static updateGroup(
+ id: number,
+ payload: CloudflareGroupPayload,
+ ): Promise {
+ return this.post(`/groups/${id}/update`, payload);
+ }
+
+ static deleteGroup(id: number): Promise {
+ return this.post(`/groups/${id}/delete`);
+ }
+
+ static syncGroup(id: number): Promise {
+ return this.post(`/groups/${id}/sync`);
+ }
+
+ static listAvailableDomains(): Promise {
+ return this.get('/domains/available');
+ }
+
+ static createMember(
+ groupId: number,
+ payload: CloudflareMemberCreatePayload,
+ ): Promise {
+ return this.post(`/groups/${groupId}/members`, payload);
+ }
+
+ static updateMember(
+ groupId: number,
+ memberId: number,
+ proxied: boolean,
+ ): Promise {
+ return this.post(
+ `/groups/${groupId}/members/${memberId}/update`,
+ { proxied },
+ );
+ }
+
+ static removeMember(groupId: number, memberId: number): Promise {
+ return this.post(`/groups/${groupId}/members/${memberId}/remove`);
+ }
+
+ static syncMember(
+ groupId: number,
+ memberId: number,
+ ): Promise {
+ return this.post(
+ `/groups/${groupId}/members/${memberId}/sync`,
+ );
+ }
+}
diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts
index 69c89b69..0afde147 100644
--- a/frontend/lib/services/openflare/index.ts
+++ b/frontend/lib/services/openflare/index.ts
@@ -15,6 +15,10 @@ export { AccessLogService } from './access-log.service';
export { OptionService } from './option.service';
export { UptimeKumaService } from './uptimekuma.service';
export { StatusService } from './status.service';
+export {
+ CloudflareService,
+ cloudflareQueryKey,
+} from './cloudflare.service';
export type {
ApplyLogCleanupPayload,
@@ -143,6 +147,19 @@ export type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
+ CloudflareAvailableDomain,
+ CloudflareConnection,
+ CloudflareConnectionPayload,
+ CloudflareConnectionSource,
+ CloudflareGroup,
+ CloudflareGroupDetail,
+ CloudflareGroupPayload,
+ CloudflareMember,
+ CloudflareMemberCreatePayload,
+ CloudflareNodeOption,
+ CloudflareOverview,
+ CloudflareSyncReceipt,
+ CloudflareSyncStatus,
} from './types';
import { AccessLogService } from './access-log.service';
@@ -160,6 +177,7 @@ import { ProxyRouteService } from './proxy-route.service';
import { TlsCertificateService } from './tls-certificate.service';
import { WafService } from './waf.service';
import { ZoneDomainService, ZoneService } from './zone.service';
+import { CloudflareService } from './cloudflare.service';
export const openflareServices = {
node: NodeService,
@@ -178,4 +196,5 @@ export const openflareServices = {
option: OptionService,
uptimeKuma: UptimeKumaService,
status: StatusService,
+ cloudflare: CloudflareService,
} as const;
diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts
index 02178dbc..ab6d5df7 100644
--- a/frontend/lib/services/openflare/types.ts
+++ b/frontend/lib/services/openflare/types.ts
@@ -1347,6 +1347,93 @@ export interface ZoneStats {
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 {
id: number;
name: string;
diff --git a/frontend/tests/cloudflare/cloudflare-overview.test.tsx b/frontend/tests/cloudflare/cloudflare-overview.test.tsx
new file mode 100644
index 00000000..0330b3ab
--- /dev/null
+++ b/frontend/tests/cloudflare/cloudflare-overview.test.tsx
@@ -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();
+ 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();
+ });
+});
diff --git a/frontend/tests/cloudflare/cloudflare-service.test.ts b/frontend/tests/cloudflare/cloudflare-service.test.ts
new file mode 100644
index 00000000..82e26bf7
--- /dev/null
+++ b/frontend/tests/cloudflare/cloudflare-service.test.ts
@@ -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(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,
+ );
+ });
+});
diff --git a/internal/apps/openflare/agent/logics.go b/internal/apps/openflare/agent/logics.go
index 9b7d9474..7967963d 100644
--- a/internal/apps/openflare/agent/logics.go
+++ b/internal/apps/openflare/agent/logics.go
@@ -9,11 +9,13 @@ import (
"strings"
"time"
+ cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
"github.com/Rain-kl/Wavelet/internal/repository"
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/model"
+ "github.com/Rain-kl/Wavelet/pkg/logger"
)
// 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 {
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)
diff --git a/internal/apps/openflare/cloudflare/client.go b/internal/apps/openflare/cloudflare/client.go
new file mode 100644
index 00000000..4190bd7d
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/client.go
@@ -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
+}
diff --git a/internal/apps/openflare/cloudflare/client_test.go b/internal/apps/openflare/cloudflare/client_test.go
new file mode 100644
index 00000000..6a9ce7c4
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/client_test.go
@@ -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)
+ }
+}
diff --git a/internal/apps/openflare/cloudflare/errs.go b/internal/apps/openflare/cloudflare/errs.go
new file mode 100644
index 00000000..13db374b
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/errs.go
@@ -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 同步任务"
+)
diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go
new file mode 100644
index 00000000..cbc6c4ff
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/logics.go
@@ -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
+}
diff --git a/internal/apps/openflare/cloudflare/reconcile.go b/internal/apps/openflare/cloudflare/reconcile.go
new file mode 100644
index 00000000..096f9b6f
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/reconcile.go
@@ -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)
+}
diff --git a/internal/apps/openflare/cloudflare/reconcile_test.go b/internal/apps/openflare/cloudflare/reconcile_test.go
new file mode 100644
index 00000000..50325bd6
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/reconcile_test.go
@@ -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)
+ }
+}
diff --git a/internal/apps/openflare/cloudflare/routers.go b/internal/apps/openflare/cloudflare/routers.go
new file mode 100644
index 00000000..08c59c57
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/routers.go
@@ -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
+}
diff --git a/internal/apps/openflare/cloudflare/routers_test.go b/internal/apps/openflare/cloudflare/routers_test.go
new file mode 100644
index 00000000..b750e8e3
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/routers_test.go
@@ -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())
+ }
+}
diff --git a/internal/apps/openflare/cloudflare/tasks.go b/internal/apps/openflare/cloudflare/tasks.go
new file mode 100644
index 00000000..78560f6c
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/tasks.go
@@ -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
+}
diff --git a/internal/apps/openflare/cloudflare/tasks_test.go b/internal/apps/openflare/cloudflare/tasks_test.go
new file mode 100644
index 00000000..de07b0d5
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/tasks_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/apps/openflare/cloudflare/types.go b/internal/apps/openflare/cloudflare/types.go
new file mode 100644
index 00000000..c5c8df05
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/types.go
@@ -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"`
+}
diff --git a/internal/apps/openflare/credential/sensitive.go b/internal/apps/openflare/credential/sensitive.go
new file mode 100644
index 00000000..e7917123
--- /dev/null
+++ b/internal/apps/openflare/credential/sensitive.go
@@ -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))
+}
diff --git a/internal/apps/openflare/credential/sensitive_test.go b/internal/apps/openflare/credential/sensitive_test.go
new file mode 100644
index 00000000..3a38a55f
--- /dev/null
+++ b/internal/apps/openflare/credential/sensitive_test.go
@@ -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")
+ }
+}
diff --git a/internal/apps/openflare/node/logics.go b/internal/apps/openflare/node/logics.go
index 0cdb28d9..b5b4468b 100644
--- a/internal/apps/openflare/node/logics.go
+++ b/internal/apps/openflare/node/logics.go
@@ -10,10 +10,12 @@ import (
"strings"
"time"
+ cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
+ "github.com/Rain-kl/Wavelet/pkg/logger"
"gorm.io/gorm"
)
@@ -232,6 +234,7 @@ func UpdateNode(ctx context.Context, id uint, input Input) (*View, error) {
return nil, err
}
ipManualOverride := resolveNodeIPManualOverride(input, node, ip)
+ previousIP := node.IP
node.Name = name
node.IP = ip
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 {
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
}
diff --git a/internal/apps/openflare/node/logics_test.go b/internal/apps/openflare/node/logics_test.go
index b19ba4a0..57107ccd 100644
--- a/internal/apps/openflare/node/logics_test.go
+++ b/internal/apps/openflare/node/logics_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
@@ -130,6 +131,27 @@ func TestUpdateNode(t *testing.T) {
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) {
cleanup := setupNodeTestDB(t)
defer cleanup()
diff --git a/internal/apps/openflare/tls/logics_test.go b/internal/apps/openflare/tls/logics_test.go
index 31cdf5b7..e5db86e2 100644
--- a/internal/apps/openflare/tls/logics_test.go
+++ b/internal/apps/openflare/tls/logics_test.go
@@ -16,6 +16,7 @@ import (
"testing"
"time"
+ "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/infra/config"
@@ -128,7 +129,7 @@ func TestCreateCertificateEncryptsPrivateKey(t *testing.T) {
stored, err := repository.GetTLSCertificateByID(ctx, certificate.ID)
require.NoError(t, err)
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)
require.NoError(t, err)
diff --git a/internal/apps/openflare/tls/sensitive.go b/internal/apps/openflare/tls/sensitive.go
index 611d62ed..acd90a0a 100644
--- a/internal/apps/openflare/tls/sensitive.go
+++ b/internal/apps/openflare/tls/sensitive.go
@@ -3,40 +3,10 @@
package tls
-import (
- "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[:])
-}
+import "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
func sealSensitive(plaintext string) (string, error) {
- plaintext = strings.TrimSpace(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
+ return credential.Seal(plaintext)
}
// 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) {
- stored = strings.TrimSpace(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))
+ return credential.Open(stored)
}
diff --git a/internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql b/internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql
new file mode 100644
index 00000000..e7bff812
--- /dev/null
+++ b/internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql
@@ -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;
diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql b/internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql
new file mode 100644
index 00000000..4013ef8a
--- /dev/null
+++ b/internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql
@@ -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;
diff --git a/internal/infra/persistence/migrator/migrator_test.go b/internal/infra/persistence/migrator/migrator_test.go
index 302b7942..d58392a2 100644
--- a/internal/infra/persistence/migrator/migrator_test.go
+++ b/internal/infra/persistence/migrator/migrator_test.go
@@ -84,6 +84,15 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
if !sqliteDB.Migrator().HasTable("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") {
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 {
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) {
diff --git a/internal/infra/task/handlers/register.go b/internal/infra/task/handlers/register.go
index bda54486..cfcc2c69 100644
--- a/internal/infra/task/handlers/register.go
+++ b/internal/infra/task/handlers/register.go
@@ -8,6 +8,7 @@ package handlers
import (
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"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/tls"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
@@ -52,6 +53,13 @@ func Register() {
task.RegisterHandler(openflare.UptimeKumaSyncTask, &openflare.UptimeKumaSyncHandler{})
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.
task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{})
task.RegisterTaskMeta(pages.PagesSourceScanMeta)
diff --git a/internal/model/openflare_cloudflare.go b/internal/model/openflare_cloudflare.go
new file mode 100644
index 00000000..2eac9e9b
--- /dev/null
+++ b/internal/model/openflare_cloudflare.go
@@ -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" }
diff --git a/internal/repository/openflare_cloudflare.go b/internal/repository/openflare_cloudflare.go
new file mode 100644
index 00000000..7158b7d6
--- /dev/null
+++ b/internal/repository/openflare_cloudflare.go
@@ -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
+ })
+}
diff --git a/internal/repository/openflare_cloudflare_test.go b/internal/repository/openflare_cloudflare_test.go
new file mode 100644
index 00000000..4d4102f9
--- /dev/null
+++ b/internal/repository/openflare_cloudflare_test.go
@@ -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)
+ }
+}
diff --git a/internal/router/v1/openflare/register_cloudflare.go b/internal/router/v1/openflare/register_cloudflare.go
new file mode 100644
index 00000000..8cf64b6c
--- /dev/null
+++ b/internal/router/v1/openflare/register_cloudflare.go
@@ -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)
+}
diff --git a/internal/router/v1/openflare/v1.go b/internal/router/v1/openflare/v1.go
index 128b6727..1b516196 100644
--- a/internal/router/v1/openflare/v1.go
+++ b/internal/router/v1/openflare/v1.go
@@ -18,6 +18,7 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup) {
registerNodeRoutes(group)
registerWAFRoutes(group)
registerTLSRoutes(group)
+ registerCloudflareRoutes(group)
registerZoneRoutes(group)
registerConfigVersionRoutes(group)
registerPagesRoutes(group)