mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 同步任务"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+16
@@ -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;
|
||||
+14
@@ -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;
|
||||
@@ -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{})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user