mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +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,223 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const singletonCFConnectionID uint = 1
|
||||
|
||||
// CFPointingMemberContext contains all local state needed to reconcile one member.
|
||||
type CFPointingMemberContext struct {
|
||||
Member model.CFPointingMember
|
||||
Group model.CFPointingGroup
|
||||
Domain model.ZoneDomain
|
||||
Zone model.Zone
|
||||
Node model.OpenFlareNode
|
||||
}
|
||||
|
||||
// GetCFConnection returns the global Cloudflare connection.
|
||||
func GetCFConnection(ctx context.Context) (*model.CFConnection, error) {
|
||||
conn := db.DB(ctx)
|
||||
if conn == nil {
|
||||
return nil, errors.New(errDatabaseNotInitialized)
|
||||
}
|
||||
var item model.CFConnection
|
||||
if err := conn.First(&item, singletonCFConnectionID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// UpsertCFConnection creates or replaces the global Cloudflare connection.
|
||||
func UpsertCFConnection(ctx context.Context, item *model.CFConnection) error {
|
||||
conn := db.DB(ctx)
|
||||
if conn == nil {
|
||||
return errors.New(errDatabaseNotInitialized)
|
||||
}
|
||||
item.ID = singletonCFConnectionID
|
||||
return conn.Save(item).Error
|
||||
}
|
||||
|
||||
// DeleteCFConnection clears the global Cloudflare connection.
|
||||
func DeleteCFConnection(ctx context.Context) error {
|
||||
conn := db.DB(ctx)
|
||||
if conn == nil {
|
||||
return errors.New(errDatabaseNotInitialized)
|
||||
}
|
||||
return conn.Delete(&model.CFConnection{}, singletonCFConnectionID).Error
|
||||
}
|
||||
|
||||
// ListCFPointingGroups lists Cloudflare pointing groups newest first.
|
||||
func ListCFPointingGroups(ctx context.Context) ([]model.CFPointingGroup, error) {
|
||||
var items []model.CFPointingGroup
|
||||
if err := db.DB(ctx).Order("id desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetCFPointingGroup returns a group by ID.
|
||||
func GetCFPointingGroup(ctx context.Context, id uint) (*model.CFPointingGroup, error) {
|
||||
var item model.CFPointingGroup
|
||||
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// CreateCFPointingGroup creates a group.
|
||||
func CreateCFPointingGroup(ctx context.Context, item *model.CFPointingGroup) error {
|
||||
return db.DB(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
// SaveCFPointingGroup persists a group.
|
||||
func SaveCFPointingGroup(ctx context.Context, item *model.CFPointingGroup) error {
|
||||
return db.DB(ctx).Save(item).Error
|
||||
}
|
||||
|
||||
// DeleteCFPointingGroup deletes an empty group.
|
||||
func DeleteCFPointingGroup(ctx context.Context, id uint) error {
|
||||
return db.DB(ctx).Delete(&model.CFPointingGroup{}, id).Error
|
||||
}
|
||||
|
||||
// CountCFPointingMembersByGroupID counts members in a group.
|
||||
func CountCFPointingMembersByGroupID(ctx context.Context, groupID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ListCFPointingMembersByGroupID lists members by group.
|
||||
func ListCFPointingMembersByGroupID(ctx context.Context, groupID uint) ([]model.CFPointingMember, error) {
|
||||
var items []model.CFPointingMember
|
||||
if err := db.DB(ctx).Where("group_id = ?", groupID).Order("id asc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListCFPointingMembersByActiveNodeID lists members whose group currently targets a node.
|
||||
func ListCFPointingMembersByActiveNodeID(ctx context.Context, nodeID uint) ([]model.CFPointingMember, error) {
|
||||
var items []model.CFPointingMember
|
||||
err := db.DB(ctx).Table("of_cf_pointing_members AS members").
|
||||
Select("members.*").
|
||||
Joins("JOIN of_cf_pointing_groups AS groups ON groups.id = members.group_id").
|
||||
Where("groups.active_node_id = ? AND groups.enabled = ?", nodeID, true).
|
||||
Order("members.id asc").Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetCFPointingMember returns a member scoped to its group.
|
||||
func GetCFPointingMember(ctx context.Context, groupID, memberID uint) (*model.CFPointingMember, error) {
|
||||
var item model.CFPointingMember
|
||||
if err := db.DB(ctx).Where("id = ? AND group_id = ?", memberID, groupID).First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetCFPointingMemberByID returns a member by ID.
|
||||
func GetCFPointingMemberByID(ctx context.Context, id uint) (*model.CFPointingMember, error) {
|
||||
var item model.CFPointingMember
|
||||
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetCFPointingMemberByZoneDomainID returns the member managing a ZoneDomain.
|
||||
func GetCFPointingMemberByZoneDomainID(ctx context.Context, zoneDomainID uint) (*model.CFPointingMember, error) {
|
||||
var item model.CFPointingMember
|
||||
if err := db.DB(ctx).Where("zone_domain_id = ?", zoneDomainID).First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// CreateCFPointingMember creates a member.
|
||||
func CreateCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||
return db.DB(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
// SaveCFPointingMember persists a member.
|
||||
func SaveCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||
return db.DB(ctx).Save(item).Error
|
||||
}
|
||||
|
||||
// UpdateCFPointingMemberColumns updates selected member fields.
|
||||
func UpdateCFPointingMemberColumns(ctx context.Context, id uint, changes map[string]any) error {
|
||||
return db.DB(ctx).Model(&model.CFPointingMember{}).Where("id = ?", id).Updates(changes).Error
|
||||
}
|
||||
|
||||
// DeleteCFPointingMember deletes a member.
|
||||
func DeleteCFPointingMember(ctx context.Context, item *model.CFPointingMember) error {
|
||||
return db.DB(ctx).Delete(item).Error
|
||||
}
|
||||
|
||||
// ListAvailableCFZoneDomains returns ZoneDomains not already managed by Cloudflare pointing.
|
||||
func ListAvailableCFZoneDomains(ctx context.Context) ([]model.ZoneDomain, error) {
|
||||
var items []model.ZoneDomain
|
||||
err := db.DB(ctx).Where(`NOT EXISTS (
|
||||
SELECT 1 FROM of_cf_pointing_members AS members
|
||||
WHERE members.zone_domain_id = of_zone_domains.id
|
||||
)`).Order("domain asc").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetCFPointingMemberContext loads one member and all referenced local objects.
|
||||
func GetCFPointingMemberContext(ctx context.Context, memberID uint) (*CFPointingMemberContext, error) {
|
||||
member, err := GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, err := GetCFPointingGroup(ctx, member.GroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domain, err := GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zone, err := GetZoneByID(ctx, domain.ZoneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GetZoneDomainByID returns a ZoneDomain by primary key.
|
||||
func GetZoneDomainByID(ctx context.Context, id uint) (*model.ZoneDomain, error) {
|
||||
var item model.ZoneDomain
|
||||
if err := db.DB(ctx).First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// MarkCFPointingGroupMembersPending resets every member after target changes.
|
||||
func MarkCFPointingGroupMembersPending(ctx context.Context, groupID uint) error {
|
||||
return db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).
|
||||
Updates(map[string]any{"sync_status": model.CFMemberSyncPending, "last_error": ""}).Error
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err := tx.Where("group_id = ?", groupID).Delete(&model.CFPointingMember{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.CFPointingGroup{}, groupID).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupCloudflareRepositoryDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||
if err != nil {
|
||||
t.Fatalf("gorm.Open() error = %v", err)
|
||||
}
|
||||
if err := conn.AutoMigrate(
|
||||
&model.CFConnection{}, &model.CFPointingGroup{}, &model.CFPointingMember{},
|
||||
&model.Zone{}, &model.ZoneDomain{}, &model.OpenFlareNode{}, &model.DNSAccount{},
|
||||
); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
db.SetDB(conn)
|
||||
t.Cleanup(func() { db.SetDB(nil) })
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestUpsertCFConnectionKeepsSingleRow(t *testing.T) {
|
||||
setupCloudflareRepositoryDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := &model.CFConnection{Source: model.CFConnectionSourceStandalone, Authorization: "one"}
|
||||
if err := UpsertCFConnection(ctx, first); err != nil {
|
||||
t.Fatalf("UpsertCFConnection(first) error = %v", err)
|
||||
}
|
||||
accountID := uint(9)
|
||||
second := &model.CFConnection{Source: model.CFConnectionSourceDNSAccount, DNSAccountID: &accountID}
|
||||
if err := UpsertCFConnection(ctx, second); err != nil {
|
||||
t.Fatalf("UpsertCFConnection(second) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := GetCFConnection(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFConnection() error = %v", err)
|
||||
}
|
||||
if got.ID != first.ID || got.Source != model.CFConnectionSourceDNSAccount {
|
||||
t.Errorf("GetCFConnection() = %+v, want same row with dns_account source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableCFZoneDomainsExcludesMembers(t *testing.T) {
|
||||
conn := setupCloudflareRepositoryDB(t)
|
||||
ctx := context.Background()
|
||||
zone := model.Zone{Domain: "example.com"}
|
||||
if err := conn.Create(&zone).Error; err != nil {
|
||||
t.Fatalf("Create(zone) error = %v", err)
|
||||
}
|
||||
domains := []model.ZoneDomain{
|
||||
{ZoneID: zone.ID, Domain: "api.example.com"},
|
||||
{ZoneID: zone.ID, Domain: "www.example.com"},
|
||||
}
|
||||
if err := conn.Create(&domains).Error; err != nil {
|
||||
t.Fatalf("Create(domains) error = %v", err)
|
||||
}
|
||||
group := model.CFPointingGroup{Name: "edge", PrimaryNodeID: 1, ActiveNodeID: 1, Enabled: true}
|
||||
if err := CreateCFPointingGroup(ctx, &group); err != nil {
|
||||
t.Fatalf("CreateCFPointingGroup() error = %v", err)
|
||||
}
|
||||
member := model.CFPointingMember{GroupID: group.ID, ZoneDomainID: domains[0].ID}
|
||||
if err := CreateCFPointingMember(ctx, &member); err != nil {
|
||||
t.Fatalf("CreateCFPointingMember() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := ListAvailableCFZoneDomains(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAvailableCFZoneDomains() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != domains[1].ID {
|
||||
t.Errorf("ListAvailableCFZoneDomains() = %+v, want only %d", got, domains[1].ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user