diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4f7f1ea7..8b1c847a 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -14,6 +14,9 @@ sidebar: false - OpenResty 默认压缩 CSS、JavaScript、JSON、XML、RSS、Atom 和 SVG 等文本响应,并发送 `Vary: Accept-Encoding`;普通代理请求默认先缓冲请求体再转发,升级时旧值 `false` 会改为 `true`,流式上传场景可手动关闭。 ### ✨ 新功能 + +- Cloudflare 节点分组会根据节点在线和应用状态自动切换到备用节点,并在主节点恢复后自动切回;分组详情支持批量开启橙云,成员操作按钮改为仅显示图标。 +- Cloudflare 指向分组新增自定义目标模式,可按分组将域名同步为 CNAME、A 或 AAAA 记录;原有节点绑定模式保持默认。 - 代理路由详情支持单独关闭 HTTP/2,默认仍启用;关闭后该路由生成的 HTTPS 配置不再启用 HTTP/2。 ### 🛠 修复 diff --git a/docs/docs.go b/docs/docs.go index bdbd6bec..693d38fa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -6419,6 +6419,63 @@ const docTemplate = `{ } } }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": { + "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.MemberBatchProxyInput" + } + } + ], + "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/batch-remove": { "post": { "security": [ @@ -15285,6 +15342,15 @@ const docTemplate = `{ }, "primary_node_id": { "type": "integer" + }, + "record_content": { + "type": "string" + }, + "record_type": { + "type": "string" + }, + "target_mode": { + "type": "string" } } }, @@ -15318,6 +15384,15 @@ const docTemplate = `{ "primary_node": { "$ref": "#/definitions/cloudflare.NodeOption" }, + "record_content": { + "type": "string" + }, + "record_type": { + "type": "string" + }, + "target_mode": { + "type": "string" + }, "updated_at": { "type": "string" } @@ -15337,6 +15412,17 @@ const docTemplate = `{ } } }, + "cloudflare.MemberBatchProxyInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "cloudflare.MemberBatchRemoveInput": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index ce901abb..36c583c0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6412,6 +6412,63 @@ } } }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": { + "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.MemberBatchProxyInput" + } + } + ], + "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/batch-remove": { "post": { "security": [ @@ -15278,6 +15335,15 @@ }, "primary_node_id": { "type": "integer" + }, + "record_content": { + "type": "string" + }, + "record_type": { + "type": "string" + }, + "target_mode": { + "type": "string" } } }, @@ -15311,6 +15377,15 @@ "primary_node": { "$ref": "#/definitions/cloudflare.NodeOption" }, + "record_content": { + "type": "string" + }, + "record_type": { + "type": "string" + }, + "target_mode": { + "type": "string" + }, "updated_at": { "type": "string" } @@ -15330,6 +15405,17 @@ } } }, + "cloudflare.MemberBatchProxyInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "cloudflare.MemberBatchRemoveInput": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c240bfc2..f32d2aeb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -330,6 +330,12 @@ definitions: type: string primary_node_id: type: integer + record_content: + type: string + record_type: + type: string + target_mode: + type: string type: object cloudflare.GroupItem: properties: @@ -351,6 +357,12 @@ definitions: type: string primary_node: $ref: '#/definitions/cloudflare.NodeOption' + record_content: + type: string + record_type: + type: string + target_mode: + type: string updated_at: type: string type: object @@ -363,6 +375,13 @@ definitions: target_group_id: type: integer type: object + cloudflare.MemberBatchProxyInput: + properties: + member_ids: + items: + type: integer + type: array + type: object cloudflare.MemberBatchRemoveInput: properties: member_ids: @@ -8335,6 +8354,42 @@ paths: summary: 批量移动 Cloudflare 指向成员 tags: - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/batch-proxy: + 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.MemberBatchProxyInput' + 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/batch-remove: post: consumes: diff --git a/frontend/app/(main)/cloudflare/components/group-dialog.tsx b/frontend/app/(main)/cloudflare/components/group-dialog.tsx index 5f87df60..92c7a424 100644 --- a/frontend/app/(main)/cloudflare/components/group-dialog.tsx +++ b/frontend/app/(main)/cloudflare/components/group-dialog.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import type { CloudflareGroup, CloudflareGroupPayload, @@ -50,7 +51,15 @@ export function GroupDialog({ () => nodes.filter((node) => node.node_type === 'edge_node'), [nodes], ); + const recordContentPlaceholders = { + CNAME: t('recordContentPlaceholder.CNAME'), + A: t('recordContentPlaceholder.A'), + AAAA: t('recordContentPlaceholder.AAAA'), + }; const [name, setName] = useState(''); + const [targetMode, setTargetMode] = useState<'node' | 'custom'>('node'); + const [recordType, setRecordType] = useState<'CNAME' | 'A' | 'AAAA'>('A'); + const [recordContent, setRecordContent] = useState(''); const [primaryNodeID, setPrimaryNodeID] = useState(''); const [backupNodeID, setBackupNodeID] = useState('none'); const [defaultProxied, setDefaultProxied] = useState(true); @@ -59,8 +68,19 @@ export function GroupDialog({ useEffect(() => { if (!open) return; setName(group?.name ?? ''); - setPrimaryNodeID(group ? String(group.primary_node.id) : ''); - setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none'); + setTargetMode(group?.target_mode === 'custom' ? 'custom' : 'node'); + setRecordType(group?.record_type ?? 'A'); + setRecordContent(group?.record_content ?? ''); + setPrimaryNodeID( + group?.target_mode !== 'custom' && group + ? String(group.primary_node.id) + : '', + ); + setBackupNodeID( + group?.target_mode !== 'custom' && group?.backup_node + ? String(group.backup_node.id) + : 'none', + ); setDefaultProxied(group?.default_proxied ?? true); setEnabled(group?.enabled ?? true); }, [group, open]); @@ -82,46 +102,113 @@ export function GroupDialog({ /> - {t('primary')} - - - - {t('backup')} - + {t('targetMode')} + { + if (value === 'node' || value === 'custom') { + setTargetMode(value); + } + }} + > + + {t('bindNode')} + + + {t('customTarget')} + + + {targetMode === 'node' ? ( + <> + + + {t('primary')} + + + + + {t('backup')} + + + + ) : ( + <> + + + {t('recordType')} + + + + + + {t('recordContent')} + + setRecordContent(event.target.value)} + /> +

+ {t('recordContentHint')} +

+
+ + )} {t('defaultProxied')} @@ -146,13 +233,23 @@ export function GroupDialog({ {tCommon('cancel')} diff --git a/frontend/app/(main)/cloudflare/page.tsx b/frontend/app/(main)/cloudflare/page.tsx index 166080a1..1cc9abe7 100644 --- a/frontend/app/(main)/cloudflare/page.tsx +++ b/frontend/app/(main)/cloudflare/page.tsx @@ -182,7 +182,12 @@ export default function CloudflarePage() {
{group.name} - {group.active_node.name} · {group.active_node.ip} + {group.target_mode === 'custom' + ? t('customTargetSummary', { + type: group.record_type, + content: group.record_content, + }) + : `${group.active_node.name} · ${group.active_node.ip}`}
diff --git a/frontend/lib/services/openflare/cloudflare.service.ts b/frontend/lib/services/openflare/cloudflare.service.ts index 910baa05..eba0bd9f 100644 --- a/frontend/lib/services/openflare/cloudflare.service.ts +++ b/frontend/lib/services/openflare/cloudflare.service.ts @@ -133,4 +133,10 @@ export class CloudflareService extends OpenFlareBaseService { member_ids: memberIds, }); } + + static batchEnableProxy(groupId: number, memberIds: number[]): Promise { + return this.post(`/groups/${groupId}/members/batch-proxy`, { + member_ids: memberIds, + }); + } } diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index a141ff8c..48d72045 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -1369,6 +1369,9 @@ export interface CloudflareNodeOption { export interface CloudflareGroup { id: number; name: string; + target_mode: 'node' | 'custom'; + record_type: 'CNAME' | 'A' | 'AAAA'; + record_content: string; primary_node: CloudflareNodeOption; backup_node: CloudflareNodeOption | null; active_node: CloudflareNodeOption; @@ -1381,6 +1384,9 @@ export interface CloudflareGroup { export interface CloudflareGroupPayload { name: string; + target_mode: 'node' | 'custom'; + record_type: 'CNAME' | 'A' | 'AAAA'; + record_content: string; primary_node_id: number; backup_node_id: number | null; default_proxied: boolean; diff --git a/frontend/messages/fragments/security.en.json b/frontend/messages/fragments/security.en.json index bebdd246..ecd3b1a4 100644 --- a/frontend/messages/fragments/security.en.json +++ b/frontend/messages/fragments/security.en.json @@ -772,10 +772,10 @@ "notReadyDesc": "Import an existing Cloudflare DNS account, or enter a standalone API token and test the connection.", "configure": "Configure connection", "groupsTitle": "Pointing groups", - "groupsDesc": "Manage the nodes and domain members behind Cloudflare A records.", + "groupsDesc": "Manage Cloudflare DNS targets and domain members.", "loadingGroups": "Loading pointing groups...", "emptyTitle": "No pointing groups", - "emptyDesc": "After creating a group, you can sync domain members to selected edge nodes.", + "emptyDesc": "Create a group to bind domain members to an edge node or a custom DNS target.", "enabled": "Enabled", "disabled": "Disabled", "memberSummary": "{count} members · new members default orange-cloud {proxy}", @@ -788,7 +788,7 @@ "syncQueued": "Group sync queued", "deleted": "Pointing group and remote records deleted", "deleteTitle": "Delete pointing group", - "deleteDesc": "This deletes all members of {name} and the remote A records managed by this module. This cannot be undone.", + "deleteDesc": "This deletes all members of {name} and the remote DNS records managed by this module. This cannot be undone.", "confirmDelete": "Delete", "loadingDetail": "Loading group details...", "groupUpdated": "Group settings updated", @@ -798,13 +798,18 @@ "memberPartial": "Partial success: {succeeded} added, {failed} failed", "proxyUpdated": "Orange-cloud setting updated and queued for sync", "memberSyncQueued": "Member sync queued", - "memberDeleted": "Member and remote A record deleted", + "memberDeleted": "Member and remote DNS record deleted", + "batchEnableProxy": "Enable orange cloud", + "batchProxyEnabled": "Enabled orange cloud for {count} members and queued sync", "back": "Back to list", "refresh": "Refresh", "edit": "Edit", "addDomain": "Add domain", "currentPoint": "Current target", "activeNode": "Active node {name} · {ip}", + "customTargetSummary": "Custom target {type} · {content}", + "nodeTargetMode": "Node binding", + "customTargetMode": "Custom record", "syncEnabled": "Sync enabled", "syncDisabled": "Sync disabled", "primaryNode": "Primary {name}", @@ -815,7 +820,7 @@ "noMembers": "No domain members yet.", "columns": { "domain": "Domain", - "desiredIp": "Desired IP", + "desiredIp": "Desired value", "status": "Status", "proxied": "Orange cloud", "actions": "Actions" @@ -825,8 +830,19 @@ "groupDialog": { "editTitle": "Edit pointing group", "createTitle": "New pointing group", - "description": "Phase 1 uses the primary node as the active node. Backup nodes are saved only and do not fail over automatically.", + "description": "Bind members to an edge node or assign a custom DNS record target.", "name": "Group name", + "targetMode": "Target mode", + "bindNode": "Bind node", + "customTarget": "Custom", + "recordType": "Record type", + "recordContent": "Record value", + "recordContentHint": "Enter an IPv4 for A, IPv6 for AAAA, or a target hostname for CNAME.", + "recordContentPlaceholder": { + "CNAME": "e.g. origin.example.com", + "A": "e.g. 203.0.113.10", + "AAAA": "e.g. 2001:db8::10" + }, "primary": "Primary node", "primaryPlaceholder": "Select an edge node with IPv4", "noIp": "No IP configured", @@ -838,7 +854,7 @@ }, "memberDialog": { "title": "Add domain members", - "description": "Search and select in bulk. Joining creates or takes over the unique same-name A record. Multiple same-name A records are rejected.", + "description": "Search and select in bulk. Joining creates or takes over the unique same-name DNS record used by this group.", "zoneDomain": "Zone domain", "searchPlaceholder": "Search domains or zones…", "selected": "{count} selected", diff --git a/frontend/messages/fragments/security.zh-CN.json b/frontend/messages/fragments/security.zh-CN.json index b5dad040..4dace12b 100644 --- a/frontend/messages/fragments/security.zh-CN.json +++ b/frontend/messages/fragments/security.zh-CN.json @@ -772,10 +772,10 @@ "notReadyDesc": "请先导入现有 Cloudflare DNS 账号,或录入独立 API Token 并完成连接测试。", "configure": "配置连接", "groupsTitle": "指向分组", - "groupsDesc": "管理 Cloudflare A 记录对应的节点与域名成员。", + "groupsDesc": "管理 Cloudflare DNS 记录的指向方式与域名成员。", "loadingGroups": "加载指向分组中...", "emptyTitle": "暂无指向分组", - "emptyDesc": "创建分组后,可将域名成员同步到指定的边缘节点。", + "emptyDesc": "创建分组后,可将域名成员绑定到边缘节点,或同步到自定义 DNS 目标。", "enabled": "已启用", "disabled": "已停用", "memberSummary": "成员 {count} 个 · 新成员默认{proxy}橙云", @@ -788,7 +788,7 @@ "syncQueued": "分组同步任务已入队", "deleted": "指向分组及远端记录已删除", "deleteTitle": "删除指向分组", - "deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 A 记录。此操作不可撤销。", + "deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 DNS 记录。此操作不可撤销。", "confirmDelete": "确认删除", "loadingDetail": "加载分组详情中...", "groupUpdated": "分组配置已更新", @@ -798,13 +798,18 @@ "memberPartial": "部分成功:{succeeded} 个已加入,{failed} 个失败", "proxyUpdated": "橙云设置已更新并排队同步", "memberSyncQueued": "成员同步任务已入队", - "memberDeleted": "成员及远端 A 记录已删除", + "memberDeleted": "成员及远端 DNS 记录已删除", + "batchEnableProxy": "批量开启橙云", + "batchProxyEnabled": "已为 {count} 个成员开启橙云并排队同步", "back": "返回列表", "refresh": "刷新", "edit": "编辑", "addDomain": "添加域名", "currentPoint": "当前指向", "activeNode": "生效节点 {name} · {ip}", + "customTargetSummary": "自定义目标 {type} · {content}", + "nodeTargetMode": "绑定节点", + "customTargetMode": "自定义记录", "syncEnabled": "同步已启用", "syncDisabled": "同步已停用", "primaryNode": "主节点 {name}", @@ -815,7 +820,7 @@ "noMembers": "暂无域名成员。", "columns": { "domain": "域名", - "desiredIp": "期望 IP", + "desiredIp": "期望值", "status": "状态", "proxied": "橙云", "actions": "操作" @@ -825,8 +830,19 @@ "groupDialog": { "editTitle": "编辑指向分组", "createTitle": "新增指向分组", - "description": "一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。", + "description": "选择绑定边缘节点,或为成员统一指定自定义 DNS 记录目标。", "name": "分组名称", + "targetMode": "指向方式", + "bindNode": "绑定节点", + "customTarget": "自定义", + "recordType": "记录类型", + "recordContent": "记录值", + "recordContentHint": "A 填 IPv4,AAAA 填 IPv6,CNAME 填目标域名。", + "recordContentPlaceholder": { + "CNAME": "例如:origin.example.com", + "A": "例如:203.0.113.10", + "AAAA": "例如:2001:db8::10" + }, "primary": "主节点", "primaryPlaceholder": "选择带 IPv4 的边缘节点", "noIp": "未配置 IP", @@ -838,7 +854,7 @@ }, "memberDialog": { "title": "添加域名成员", - "description": "支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名 A 会拒绝同步。", + "description": "支持搜索筛选与批量勾选;加入后会创建或接管当前分组指向所用的唯一同名 DNS 记录。", "zoneDomain": "Zone 域名", "searchPlaceholder": "搜索域名或顶级域…", "selected": "已选 {count}", diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx index 4004ab33..d702341e 100644 --- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx +++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx @@ -57,6 +57,9 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => { const mockGroup = { id: 7, name: '生产节点', + target_mode: 'node' as const, + record_type: 'A' as const, + record_content: '', primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, backup_node: null, active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, @@ -70,6 +73,9 @@ const mockGroup = { const mockTargetGroup = { id: 8, name: '备用分组', + target_mode: 'node' as const, + record_type: 'A' as const, + record_content: '', primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, backup_node: null, active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, diff --git a/frontend/tests/cloudflare/cloudflare-overview.test.tsx b/frontend/tests/cloudflare/cloudflare-overview.test.tsx index 8248603a..098eb1da 100644 --- a/frontend/tests/cloudflare/cloudflare-overview.test.tsx +++ b/frontend/tests/cloudflare/cloudflare-overview.test.tsx @@ -109,6 +109,9 @@ describe('Cloudflare overview', () => { { id: 7, name: '生产节点', + target_mode: 'node', + record_type: 'A', + record_content: '', primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, backup_node: null, active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, diff --git a/internal/apps/openflare/cloudflare/client.go b/internal/apps/openflare/cloudflare/client.go index 0d141cc1..3ae6e8e4 100644 --- a/internal/apps/openflare/cloudflare/client.go +++ b/internal/apps/openflare/cloudflare/client.go @@ -59,9 +59,9 @@ 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) + ListRecords(context.Context, string, string, string) ([]DNSRecord, error) + CreateRecord(context.Context, string, RecordInput) (*DNSRecord, error) + UpdateRecord(context.Context, string, string, RecordInput) (*DNSRecord, error) DeleteRecord(context.Context, string, string) error } @@ -146,9 +146,9 @@ func (client *HTTPClient) GetRecord(ctx context.Context, zoneID, recordID string 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"}} +// ListRecords lists exact-name DNS records of a type. +func (client *HTTPClient) ListRecords(ctx context.Context, zoneID, name, recordType string) ([]DNSRecord, error) { + query := url.Values{"type": {strings.TrimSpace(recordType)}, "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 { @@ -157,8 +157,8 @@ func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) return records, nil } -// CreateARecord creates an A record. -func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) { +// CreateRecord creates a DNS record. +func (client *HTTPClient) CreateRecord(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 { @@ -167,8 +167,8 @@ func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, inpu return &record, nil } -// UpdateARecord replaces an A record. -func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) { +// UpdateRecord replaces a DNS record. +func (client *HTTPClient) UpdateRecord(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 { @@ -177,6 +177,21 @@ func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID st return &record, nil } +// ListARecords lists exact-name A records. +func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) ([]DNSRecord, error) { + return client.ListRecords(ctx, zoneID, name, "A") +} + +// CreateARecord creates an A record. +func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) { + return client.CreateRecord(ctx, zoneID, input) +} + +// UpdateARecord replaces an A record. +func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) { + return client.UpdateRecord(ctx, zoneID, recordID, input) +} + // DeleteRecord deletes a DNS record. func (client *HTTPClient) DeleteRecord(ctx context.Context, zoneID, recordID string) error { var result struct { diff --git a/internal/apps/openflare/cloudflare/errs.go b/internal/apps/openflare/cloudflare/errs.go index 0a3fb85e..a15a6bbf 100644 --- a/internal/apps/openflare/cloudflare/errs.go +++ b/internal/apps/openflare/cloudflare/errs.go @@ -11,11 +11,14 @@ const ( errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号" errGroupNameRequired = "分组名称不能为空" errGroupNodeSame = "主节点和备用节点不能相同" + errGroupTargetModeInvalid = "请选择有效的指向方式" + errRecordTypeInvalid = "请选择 CNAME、A 或 AAAA 记录类型" + errRecordContentInvalid = "DNS 记录值无效,请检查域名或 IP 地址" errNodeInvalid = "请选择有效的边缘节点" errNodeIPv4Required = "生效节点必须配置合法 IPv4" errGroupDisabled = "指向分组已停用" errMemberExists = "该域名已加入其他指向分组" - errMultipleARecords = "检测到 Cloudflare 中存在多条同名 A 记录,请先手动清理" + errMultipleDNSRecords = "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理" errSyncFailed = "Cloudflare DNS 同步失败" errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败" errTaskDispatchFailed = "无法投递 Cloudflare 同步任务" diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go index d68403f1..90112da3 100644 --- a/internal/apps/openflare/cloudflare/logics.go +++ b/internal/apps/openflare/cloudflare/logics.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "net" + "regexp" "strings" "time" @@ -219,30 +220,110 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input if name == "" { return nil, errors.New(errGroupNameRequired) } - if input.BackupNodeID != nil && *input.BackupNodeID == input.PrimaryNodeID { - return nil, errors.New(errGroupNodeSame) + targetMode := strings.TrimSpace(input.TargetMode) + if targetMode == "" { + targetMode = model.CFPointingTargetModeNode } - primary, err := validEdgeNode(ctx, input.PrimaryNodeID, true) - if err != nil { - return nil, err + if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom { + return nil, errors.New(errGroupTargetModeInvalid) } - if input.BackupNodeID != nil { - if _, err = validEdgeNode(ctx, *input.BackupNodeID, false); err != nil { + primaryNodeID := uint(0) + var backupNodeID *uint + recordType := strings.ToUpper(strings.TrimSpace(input.RecordType)) + if recordType == "" { + recordType = "A" + } + recordContent := strings.TrimSpace(input.RecordContent) + if targetMode == model.CFPointingTargetModeCustom && !validCustomRecordType(recordType) { + return nil, errors.New(errRecordTypeInvalid) + } + if targetMode == model.CFPointingTargetModeCustom && !validCustomRecordContent(recordType, recordContent) { + return nil, errors.New(errRecordContentInvalid) + } + if targetMode == model.CFPointingTargetModeNode { + resolvedPrimary, resolvedBackup, err := resolveGroupNodes(ctx, input) + if err != nil { return nil, err } + primaryNodeID = resolvedPrimary + backupNodeID = resolvedBackup + } + activeNodeID := primaryNodeID + if existing != nil && targetMode == model.CFPointingTargetModeNode && existing.TargetMode == targetMode && existing.PrimaryNodeID == primaryNodeID && equalOptionalUint(existing.BackupNodeID, backupNodeID) { + activeNodeID = existing.ActiveNodeID } if existing == nil { existing = &model.CFPointingGroup{} } existing.Name = name - existing.PrimaryNodeID = primary.ID - existing.ActiveNodeID = primary.ID - existing.BackupNodeID = input.BackupNodeID + existing.TargetMode = targetMode + existing.RecordType = recordType + existing.RecordContent = recordContent + existing.PrimaryNodeID = primaryNodeID + existing.ActiveNodeID = activeNodeID + existing.BackupNodeID = backupNodeID existing.DefaultProxied = input.DefaultProxied existing.Enabled = input.Enabled return existing, nil } +func equalOptionalUint(left, right *uint) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func resolveGroupNodes(ctx context.Context, input GroupInput) (uint, *uint, error) { + if input.BackupNodeID != nil && *input.BackupNodeID == input.PrimaryNodeID { + return 0, nil, errors.New(errGroupNodeSame) + } + primary, err := validEdgeNode(ctx, input.PrimaryNodeID, true) + if err != nil { + return 0, nil, err + } + if input.BackupNodeID != nil { + if _, err = validEdgeNode(ctx, *input.BackupNodeID, false); err != nil { + return 0, nil, err + } + } + return primary.ID, input.BackupNodeID, nil +} + +func validCustomRecordType(recordType string) bool { + return recordType == "CNAME" || recordType == "A" || recordType == "AAAA" +} + +func validCustomRecordContent(recordType, content string) bool { + switch recordType { + case "A": + return isIPv4(content) + case "AAAA": + return isIPv6(content) + case "CNAME": + return validCNAMEContent(content) + default: + return false + } +} + +var cnameHostPattern = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$`) + +func isIPv4(content string) bool { + ip := net.ParseIP(content) + return ip != nil && ip.To4() != nil +} + +func isIPv6(content string) bool { + ip := net.ParseIP(content) + return ip != nil && ip.To4() == nil +} + +func validCNAMEContent(content string) bool { + host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(content)), ".") + return len(host) > 0 && len(host) <= 253 && net.ParseIP(host) == nil && cnameHostPattern.MatchString(host) +} + 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" { @@ -254,20 +335,90 @@ func validEdgeNode(ctx context.Context, id uint, requireIPv4 bool) (*model.OpenF return node, nil } -func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupItem, error) { - primary, err := lookupGroupNode(ctx, group.ID, group.PrimaryNodeID) +// CheckNodeFailover switches enabled node groups to healthy backup nodes and fails back after primary recovery. +func CheckNodeFailover(ctx context.Context) (int, error) { + groups, err := repository.ListCFPointingGroups(ctx) if err != nil { - return nil, err + return 0, err } - active, err := lookupGroupNode(ctx, group.ID, group.ActiveNodeID) - if err != nil { - return nil, err + now := time.Now() + changed := 0 + var dispatchErrors []error + for i := range groups { + group := &groups[i] + if !group.Enabled || group.TargetMode == model.CFPointingTargetModeCustom || group.BackupNodeID == nil { + continue + } + primary, primaryErr := repository.GetOpenFlareNodeByID(ctx, group.PrimaryNodeID) + if primaryErr != nil && !errors.Is(primaryErr, gorm.ErrRecordNotFound) { + return changed, primaryErr + } + primaryHealthy := primaryErr == nil && isNodeHealthyForFailover(primary, now) + targetNodeID := group.ActiveNodeID + if primaryHealthy { + targetNodeID = group.PrimaryNodeID + } else { + backup, backupErr := repository.GetOpenFlareNodeByID(ctx, *group.BackupNodeID) + if backupErr != nil && !errors.Is(backupErr, gorm.ErrRecordNotFound) { + return changed, backupErr + } + if backupErr == nil && isNodeHealthyForFailover(backup, now) { + targetNodeID = *group.BackupNodeID + } + } + if targetNodeID != group.ActiveNodeID { + if err = repository.UpdateCFPointingGroupTarget(ctx, group.ID, map[string]any{"active_node_id": targetNodeID}); err != nil { + return changed, err + } + if err = repository.MarkCFPointingGroupMembersPending(ctx, group.ID); err != nil { + return changed, err + } + changed++ + } + pending, pendingErr := repository.HasPendingCFPointingGroupMembers(ctx, group.ID) + if pendingErr != nil { + return changed, pendingErr + } + if pending { + if _, err = DispatchGroupSync(ctx, group.ID, "cloudflare_node_failover"); err != nil { + logger.WarnF(ctx, "[Cloudflare] dispatch failover sync failed: group_id=%d error=%v", group.ID, err) + dispatchErrors = append(dispatchErrors, err) + } + } + } + return changed, errors.Join(dispatchErrors...) +} + +func isNodeHealthyForFailover(node *model.OpenFlareNode, now time.Time) bool { + if node == nil || node.Status == "offline" || node.OpenrestyStatus == "unhealthy" || strings.TrimSpace(node.LastError) != "" { + return false + } + return node.LastSeenAt != nil && now.Sub(*node.LastSeenAt) <= 60*time.Second +} + +func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupItem, error) { + targetMode := group.TargetMode + if targetMode == "" { + targetMode = model.CFPointingTargetModeNode + } + primary := (*model.OpenFlareNode)(nil) + active := (*model.OpenFlareNode)(nil) + var err error + if targetMode == model.CFPointingTargetModeNode { + primary, err = lookupGroupNode(ctx, group.ID, group.PrimaryNodeID) + if err != nil { + return nil, err + } + active, err = lookupGroupNode(ctx, group.ID, 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: nodeOptionForID(group.PrimaryNodeID, primary), ActiveNode: nodeOptionForID(group.ActiveNodeID, active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt} + item := &GroupItem{ID: group.ID, Name: group.Name, TargetMode: targetMode, RecordType: group.RecordType, RecordContent: group.RecordContent, PrimaryNode: nodeOptionForID(group.PrimaryNodeID, primary), ActiveNode: nodeOptionForID(group.ActiveNodeID, active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt} if group.BackupNodeID != nil { backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID) if backupErr != nil { @@ -375,7 +526,7 @@ func UpdateMember(ctx context.Context, groupID, memberID uint, input MemberUpdat return memberItem(member, domain), nil } -// RemoveMember deletes the managed remote A record before removing local state. +// RemoveMember deletes the managed remote DNS record before removing local state. func RemoveMember(ctx context.Context, groupID, memberID uint) error { member, err := repository.GetCFPointingMember(ctx, groupID, memberID) if err != nil { @@ -457,7 +608,7 @@ func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatch return nil } -// BatchRemoveMembers deletes multiple members and their remote A records. +// BatchRemoveMembers deletes multiple members and their remote DNS records. func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error { if len(input.MemberIDs) == 0 { return errors.New(errNoMembersSelected) @@ -477,6 +628,46 @@ func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBat return nil } +// BatchEnableProxy enables orange-cloud proxy for selected members. +func BatchEnableProxy(ctx context.Context, groupID uint, input MemberBatchProxyInput) error { + if len(input.MemberIDs) == 0 { + return errors.New(errNoMembersSelected) + } + group, err := repository.GetCFPointingGroup(ctx, groupID) + if err != nil { + return err + } + var firstErr error + for _, memberID := range uniqueIDs(input.MemberIDs) { + member, getErr := repository.GetCFPointingMember(ctx, groupID, memberID) + if getErr != nil { + if firstErr == nil { + firstErr = getErr + } + continue + } + member.Proxied = true + member.SyncStatus = model.CFMemberSyncPending + member.LastError = "" + if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil { + logger.ErrorF(ctx, "[Cloudflare] batch enable proxy save member failed: member_id=%d error=%v", memberID, saveErr) + if firstErr == nil { + firstErr = saveErr + } + continue + } + if group.Enabled { + if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_batch_proxy"); syncErr != nil { + logger.WarnF(ctx, "[Cloudflare] dispatch batch enable proxy sync failed: member_id=%d error=%v", member.ID, syncErr) + if firstErr == nil { + firstErr = syncErr + } + } + } + } + return firstErr +} + func uniqueIDs(ids []uint) []uint { if len(ids) == 0 { return ids @@ -495,7 +686,7 @@ func uniqueIDs(ids []uint) []uint { return result } -// DeleteGroup removes every managed remote A record and then local state. +// DeleteGroup removes every managed remote DNS record and then local state. func DeleteGroup(ctx context.Context, groupID uint) error { if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil { return err diff --git a/internal/apps/openflare/cloudflare/logics_test.go b/internal/apps/openflare/cloudflare/logics_test.go index 93fbb1d5..85b3d354 100644 --- a/internal/apps/openflare/cloudflare/logics_test.go +++ b/internal/apps/openflare/cloudflare/logics_test.go @@ -5,13 +5,153 @@ package cloudflare import ( "context" + "errors" "testing" + "time" db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" ) +func TestCheckNodeFailoverContinuesAfterDispatchError(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) + } + + lastSeen := time.Now() + backup := model.OpenFlareNode{Name: "backup", NodeID: "node-backup", NodeType: "edge_node", IP: "203.0.113.11", Status: "online", OpenrestyStatus: "healthy", LastSeenAt: &lastSeen} + if err := db.DB(ctx).Create(&backup).Error; err != nil { + t.Fatalf("Create(backup) error = %v", err) + } + if err := db.DB(ctx).Model(group).Update("backup_node_id", backup.ID).Error; err != nil { + t.Fatalf("Update(group backup) error = %v", err) + } + + zoneDomain := model.ZoneDomain{ZoneID: 1, Domain: "second.example.com"} + if err := db.DB(ctx).Create(&zoneDomain).Error; err != nil { + t.Fatalf("Create(zoneDomain) error = %v", err) + } + secondGroup := model.CFPointingGroup{Name: "secondary", PrimaryNodeID: group.PrimaryNodeID, BackupNodeID: &backup.ID, ActiveNodeID: group.PrimaryNodeID, Enabled: true} + if err := db.DB(ctx).Create(&secondGroup).Error; err != nil { + t.Fatalf("Create(secondGroup) error = %v", err) + } + secondMember := model.CFPointingMember{GroupID: secondGroup.ID, ZoneDomainID: zoneDomain.ID, SyncStatus: model.CFMemberSyncPending} + if err := db.DB(ctx).Create(&secondMember).Error; err != nil { + t.Fatalf("Create(secondMember) error = %v", err) + } + + dispatchErr := errors.New("dispatch unavailable") + dispatchCalls := 0 + restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { + dispatchCalls++ + if dispatchCalls == 1 { + return "", dispatchErr + } + return "task-id", nil + }) + t.Cleanup(restoreDispatch) + + changed, err := CheckNodeFailover(ctx) + if !errors.Is(err, dispatchErr) { + t.Fatalf("CheckNodeFailover() error = %v, want dispatch error", err) + } + if changed != 2 { + t.Errorf("CheckNodeFailover() changed = %d, want 2", changed) + } + if dispatchCalls != 2 { + t.Errorf("CheckNodeFailover() dispatch calls = %d, want 2", dispatchCalls) + } + for _, groupID := range []uint{group.ID, secondGroup.ID} { + updated, err := repository.GetCFPointingGroup(ctx, groupID) + if err != nil { + t.Fatalf("GetCFPointingGroup(%d) error = %v", groupID, err) + } + if updated.ActiveNodeID != backup.ID { + t.Errorf("group %d active node = %d, want %d", groupID, updated.ActiveNodeID, backup.ID) + } + } +} + +func TestBatchEnableProxyContinuesAfterDispatchError(t *testing.T) { + ctx, firstMemberID := setupCloudflareLogicDB(t) + firstMember, err := repository.GetCFPointingMemberByID(ctx, firstMemberID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + zoneDomain := model.ZoneDomain{ZoneID: 1, Domain: "second.example.com"} + if err := db.DB(ctx).Create(&zoneDomain).Error; err != nil { + t.Fatalf("Create(zoneDomain) error = %v", err) + } + secondMember := model.CFPointingMember{GroupID: firstMember.GroupID, ZoneDomainID: zoneDomain.ID} + if err := db.DB(ctx).Create(&secondMember).Error; err != nil { + t.Fatalf("Create(secondMember) error = %v", err) + } + + dispatchErr := errors.New("dispatch unavailable") + dispatchCalls := 0 + restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { + dispatchCalls++ + if dispatchCalls == 1 { + return "", dispatchErr + } + return "task-id", nil + }) + t.Cleanup(restoreDispatch) + + err = BatchEnableProxy(ctx, firstMember.GroupID, MemberBatchProxyInput{MemberIDs: []uint{firstMember.ID, secondMember.ID}}) + if !errors.Is(err, dispatchErr) { + t.Fatalf("BatchEnableProxy() error = %v, want dispatch error", err) + } + if dispatchCalls != 2 { + t.Errorf("BatchEnableProxy() dispatch calls = %d, want 2", dispatchCalls) + } + for _, memberID := range []uint{firstMember.ID, secondMember.ID} { + updated, err := repository.GetCFPointingMemberByID(ctx, memberID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID(%d) error = %v", memberID, err) + } + if !updated.Proxied { + t.Errorf("member %d proxied = false, want true", memberID) + } + } +} + +func TestBatchEnableProxyContinuesAfterMemberLookupError(t *testing.T) { + ctx, memberID := setupCloudflareLogicDB(t) + member, err := repository.GetCFPointingMemberByID(ctx, memberID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + dispatchCalls := 0 + restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { + dispatchCalls++ + return "task-id", nil + }) + t.Cleanup(restoreDispatch) + + err = BatchEnableProxy(ctx, member.GroupID, MemberBatchProxyInput{MemberIDs: []uint{99999, member.ID}}) + if err == nil { + t.Fatal("BatchEnableProxy() error = nil, want member lookup error") + } + if dispatchCalls != 1 { + t.Errorf("BatchEnableProxy() dispatch calls = %d, want 1", dispatchCalls) + } + updated, err := repository.GetCFPointingMemberByID(ctx, member.ID) + if err != nil { + t.Fatalf("GetCFPointingMemberByID() error = %v", err) + } + if !updated.Proxied { + t.Errorf("member %d proxied = false, want true", member.ID) + } +} + func TestMoveMemberAndBatchOperations(t *testing.T) { ctx, member1ID := setupCloudflareLogicDB(t) diff --git a/internal/apps/openflare/cloudflare/reconcile.go b/internal/apps/openflare/cloudflare/reconcile.go index 5f0a331e..60793037 100644 --- a/internal/apps/openflare/cloudflare/reconcile.go +++ b/internal/apps/openflare/cloudflare/reconcile.go @@ -23,9 +23,11 @@ const ( memberSyncStatusColumn = "sync_status" ) +var errCachedRecordUnavailable = errors.New("cached Cloudflare record unavailable") + var memberLocks [memberLockStripeCount]sync.Mutex -// ReconcileMember makes one Cloudflare A record match the local desired state. +// ReconcileMember makes one Cloudflare DNS record match the local desired state. func ReconcileMember(ctx context.Context, memberID uint) error { lock := &memberLocks[memberID%memberLockStripeCount] lock.Lock() @@ -51,9 +53,9 @@ func reconcileMember(ctx context.Context, memberID uint) error { if !state.Group.Enabled { return errors.New(errGroupDisabled) } - ip := strings.TrimSpace(state.Node.IP) - if net.ParseIP(ip).To4() == nil { - return errors.New(errNodeIPv4Required) + input, err := desiredRecordInput(state) + if err != nil { + return err } connection, err := repository.GetCFConnection(ctx) if err != nil || connection.Status != model.CFConnectionStatusReady { @@ -72,37 +74,79 @@ func reconcileMember(ctx context.Context, memberID uint) error { } zoneID = zone.ID } - input := RecordInput{Type: "A", Name: state.Domain.Domain, Content: ip, Proxied: state.Member.Proxied, TTL: 300} + input.Name = state.Domain.Domain + input.Proxied = state.Member.Proxied + input.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) + completed, cachedErr := reconcileCachedRecord(ctx, client, memberID, zoneID, recordID, input) + if errors.Is(cachedErr, errCachedRecordUnavailable) { + cachedErr = nil + } + if cachedErr != nil { + return cachedErr + } + if completed { + return nil } } - records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain) + records, err := client.ListRecords(ctx, zoneID, state.Domain.Domain, input.Type) if err != nil { return err } var record *DNSRecord switch len(records) { case 0: - record, err = client.CreateARecord(ctx, zoneID, input) + record, err = client.CreateRecord(ctx, zoneID, input) case 1: - record, err = client.UpdateARecord(ctx, zoneID, records[0].ID, input) + record, err = client.UpdateRecord(ctx, zoneID, records[0].ID, input) default: - return errors.New(errMultipleARecords) + return errors.New(errMultipleDNSRecords) } if err != nil { return err } - return markMemberSynced(ctx, memberID, zoneID, record.ID, ip) + return markMemberSynced(ctx, memberID, zoneID, record.ID, input.Content) +} + +func reconcileCachedRecord( + ctx context.Context, + client Client, + memberID uint, + zoneID, recordID string, + input RecordInput, +) (bool, error) { + current, err := client.GetRecord(ctx, zoneID, recordID) + if err != nil { + return false, errors.Join(errCachedRecordUnavailable, err) + } + if current.Type != input.Type { + return false, client.DeleteRecord(ctx, zoneID, recordID) + } + record, err := client.UpdateRecord(ctx, zoneID, recordID, input) + if err != nil { + return false, err + } + return true, markMemberSynced(ctx, memberID, zoneID, record.ID, input.Content) +} + +func desiredRecordInput(state *repository.CFPointingMemberContext) (RecordInput, error) { + if state.Group.TargetMode == model.CFPointingTargetModeCustom { + recordType := strings.ToUpper(strings.TrimSpace(state.Group.RecordType)) + content := strings.TrimSpace(state.Group.RecordContent) + if !validCustomRecordType(recordType) || !validCustomRecordContent(recordType, content) { + return RecordInput{}, errors.New(errRecordContentInvalid) + } + return RecordInput{Type: recordType, Content: content}, nil + } + ip := strings.TrimSpace(state.Node.IP) + if net.ParseIP(ip).To4() == nil { + return RecordInput{}, errors.New(errNodeIPv4Required) + } + return RecordInput{Type: "A", Content: ip}, nil } func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip string) error { @@ -113,7 +157,7 @@ func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip s }) } -// DeleteManagedRecord deletes the cached or uniquely discoverable A record. +// DeleteManagedRecord deletes the cached or uniquely discoverable managed record. func DeleteManagedRecord(ctx context.Context, memberID uint) error { state, err := repository.GetCFPointingMemberContext(ctx, memberID) if err != nil { @@ -144,7 +188,14 @@ func DeleteManagedRecord(ctx context.Context, memberID uint) error { return nil } } - records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain) + recordType := "A" + if state.Group.TargetMode == model.CFPointingTargetModeCustom { + recordType = state.Group.RecordType + if recordType == "" { + recordType = "A" + } + } + records, err := client.ListRecords(ctx, zoneID, state.Domain.Domain, recordType) if err != nil { return err } @@ -152,7 +203,7 @@ func DeleteManagedRecord(ctx context.Context, memberID uint) error { return nil } if len(records) > 1 { - return errors.New(errMultipleARecords) + return errors.New(errMultipleDNSRecords) } 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 index 50325bd6..9d33c695 100644 --- a/internal/apps/openflare/cloudflare/reconcile_test.go +++ b/internal/apps/openflare/cloudflare/reconcile_test.go @@ -31,16 +31,22 @@ func (client *fakeClient) FindZone(context.Context, string) (*Zone, error) { 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) ListRecords(_ context.Context, _, _ string, recordType string) ([]DNSRecord, error) { + items := make([]DNSRecord, 0, len(client.records)) + for _, record := range client.records { + if record.Type == "" || record.Type == recordType { + items = append(items, record) + } + } + return items, nil } -func (client *fakeClient) CreateARecord(_ context.Context, _ string, input RecordInput) (*DNSRecord, error) { +func (client *fakeClient) CreateRecord(_ 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 + return &DNSRecord{ID: "record-created", Type: input.Type, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil } -func (client *fakeClient) UpdateARecord(_ context.Context, _, id string, input RecordInput) (*DNSRecord, error) { +func (client *fakeClient) UpdateRecord(_ 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 + return &DNSRecord{ID: id, Type: input.Type, 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) diff --git a/internal/apps/openflare/cloudflare/routers.go b/internal/apps/openflare/cloudflare/routers.go index 617a0599..c5150aae 100644 --- a/internal/apps/openflare/cloudflare/routers.go +++ b/internal/apps/openflare/cloudflare/routers.go @@ -198,7 +198,7 @@ func UpdateGroupHandler(c *gin.Context) { c.JSON(http.StatusOK, response.OK(item)) } -// DeleteGroupHandler deletes a pointing group and its managed remote A records. +// DeleteGroupHandler deletes a pointing group and its managed remote DNS records. // @Summary 删除 Cloudflare 指向分组 // @Tags openflare-cloudflare // @Produce json @@ -316,7 +316,7 @@ func UpdateMemberHandler(c *gin.Context) { c.JSON(http.StatusOK, response.OK(item)) } -// RemoveMemberHandler removes a member and its managed remote A record. +// RemoveMemberHandler removes a member and its managed remote DNS record. // @Summary 移出 Cloudflare 指向成员 // @Tags openflare-cloudflare // @Produce json @@ -440,6 +440,33 @@ func BatchRemoveMembersHandler(c *gin.Context) { c.JSON(http.StatusOK, response.OKNil()) } +// BatchEnableProxyHandler enables orange-cloud proxy for multiple members. +// @Summary 批量开启 Cloudflare 橙云 +// @Tags openflare-cloudflare +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "分组 ID" +// @Param body body cloudflare.MemberBatchProxyInput true "批量开启橙云参数" +// @Success 200 {object} response.Any +// @Failure 400 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-proxy [post] +func BatchEnableProxyHandler(c *gin.Context) { + id, ok := apiutil.IDParam(c) + if !ok { + return + } + var input MemberBatchProxyInput + if !apiutil.BindJSON(c, &input) { + return + } + if abortLogic(c, BatchEnableProxy(c.Request.Context(), id, input)) { + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + // ListAvailableDomainsHandler lists ZoneDomains not assigned to another group. // @Summary 获取可加入 Cloudflare 指向的域名 // @Tags openflare-cloudflare diff --git a/internal/apps/openflare/cloudflare/tasks.go b/internal/apps/openflare/cloudflare/tasks.go index 828b79d4..cc6bd6a8 100644 --- a/internal/apps/openflare/cloudflare/tasks.go +++ b/internal/apps/openflare/cloudflare/tasks.go @@ -25,6 +25,8 @@ const ( SyncGroupTask = "cloudflare:sync_group" // SyncByNodeTask is the Asynq task type for members targeting one node. SyncByNodeTask = "cloudflare:sync_by_node" + // FailoverCheckTask checks node availability and updates active Cloudflare targets. + FailoverCheckTask = "cloudflare:failover_check" // TaskTypeSyncMember is the task metadata type for member synchronization. TaskTypeSyncMember = "of_cloudflare_sync_member" @@ -32,14 +34,29 @@ const ( TaskTypeSyncGroup = "of_cloudflare_sync_group" // TaskTypeSyncByNode is the task metadata type for node-triggered synchronization. TaskTypeSyncByNode = "of_cloudflare_sync_by_node" + // TaskTypeFailoverCheck is the schedule metadata type for node failover checks. + TaskTypeFailoverCheck = "of_cloudflare_failover_check" ) +// FailoverCheckMeta describes the scheduled node failover reconciliation. +var FailoverCheckMeta = task.TaskMeta{ + Type: TaskTypeFailoverCheck, + AsynqTask: FailoverCheckTask, + Name: "Cloudflare 节点故障回退检查", + Description: "根据节点在线和应用状态切换分组主备节点", + SupportsTime: false, + MaxRetry: 2, + Queue: task.QueueDefault, + Retryable: true, + InternalOnly: true, +} + // SyncMemberMeta describes one-member reconciliation (admin-dispatchable). var SyncMemberMeta = task.TaskMeta{ Type: TaskTypeSyncMember, AsynqTask: SyncMemberTask, Name: "Cloudflare 域名同步", - Description: "同步单个域名的 Cloudflare A 记录", + Description: "同步单个域名的 Cloudflare DNS 记录", SupportsTime: false, MaxRetry: 3, Queue: task.QueueDefault, @@ -169,14 +186,17 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt if loadErr != nil { task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr) } else { + target := fmt.Sprintf("%s(%s)", state.Node.Name, strings.TrimSpace(state.Node.IP)) + if state.Group.TargetMode == model.CFPointingTargetModeCustom { + target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent) + } task.AppendLog(ctx, - "开始域名同步: domain=%s zone=%s group=%s(#%d) node=%s(%s) proxied=%v member_id=%d", + "开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d", state.Domain.Domain, state.Zone.Domain, state.Group.Name, state.Group.ID, - state.Node.Name, - strings.TrimSpace(state.Node.IP), + target, state.Member.Proxied, input.MemberID, ) @@ -194,12 +214,15 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt message := "Cloudflare 域名同步成功" if state != nil { - ip := strings.TrimSpace(state.Node.IP) + target := strings.TrimSpace(state.Node.IP) + if state.Group.TargetMode == model.CFPointingTargetModeCustom { + target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent) + } message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)", - state.Domain.Domain, ip, state.Member.Proxied) + state.Domain.Domain, target, state.Member.Proxied) task.AppendLog(ctx, - "域名同步成功: domain=%s desired_ip=%s proxied=%v group=%s node=%s", - state.Domain.Domain, ip, state.Member.Proxied, state.Group.Name, state.Node.Name, + "域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s", + state.Domain.Domain, target, state.Member.Proxied, state.Group.Name, state.Group.TargetMode, ) } else { task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID) @@ -242,14 +265,9 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr) } else { scopeName = group.Name - if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID); nodeErr != nil { - task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v", - group.Name, group.ActiveNodeID, nodeErr) - } else { - activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP)) - } + activeNode = groupSyncTarget(ctx, group) task.AppendLog(ctx, - "准备分组同步: group=%s id=%d enabled=%v active_node=%s default_proxied=%v", + "准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v", group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied, ) } @@ -258,6 +276,19 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode) } +func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string { + if group.TargetMode == model.CFPointingTargetModeCustom { + return fmt.Sprintf("%s %s", group.RecordType, group.RecordContent) + } + node, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID) + if err != nil { + task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v", + group.Name, group.ActiveNodeID, err) + return "" + } + return fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP)) +} + // SyncByNodeTaskHandler reconciles every member targeting a node. type SyncByNodeTaskHandler struct{} @@ -310,7 +341,7 @@ func executeBatchSync( return nil, listErr } - task.AppendLog(ctx, "开始%s同步: name=%s id=%d active_node=%s 域名数=%d", + task.AppendLog(ctx, "开始%s同步: name=%s id=%d target=%s 域名数=%d", scope, scopeName, scopeID, activeNode, len(members)) if len(members) == 0 { message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName) @@ -364,3 +395,31 @@ func decodePayload(payload []byte, target any) error { } return nil } + +// FailoverCheckTaskHandler checks enabled node groups for failover and recovery. +type FailoverCheckTaskHandler struct{} + +// ValidatePayload validates the empty scheduled-task payload. +func (handler *FailoverCheckTaskHandler) ValidatePayload(payload []byte) ([]byte, error) { + var input struct{} + if err := decodePayload(payload, &input); err != nil { + return nil, fmt.Errorf("无效的 Cloudflare 故障回退检查参数: %w", err) + } + return json.Marshal(input) +} + +// Execute switches affected groups and queues DNS reconciliation. +func (handler *FailoverCheckTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + if _, err := handler.ValidatePayload(payload); err != nil { + return nil, task.PermanentError(err.Error()) + } + task.AppendLog(ctx, "开始检查 Cloudflare 节点分组主备状态") + changed, err := CheckNodeFailover(ctx) + if err != nil { + task.AppendLog(ctx, "Cloudflare 节点故障回退检查失败: error=%v", err) + return nil, err + } + message := fmt.Sprintf("Cloudflare 节点故障回退检查完成,切换分组 %d 个", changed) + task.AppendLog(ctx, "%s", message) + return &task.TaskResult{Message: message}, nil +} diff --git a/internal/apps/openflare/cloudflare/types.go b/internal/apps/openflare/cloudflare/types.go index 088b3836..1275c141 100644 --- a/internal/apps/openflare/cloudflare/types.go +++ b/internal/apps/openflare/cloudflare/types.go @@ -32,6 +32,9 @@ type NodeOption struct { // GroupInput creates or updates a pointing group. type GroupInput struct { Name string `json:"name"` + TargetMode string `json:"target_mode"` + RecordType string `json:"record_type"` + RecordContent string `json:"record_content"` PrimaryNodeID uint `json:"primary_node_id"` BackupNodeID *uint `json:"backup_node_id"` DefaultProxied bool `json:"default_proxied"` @@ -42,6 +45,9 @@ type GroupInput struct { type GroupItem struct { ID uint `json:"id"` Name string `json:"name"` + TargetMode string `json:"target_mode"` + RecordType string `json:"record_type"` + RecordContent string `json:"record_content"` PrimaryNode NodeOption `json:"primary_node"` BackupNode *NodeOption `json:"backup_node"` ActiveNode NodeOption `json:"active_node"` @@ -79,6 +85,11 @@ type MemberBatchRemoveInput struct { MemberIDs []uint `json:"member_ids"` } +// MemberBatchProxyInput contains the members to enable orange-cloud proxy for. +type MemberBatchProxyInput struct { + MemberIDs []uint `json:"member_ids"` +} + // MemberItem is the admin-facing member state. type MemberItem struct { ID uint `json:"id"` diff --git a/internal/infra/persistence/migrator/goose/postgres/202610070002_add_cloudflare_custom_target.sql b/internal/infra/persistence/migrator/goose/postgres/202610070002_add_cloudflare_custom_target.sql new file mode 100644 index 00000000..5d398d62 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/postgres/202610070002_add_cloudflare_custom_target.sql @@ -0,0 +1,16 @@ +-- +goose Up +ALTER TABLE of_cf_pointing_groups + ADD COLUMN target_mode VARCHAR(16) NOT NULL DEFAULT 'node', + ADD COLUMN record_type VARCHAR(8) NOT NULL DEFAULT 'A', + ADD COLUMN record_content VARCHAR(255) NOT NULL DEFAULT ''; + +INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (105, 'Cloudflare 节点故障回退检查', 'of_cloudflare_failover_check', '* * * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; + +-- +goose Down +DELETE FROM w_schedules WHERE id = 105; +ALTER TABLE of_cf_pointing_groups + DROP COLUMN record_content, + DROP COLUMN record_type, + DROP COLUMN target_mode; diff --git a/internal/infra/persistence/migrator/goose/sqlite/202610070002_add_cloudflare_custom_target.sql b/internal/infra/persistence/migrator/goose/sqlite/202610070002_add_cloudflare_custom_target.sql new file mode 100644 index 00000000..c01a367a --- /dev/null +++ b/internal/infra/persistence/migrator/goose/sqlite/202610070002_add_cloudflare_custom_target.sql @@ -0,0 +1,14 @@ +-- +goose Up +ALTER TABLE of_cf_pointing_groups ADD COLUMN target_mode VARCHAR(16) NOT NULL DEFAULT 'node'; +ALTER TABLE of_cf_pointing_groups ADD COLUMN record_type VARCHAR(8) NOT NULL DEFAULT 'A'; +ALTER TABLE of_cf_pointing_groups ADD COLUMN record_content VARCHAR(255) NOT NULL DEFAULT ''; + +INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (105, 'Cloudflare 节点故障回退检查', 'of_cloudflare_failover_check', '* * * * *', '{}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; + +-- +goose Down +DELETE FROM w_schedules WHERE id = 105; +ALTER TABLE of_cf_pointing_groups DROP COLUMN record_content; +ALTER TABLE of_cf_pointing_groups DROP COLUMN record_type; +ALTER TABLE of_cf_pointing_groups DROP COLUMN target_mode; diff --git a/internal/infra/task/handlers/register.go b/internal/infra/task/handlers/register.go index 2da26e4c..c1fc20f1 100644 --- a/internal/infra/task/handlers/register.go +++ b/internal/infra/task/handlers/register.go @@ -60,6 +60,8 @@ func Register() { task.RegisterTaskMeta(cf.SyncGroupMeta) task.RegisterHandler(cf.SyncByNodeTask, &cf.SyncByNodeTaskHandler{}) task.RegisterTaskMeta(cf.SyncByNodeMeta) + task.RegisterHandler(cf.FailoverCheckTask, &cf.FailoverCheckTaskHandler{}) + task.RegisterTaskMeta(cf.FailoverCheckMeta) // pages source actions are only dispatched by the Pages domain API/scanner. task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{}) diff --git a/internal/model/openflare_cloudflare.go b/internal/model/openflare_cloudflare.go index 2eac9e9b..6ece79ba 100644 --- a/internal/model/openflare_cloudflare.go +++ b/internal/model/openflare_cloudflare.go @@ -24,6 +24,11 @@ const ( CFMemberSyncOK = "ok" // CFMemberSyncError indicates the latest reconciliation failed. CFMemberSyncError = "error" + + // CFPointingTargetModeNode binds group members to a managed edge node. + CFPointingTargetModeNode = "node" + // CFPointingTargetModeCustom binds group members to a custom DNS target. + CFPointingTargetModeCustom = "custom" ) // CFConnection stores the single Cloudflare API credential source. @@ -45,6 +50,9 @@ func (CFConnection) TableName() string { return "of_cf_connections" } type CFPointingGroup struct { ID uint `json:"id" gorm:"primaryKey;autoIncrement"` Name string `json:"name" gorm:"size:128;not null"` + TargetMode string `json:"target_mode" gorm:"size:16;not null;default:'node'"` + RecordType string `json:"record_type" gorm:"size:8;not null;default:'A'"` + RecordContent string `json:"record_content" gorm:"size:255;not null;default:''"` 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"` diff --git a/internal/repository/openflare_cloudflare.go b/internal/repository/openflare_cloudflare.go index 0f28b117..41238056 100644 --- a/internal/repository/openflare_cloudflare.go +++ b/internal/repository/openflare_cloudflare.go @@ -64,6 +64,11 @@ func ListCFPointingGroups(ctx context.Context) ([]model.CFPointingGroup, error) return items, nil } +// UpdateCFPointingGroupTarget updates the active node or custom-record target. +func UpdateCFPointingGroupTarget(ctx context.Context, groupID uint, changes map[string]any) error { + return db.DB(ctx).Model(&model.CFPointingGroup{}).Where("id = ?", groupID).Updates(changes).Error +} + // GetCFPointingGroup returns a group by ID. func GetCFPointingGroup(ctx context.Context, id uint) (*model.CFPointingGroup, error) { var item model.CFPointingGroup @@ -192,9 +197,12 @@ func GetCFPointingMemberContext(ctx context.Context, memberID uint) (*CFPointing if err != nil { return nil, err } - node, err := GetOpenFlareNodeByID(ctx, group.ActiveNodeID) - if err != nil { - return nil, err + node := &model.OpenFlareNode{} + if group.TargetMode != model.CFPointingTargetModeCustom { + 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 } @@ -214,6 +222,15 @@ func MarkCFPointingGroupMembersPending(ctx context.Context, groupID uint) error Updates(map[string]any{"sync_status": model.CFMemberSyncPending, "last_error": ""}).Error } +// HasPendingCFPointingGroupMembers reports whether a group has members awaiting reconciliation. +func HasPendingCFPointingGroupMembers(ctx context.Context, groupID uint) (bool, error) { + var count int64 + err := db.DB(ctx).Model(&model.CFPointingMember{}). + Where("group_id = ? AND sync_status = ?", groupID, model.CFMemberSyncPending). + Count(&count).Error + return count > 0, err +} + // 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 { diff --git a/internal/router/v1/openflare/register_cloudflare.go b/internal/router/v1/openflare/register_cloudflare.go index 2d3aab0b..f7f9a7ad 100644 --- a/internal/router/v1/openflare/register_cloudflare.go +++ b/internal/router/v1/openflare/register_cloudflare.go @@ -33,4 +33,5 @@ func registerCloudflareRoutes(apiGroup *gin.RouterGroup) { route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler) route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler) route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler) + route.POST("/groups/:id/members/batch-proxy", cf.BatchEnableProxyHandler) }