feat(cloudflare): add custom targets and node failover (#43)

* feat(cloudflare): add custom targets and node failover

* fix(cloudflare): propagate batch sync errors

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Ryan
2026-10-07 11:29:41 +08:00
committed by GitHub
parent e49e0ad2bf
commit fd0e9acc2f
28 changed files with 1144 additions and 155 deletions
+3
View File
@@ -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。
### 🛠 修复
+86
View File
@@ -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": {
+86
View File
@@ -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": {
+55
View File
@@ -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:
@@ -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({
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldLabel id='cf-target-mode-label'>{t('targetMode')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={targetMode}
aria-labelledby='cf-target-mode-label'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'node' || value === 'custom') {
setTargetMode(value);
}
}}
>
<ToggleGroupItem value='node' className='w-full'>
{t('bindNode')}
</ToggleGroupItem>
<ToggleGroupItem value='custom' className='w-full'>
{t('customTarget')}
</ToggleGroupItem>
</ToggleGroup>
</Field>
{targetMode === 'node' ? (
<>
<Field>
<FieldLabel htmlFor='cf-primary-node'>
{t('primary')}
</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</>
) : (
<>
<Field>
<FieldLabel htmlFor='cf-record-type'>
{t('recordType')}
</FieldLabel>
<Select
value={recordType}
onValueChange={(value: 'CNAME' | 'A' | 'AAAA') =>
setRecordType(value)
}
>
<SelectTrigger id='cf-record-type' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='CNAME'>CNAME</SelectItem>
<SelectItem value='A'>A</SelectItem>
<SelectItem value='AAAA'>AAAA</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-record-content'>
{t('recordContent')}
</FieldLabel>
<Input
id='cf-record-content'
value={recordContent}
placeholder={recordContentPlaceholders[recordType]}
onChange={(event) => setRecordContent(event.target.value)}
/>
<p className='text-sm text-muted-foreground'>
{t('recordContentHint')}
</p>
</Field>
</>
)}
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
@@ -146,13 +233,23 @@ export function GroupDialog({
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
disabled={
pending ||
!name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
}
onClick={() =>
onSubmit({
name: name.trim(),
primary_node_id: Number(primaryNodeID),
target_mode: targetMode,
record_type: recordType,
record_content: recordContent.trim(),
primary_node_id:
targetMode === 'node' ? Number(primaryNodeID) : 0,
backup_node_id:
backupNodeID === 'none' ? null : Number(backupNodeID),
targetMode === 'node' && backupNodeID !== 'none'
? Number(backupNodeID)
: null,
default_proxied: defaultProxied,
enabled,
})
@@ -5,6 +5,7 @@ import {
ArrowLeft,
ArrowRightLeft,
Cloud,
CloudCog,
Loader2,
Plus,
RefreshCw,
@@ -239,6 +240,16 @@ export function CloudflareGroupDetailPageClient() {
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const batchEnableProxyMutation = useMutation({
mutationFn: (memberIds: number[]) =>
CloudflareService.batchEnableProxy(groupID, memberIds),
onSuccess: async (_, memberIds) => {
toast.success(t('batchProxyEnabled', { count: memberIds.length }));
setSelectedMemberIDs(new Set());
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleSingleMove = (member: CloudflareMember) => {
setMovingMembers([member]);
@@ -324,23 +335,39 @@ export function CloudflareGroupDetailPageClient() {
<CardHeader>
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription>
{t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
{group.target_mode === 'custom'
? t('customTargetSummary', {
type: group.record_type,
content: group.record_content,
})
: t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge>
{group.target_mode === 'node' ? (
<>
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</>
) : null}
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
{t(
group.target_mode === 'custom'
? 'customTargetMode'
: 'nodeTargetMode',
)}
</Badge>
</CardContent>
</Card>
@@ -356,6 +383,19 @@ export function CloudflareGroupDetailPageClient() {
<Badge variant='secondary' className='text-xs'>
{t('selectedCount', { count: selectedMembers.length })}
</Badge>
<Button
variant='outline'
size='sm'
onClick={() =>
batchEnableProxyMutation.mutate(
selectedMembers.map((member) => member.id),
)
}
disabled={batchEnableProxyMutation.isPending}
>
<CloudCog data-icon='inline-start' />
{t('batchEnableProxy')}
</Button>
<Button
variant='outline'
size='sm'
@@ -485,27 +525,30 @@ export function CloudflareGroupDetailPageClient() {
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
size='icon'
title={t('move')}
aria-label={t('move')}
onClick={() => handleSingleMove(member)}
>
<ArrowRightLeft data-icon='inline-start' />
{t('move')}
<ArrowRightLeft />
</Button>
<Button
variant='outline'
size='sm'
size='icon'
title={t('sync')}
aria-label={t('sync')}
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
{t('sync')}
<RefreshCw />
</Button>
<Button
variant='destructive'
size='sm'
size='icon'
title={t('remove')}
aria-label={t('remove')}
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
{t('remove')}
<Trash2 />
</Button>
</div>
</TableCell>
+6 -1
View File
@@ -182,7 +182,12 @@ export default function CloudflarePage() {
<div>
<CardTitle className='text-base'>{group.name}</CardTitle>
<CardDescription>
{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}`}
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -133,4 +133,10 @@ export class CloudflareService extends OpenFlareBaseService {
member_ids: memberIds,
});
}
static batchEnableProxy(groupId: number, memberIds: number[]): Promise<void> {
return this.post<void>(`/groups/${groupId}/members/batch-proxy`, {
member_ids: memberIds,
});
}
}
+6
View File
@@ -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;
+23 -7
View File
@@ -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",
@@ -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}",
@@ -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' },
@@ -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' },
+25 -10
View File
@@ -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 {
+4 -1
View File
@@ -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 同步任务"
+211 -20
View File
@@ -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
@@ -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)
+70 -19
View File
@@ -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)
}
@@ -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)
+29 -2
View File
@@ -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
+75 -16
View File
@@ -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
}
@@ -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"`
@@ -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;
@@ -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;
+2
View File
@@ -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{})
+8
View File
@@ -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"`
+20 -3
View File
@@ -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 {
@@ -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)
}