mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
fix(cloudflare): clean up pointing member when zone domain is deleted
This commit is contained in:
@@ -14,6 +14,7 @@ sidebar: false
|
|||||||
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
|
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
|
||||||
|
|
||||||
### 🛠 修复
|
### 🛠 修复
|
||||||
|
- 修复在网站列表中删除已加入 Cloudflare 指向分组的域名后,访问 Cloudflare 指向分组详情报错「Cloudflare 资源不存在」的问题。
|
||||||
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
|
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
|
||||||
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
|
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
|
||||||
- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。
|
- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。
|
||||||
|
|||||||
@@ -423,6 +423,13 @@ func listMemberItems(ctx context.Context, groupID uint) ([]MemberItem, error) {
|
|||||||
for i := range members {
|
for i := range members {
|
||||||
domain, domainErr := repository.GetZoneDomainByID(ctx, members[i].ZoneDomainID)
|
domain, domainErr := repository.GetZoneDomainByID(ctx, members[i].ZoneDomainID)
|
||||||
if domainErr != nil {
|
if domainErr != nil {
|
||||||
|
if errors.Is(domainErr, gorm.ErrRecordNotFound) {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] cleaning up orphaned pointing member: member_id=%d zone_domain_id=%d", members[i].ID, members[i].ZoneDomainID)
|
||||||
|
if delErr := repository.DeleteCFPointingMember(ctx, &members[i]); delErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] delete orphaned member failed: member_id=%d error=%v", members[i].ID, delErr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, domainErr
|
return nil, domainErr
|
||||||
}
|
}
|
||||||
items = append(items, *memberItem(&members[i], domain))
|
items = append(items, *memberItem(&members[i], domain))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -116,6 +117,9 @@ func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip s
|
|||||||
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||||
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
connection, err := repository.GetCFConnection(ctx)
|
connection, err := repository.GetCFConnection(ctx)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -41,3 +43,35 @@ func TestConnectionHandlersNeverReturnAPIToken(t *testing.T) {
|
|||||||
t.Fatalf("GET /connection leaked token: %s", get.Body.String())
|
t.Fatalf("GET /connection leaked token: %s", get.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetGroupWithOrphanedMemberHealsAndSucceeds(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(response.ErrorHandlerMiddleware())
|
||||||
|
router.GET("/groups/:id", GetGroupHandler)
|
||||||
|
|
||||||
|
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate orphaned member by deleting the ZoneDomain directly
|
||||||
|
if err := db.DB(ctx).Exec("DELETE FROM of_zone_domains WHERE id = ?", member.ZoneDomainID).Error; err != nil {
|
||||||
|
t.Fatalf("DELETE FROM of_zone_domains error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/groups/1", nil)
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /groups/1 status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the orphaned member has been removed
|
||||||
|
_, err = repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("GetCFPointingMemberByID() should return not found after healing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -317,10 +318,19 @@ func executeBatchSync(
|
|||||||
return &task.TaskResult{Message: message}, nil
|
return &task.TaskResult{Message: message}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncedCount := 0
|
||||||
for index, member := range members {
|
for index, member := range members {
|
||||||
domainName := fmt.Sprintf("zone_domain_id=%d", member.ZoneDomainID)
|
domainName := fmt.Sprintf("zone_domain_id=%d", member.ZoneDomainID)
|
||||||
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
|
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
|
||||||
domainName = domain.Domain
|
domainName = domain.Domain
|
||||||
|
} else if errors.Is(domainErr, gorm.ErrRecordNotFound) {
|
||||||
|
task.AppendLog(ctx, "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d",
|
||||||
|
index+1, len(members), member.ID, member.ZoneDomainID)
|
||||||
|
if delErr := repository.DeleteCFPointingMember(ctx, &member); delErr != nil {
|
||||||
|
task.AppendLog(ctx, "[%d/%d] 清理孤立成员失败: member_id=%d error=%v",
|
||||||
|
index+1, len(members), member.ID, delErr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
|
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
|
||||||
index+1, len(members), domainName, member.ID, member.Proxied)
|
index+1, len(members), domainName, member.ID, member.Proxied)
|
||||||
@@ -329,13 +339,14 @@ func executeBatchSync(
|
|||||||
index+1, len(members), domainName, member.ID, err)
|
index+1, len(members), domainName, member.ID, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
syncedCount++
|
||||||
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
|
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
|
||||||
}
|
}
|
||||||
|
|
||||||
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, len(members))
|
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, syncedCount)
|
||||||
if activeNode != "" {
|
if activeNode != "" {
|
||||||
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
|
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
|
||||||
scope, scopeName, activeNode, len(members))
|
scope, scopeName, activeNode, syncedCount)
|
||||||
}
|
}
|
||||||
task.AppendLog(ctx, "%s", message)
|
task.AppendLog(ctx, "%s", message)
|
||||||
return &task.TaskResult{Message: message}, nil
|
return &task.TaskResult{Message: message}, nil
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"golang.org/x/net/publicsuffix"
|
"golang.org/x/net/publicsuffix"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -220,6 +222,19 @@ func DeleteDomain(ctx context.Context, zoneID, id uint) error {
|
|||||||
if item.ProxyRouteID != nil {
|
if item.ProxyRouteID != nil {
|
||||||
return errors.New(errDomainBoundToRoute)
|
return errors.New(errDomainBoundToRoute)
|
||||||
}
|
}
|
||||||
|
member, cfErr := repository.GetCFPointingMemberByZoneDomainID(ctx, item.ID)
|
||||||
|
if cfErr != nil && !errors.Is(cfErr, gorm.ErrRecordNotFound) {
|
||||||
|
return cfErr
|
||||||
|
}
|
||||||
|
if member != nil {
|
||||||
|
if delErr := cf.DeleteManagedRecord(ctx, member.ID); delErr != nil {
|
||||||
|
logger.WarnF(ctx, "[Zone] delete managed Cloudflare record failed for domain %s (member_id=%d): %v", item.Domain, member.ID, delErr)
|
||||||
|
}
|
||||||
|
if delMemberErr := repository.DeleteCFPointingMember(ctx, member); delMemberErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Zone] delete Cloudflare pointing member failed: member_id=%d error=%v", member.ID, delMemberErr)
|
||||||
|
return delMemberErr
|
||||||
|
}
|
||||||
|
}
|
||||||
return repository.DeleteZoneDomain(ctx, item)
|
return repository.DeleteZoneDomain(ctx, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func setupZoneDB(t *testing.T) context.Context {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}))
|
require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}, &model.CFPointingGroup{}, &model.CFPointingMember{}))
|
||||||
db.SetDB(conn)
|
db.SetDB(conn)
|
||||||
t.Cleanup(func() { db.SetDB(nil) })
|
t.Cleanup(func() { db.SetDB(nil) })
|
||||||
return context.Background()
|
return context.Background()
|
||||||
@@ -54,6 +54,22 @@ func TestDeleteDomainRejectsBoundRoute(t *testing.T) {
|
|||||||
require.NoError(t, DeleteDomain(ctx, zone.ID, item.ID))
|
require.NoError(t, DeleteDomain(ctx, zone.ID, item.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteDomainCleansUpCloudflareMember(t *testing.T) {
|
||||||
|
ctx := setupZoneDB(t)
|
||||||
|
zone, err := Create(ctx, Input{Domain: "example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
domain, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
member := model.CFPointingMember{GroupID: 1, ZoneDomainID: domain.ID}
|
||||||
|
require.NoError(t, repository.CreateCFPointingMember(ctx, &member))
|
||||||
|
|
||||||
|
require.NoError(t, DeleteDomain(ctx, zone.ID, domain.ID))
|
||||||
|
|
||||||
|
_, err = repository.GetCFPointingMemberByZoneDomainID(ctx, domain.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||||
root, err := zoneRoot("api.example.co.uk")
|
root, err := zoneRoot("api.example.co.uk")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ func DeleteCFPointingGroup(ctx context.Context, id uint) error {
|
|||||||
// CountCFPointingMembersByGroupID counts members in a group.
|
// CountCFPointingMembersByGroupID counts members in a group.
|
||||||
func CountCFPointingMembersByGroupID(ctx context.Context, groupID uint) (int64, error) {
|
func CountCFPointingMembersByGroupID(ctx context.Context, groupID uint) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := db.DB(ctx).Model(&model.CFPointingMember{}).Where("group_id = ?", groupID).Count(&count).Error
|
err := db.DB(ctx).Table("of_cf_pointing_members AS members").
|
||||||
|
Joins("JOIN of_zone_domains AS domains ON domains.id = members.zone_domain_id").
|
||||||
|
Where("members.group_id = ?", groupID).Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,16 @@ func SaveZoneDomain(ctx context.Context, domain *model.ZoneDomain) error {
|
|||||||
|
|
||||||
// DeleteZoneDomain deletes a zone domain record.
|
// DeleteZoneDomain deletes a zone domain record.
|
||||||
func DeleteZoneDomain(ctx context.Context, domain *model.ZoneDomain) error {
|
func DeleteZoneDomain(ctx context.Context, domain *model.ZoneDomain) error {
|
||||||
return db.DB(ctx).Delete(domain).Error
|
conn := db.DB(ctx)
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New(errDatabaseNotInitialized)
|
||||||
|
}
|
||||||
|
return conn.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("zone_domain_id = ?", domain.ID).Delete(&model.CFPointingMember{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(domain).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListZoneDomainsByRouteID returns the domains bound to a proxy route.
|
// ListZoneDomainsByRouteID returns the domains bound to a proxy route.
|
||||||
|
|||||||
Reference in New Issue
Block a user