From 3edcdb9e9fcbcaaa302f1a025ade86d6c4135673 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 4 Aug 2026 12:32:37 +0800 Subject: [PATCH] feat(cloudflare): add DNS pointing integration Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation. --- docs/changelog/index.md | 4 + docs/design/architecture.md | 30 +- docs/design/index.md | 1 + docs/docs.go | 1030 +++++++++++++++++ docs/plan/20260804-cloudflare-pointing.md | 151 +++ docs/plan/index.md | 2 + docs/swagger.json | 1030 +++++++++++++++++ docs/swagger.yaml | 608 ++++++++++ .../cloudflare/components/group-dialog.tsx | 166 +++ .../components/member-add-dialog.tsx | 100 ++ .../(main)/cloudflare/groups/[id]/page.tsx | 295 +++++ .../app/(main)/cloudflare/groups/page.tsx | 201 ++++ frontend/app/(main)/cloudflare/page.tsx | 126 ++ .../app/(main)/cloudflare/settings/page.tsx | 257 ++++ frontend/lib/navigation/openflare-nav.ts | 6 + .../services/openflare/cloudflare.service.ts | 105 ++ frontend/lib/services/openflare/index.ts | 19 + frontend/lib/services/openflare/types.ts | 87 ++ .../cloudflare/cloudflare-overview.test.tsx | 52 + .../cloudflare/cloudflare-service.test.ts | 58 + internal/apps/openflare/agent/logics.go | 7 + internal/apps/openflare/cloudflare/client.go | 293 +++++ .../apps/openflare/cloudflare/client_test.go | 90 ++ internal/apps/openflare/cloudflare/errs.go | 22 + internal/apps/openflare/cloudflare/logics.go | 453 ++++++++ .../apps/openflare/cloudflare/reconcile.go | 154 +++ .../openflare/cloudflare/reconcile_test.go | 218 ++++ internal/apps/openflare/cloudflare/routers.go | 383 ++++++ .../apps/openflare/cloudflare/routers_test.go | 43 + internal/apps/openflare/cloudflare/tasks.go | 193 +++ .../apps/openflare/cloudflare/tasks_test.go | 19 + internal/apps/openflare/cloudflare/types.go | 106 ++ .../apps/openflare/credential/sensitive.go | 60 + .../openflare/credential/sensitive_test.go | 73 ++ internal/apps/openflare/node/logics.go | 8 + internal/apps/openflare/node/logics_test.go | 22 + internal/apps/openflare/tls/logics_test.go | 3 +- internal/apps/openflare/tls/sensitive.go | 47 +- ...02608040001_create_cloudflare_pointing.sql | 49 + ...02608040001_create_cloudflare_pointing.sql | 49 + .../persistence/migrator/migrator_test.go | 20 + internal/infra/task/handlers/register.go | 8 + internal/model/openflare_cloudflare.go | 77 ++ internal/repository/openflare_cloudflare.go | 223 ++++ .../repository/openflare_cloudflare_test.go | 86 ++ .../v1/openflare/register_cloudflare.go | 33 + internal/router/v1/openflare/v1.go | 1 + 47 files changed, 7019 insertions(+), 49 deletions(-) create mode 100644 docs/plan/20260804-cloudflare-pointing.md create mode 100644 frontend/app/(main)/cloudflare/components/group-dialog.tsx create mode 100644 frontend/app/(main)/cloudflare/components/member-add-dialog.tsx create mode 100644 frontend/app/(main)/cloudflare/groups/[id]/page.tsx create mode 100644 frontend/app/(main)/cloudflare/groups/page.tsx create mode 100644 frontend/app/(main)/cloudflare/page.tsx create mode 100644 frontend/app/(main)/cloudflare/settings/page.tsx create mode 100644 frontend/lib/services/openflare/cloudflare.service.ts create mode 100644 frontend/tests/cloudflare/cloudflare-overview.test.tsx create mode 100644 frontend/tests/cloudflare/cloudflare-service.test.ts create mode 100644 internal/apps/openflare/cloudflare/client.go create mode 100644 internal/apps/openflare/cloudflare/client_test.go create mode 100644 internal/apps/openflare/cloudflare/errs.go create mode 100644 internal/apps/openflare/cloudflare/logics.go create mode 100644 internal/apps/openflare/cloudflare/reconcile.go create mode 100644 internal/apps/openflare/cloudflare/reconcile_test.go create mode 100644 internal/apps/openflare/cloudflare/routers.go create mode 100644 internal/apps/openflare/cloudflare/routers_test.go create mode 100644 internal/apps/openflare/cloudflare/tasks.go create mode 100644 internal/apps/openflare/cloudflare/tasks_test.go create mode 100644 internal/apps/openflare/cloudflare/types.go create mode 100644 internal/apps/openflare/credential/sensitive.go create mode 100644 internal/apps/openflare/credential/sensitive_test.go create mode 100644 internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql create mode 100644 internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql create mode 100644 internal/model/openflare_cloudflare.go create mode 100644 internal/repository/openflare_cloudflare.go create mode 100644 internal/repository/openflare_cloudflare_test.go create mode 100644 internal/router/v1/openflare/register_cloudflare.go 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 ( + + + + {group ? '编辑指向分组' : '新增指向分组'} + + 一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。 + + + + + 分组名称 + setName(event.target.value)} + /> + + + 主节点 + + + + 备用节点 + + + + + 新成员默认开启橙云 + + + + + 启用同步 + + + + + + + + + + ); +} 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 ( + + + + 添加域名成员 + + 加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。 + + + + + Zone 域名 + + + + 开启橙云代理 + + + + + + + + + + ); +} 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)