mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
feat(cloudflare): add DNS pointing integration
Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
memberLockStripeCount = 64
|
||||
memberLastErrorColumn = "last_error"
|
||||
memberSyncStatusColumn = "sync_status"
|
||||
)
|
||||
|
||||
var memberLocks [memberLockStripeCount]sync.Mutex
|
||||
|
||||
// ReconcileMember makes one Cloudflare A record match the local desired state.
|
||||
func ReconcileMember(ctx context.Context, memberID uint) error {
|
||||
lock := &memberLocks[memberID%memberLockStripeCount]
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncing, memberLastErrorColumn: ""}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reconcileMember(ctx, memberID); err != nil {
|
||||
if updateErr := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{memberSyncStatusColumn: model.CFMemberSyncError, memberLastErrorColumn: err.Error()}); updateErr != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] persist member sync error failed: member_id=%d error=%v", memberID, updateErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reconcileMember(ctx context.Context, memberID uint) error {
|
||||
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.Group.Enabled {
|
||||
return errors.New(errGroupDisabled)
|
||||
}
|
||||
ip := strings.TrimSpace(state.Node.IP)
|
||||
if net.ParseIP(ip).To4() == nil {
|
||||
return errors.New(errNodeIPv4Required)
|
||||
}
|
||||
connection, err := repository.GetCFConnection(ctx)
|
||||
if err != nil || connection.Status != model.CFConnectionStatusReady {
|
||||
return errors.New(errConnectionNotConfigured)
|
||||
}
|
||||
token, err := resolveToken(ctx, connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := clientFactory(token)
|
||||
zoneID := state.Member.CFZoneID
|
||||
if zoneID == "" {
|
||||
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
zoneID = zone.ID
|
||||
}
|
||||
input := RecordInput{Type: "A", Name: state.Domain.Domain, Content: ip, Proxied: state.Member.Proxied, TTL: 300}
|
||||
if input.Proxied {
|
||||
input.TTL = 1
|
||||
}
|
||||
recordID := state.Member.CFRecordID
|
||||
if recordID != "" {
|
||||
if _, getErr := client.GetRecord(ctx, zoneID, recordID); getErr == nil {
|
||||
record, updateErr := client.UpdateARecord(ctx, zoneID, recordID, input)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||
}
|
||||
}
|
||||
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var record *DNSRecord
|
||||
switch len(records) {
|
||||
case 0:
|
||||
record, err = client.CreateARecord(ctx, zoneID, input)
|
||||
case 1:
|
||||
record, err = client.UpdateARecord(ctx, zoneID, records[0].ID, input)
|
||||
default:
|
||||
return errors.New(errMultipleARecords)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||
}
|
||||
|
||||
func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip string) error {
|
||||
now := time.Now()
|
||||
return repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{
|
||||
"cf_zone_id": zoneID, "cf_record_id": recordID, "desired_ip": ip,
|
||||
memberSyncStatusColumn: model.CFMemberSyncOK, memberLastErrorColumn: "", "synced_at": &now,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteManagedRecord deletes the cached or uniquely discoverable A record.
|
||||
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connection, err := repository.GetCFConnection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := resolveToken(ctx, connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := clientFactory(token)
|
||||
zoneID := state.Member.CFZoneID
|
||||
if zoneID == "" {
|
||||
zone, findErr := client.FindZone(ctx, state.Zone.Domain)
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
zoneID = zone.ID
|
||||
}
|
||||
if state.Member.CFRecordID != "" {
|
||||
if deleteErr := client.DeleteRecord(ctx, zoneID, state.Member.CFRecordID); deleteErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(records) > 1 {
|
||||
return errors.New(errMultipleARecords)
|
||||
}
|
||||
return client.DeleteRecord(ctx, zoneID, records[0].ID)
|
||||
}
|
||||
Reference in New Issue
Block a user