mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
fd0e9acc2f
* feat(cloudflare): add custom targets and node failover * fix(cloudflare): propagate batch sync errors --------- Co-authored-by: OpenAI Codex <codex@openai.com>
210 lines
5.9 KiB
Go
210 lines
5.9 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cloudflare
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
memberLockStripeCount = 64
|
|
memberLastErrorColumn = "last_error"
|
|
memberSyncStatusColumn = "sync_status"
|
|
)
|
|
|
|
var errCachedRecordUnavailable = errors.New("cached Cloudflare record unavailable")
|
|
|
|
var memberLocks [memberLockStripeCount]sync.Mutex
|
|
|
|
// 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()
|
|
defer lock.Unlock()
|
|
|
|
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncing, memberLastErrorColumn: ""}); err != nil {
|
|
return err
|
|
}
|
|
if err := reconcileMember(ctx, memberID); err != nil {
|
|
if updateErr := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncError, memberLastErrorColumn: err.Error()}); updateErr != nil {
|
|
logger.ErrorF(ctx, "[Cloudflare] persist member sync error failed: member_id=%d error=%v", memberID, updateErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reconcileMember(ctx context.Context, memberID uint) error {
|
|
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !state.Group.Enabled {
|
|
return errors.New(errGroupDisabled)
|
|
}
|
|
input, err := desiredRecordInput(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connection, err := repository.GetCFConnection(ctx)
|
|
if err != nil || connection.Status != model.CFConnectionStatusReady {
|
|
return errors.New(errConnectionNotConfigured)
|
|
}
|
|
token, err := resolveToken(ctx, connection)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := clientFactory(token)
|
|
zoneID := state.Member.CFZoneID
|
|
if zoneID == "" {
|
|
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
|
if findErr != nil {
|
|
return findErr
|
|
}
|
|
zoneID = zone.ID
|
|
}
|
|
input.Name = state.Domain.Domain
|
|
input.Proxied = state.Member.Proxied
|
|
input.TTL = 300
|
|
if input.Proxied {
|
|
input.TTL = 1
|
|
}
|
|
recordID := state.Member.CFRecordID
|
|
if recordID != "" {
|
|
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.ListRecords(ctx, zoneID, state.Domain.Domain, input.Type)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var record *DNSRecord
|
|
switch len(records) {
|
|
case 0:
|
|
record, err = client.CreateRecord(ctx, zoneID, input)
|
|
case 1:
|
|
record, err = client.UpdateRecord(ctx, zoneID, records[0].ID, input)
|
|
default:
|
|
return errors.New(errMultipleDNSRecords)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
now := time.Now()
|
|
return repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{
|
|
"cf_zone_id": zoneID, "cf_record_id": recordID, "desired_ip": ip,
|
|
memberSyncStatusColumn: model.CFMemberSyncOK, memberLastErrorColumn: "", "synced_at": &now,
|
|
})
|
|
}
|
|
|
|
// DeleteManagedRecord deletes the cached or uniquely discoverable managed record.
|
|
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
|
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
connection, err := repository.GetCFConnection(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, err := resolveToken(ctx, connection)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := clientFactory(token)
|
|
zoneID := state.Member.CFZoneID
|
|
if zoneID == "" {
|
|
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
|
if findErr != nil {
|
|
return findErr
|
|
}
|
|
zoneID = zone.ID
|
|
}
|
|
if state.Member.CFRecordID != "" {
|
|
if deleteErr := client.DeleteRecord(ctx, zoneID, state.Member.CFRecordID); deleteErr == nil {
|
|
return nil
|
|
}
|
|
}
|
|
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
|
|
}
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
if len(records) > 1 {
|
|
return errors.New(errMultipleDNSRecords)
|
|
}
|
|
return client.DeleteRecord(ctx, zoneID, records[0].ID)
|
|
}
|