mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
356 lines
12 KiB
Go
356 lines
12 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cloudflare
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
)
|
|
|
|
const (
|
|
// SyncMemberTask is the Asynq task type for one Cloudflare member.
|
|
SyncMemberTask = "cloudflare:sync_member"
|
|
// SyncGroupTask is the Asynq task type for a Cloudflare group.
|
|
SyncGroupTask = "cloudflare:sync_group"
|
|
// SyncByNodeTask is the Asynq task type for members targeting one node.
|
|
SyncByNodeTask = "cloudflare:sync_by_node"
|
|
|
|
// TaskTypeSyncMember is the task metadata type for member synchronization.
|
|
TaskTypeSyncMember = "of_cloudflare_sync_member"
|
|
// TaskTypeSyncGroup is the task metadata type for group synchronization.
|
|
TaskTypeSyncGroup = "of_cloudflare_sync_group"
|
|
// TaskTypeSyncByNode is the task metadata type for node-triggered synchronization.
|
|
TaskTypeSyncByNode = "of_cloudflare_sync_by_node"
|
|
)
|
|
|
|
// SyncMemberMeta describes one-member reconciliation (admin-dispatchable).
|
|
var SyncMemberMeta = task.TaskMeta{
|
|
Type: TaskTypeSyncMember,
|
|
AsynqTask: SyncMemberTask,
|
|
Name: "Cloudflare 域名同步",
|
|
Description: "同步单个域名的 Cloudflare A 记录",
|
|
SupportsTime: false,
|
|
MaxRetry: 3,
|
|
Queue: task.QueueDefault,
|
|
Retryable: true,
|
|
Params: []task.TaskParam{
|
|
{
|
|
Name: "member_id",
|
|
Label: "成员 ID",
|
|
Type: "number",
|
|
Required: true,
|
|
Placeholder: "请输入 Cloudflare 指向成员 ID",
|
|
Description: "of_cf_pointing_members 表中的成员主键 ID",
|
|
},
|
|
},
|
|
}
|
|
|
|
// SyncGroupMeta describes group reconciliation (admin-dispatchable).
|
|
var SyncGroupMeta = task.TaskMeta{
|
|
Type: TaskTypeSyncGroup,
|
|
AsynqTask: SyncGroupTask,
|
|
Name: "Cloudflare 分组同步",
|
|
Description: "同步指向分组内全部域名",
|
|
SupportsTime: false,
|
|
MaxRetry: 2,
|
|
Queue: task.QueueDefault,
|
|
Retryable: true,
|
|
Params: []task.TaskParam{
|
|
{
|
|
Name: "group_id",
|
|
Label: "分组 ID",
|
|
Type: "number",
|
|
Required: true,
|
|
Placeholder: "请输入 Cloudflare 指向分组 ID",
|
|
Description: "of_cf_pointing_groups 表中的分组主键 ID",
|
|
},
|
|
},
|
|
}
|
|
|
|
// SyncByNodeMeta describes node-triggered reconciliation (internal only).
|
|
var SyncByNodeMeta = task.TaskMeta{
|
|
Type: TaskTypeSyncByNode,
|
|
AsynqTask: SyncByNodeTask,
|
|
Name: "Cloudflare 节点同步",
|
|
Description: "同步当前指向指定节点的全部域名",
|
|
SupportsTime: false,
|
|
MaxRetry: 2,
|
|
Queue: task.QueueDefault,
|
|
Retryable: true,
|
|
InternalOnly: true,
|
|
}
|
|
|
|
// SyncMemberPayload identifies one member.
|
|
type SyncMemberPayload struct {
|
|
MemberID uint `json:"member_id"`
|
|
}
|
|
|
|
// SyncGroupPayload identifies one group.
|
|
type SyncGroupPayload struct {
|
|
GroupID uint `json:"group_id"`
|
|
}
|
|
|
|
// SyncByNodePayload identifies one active node.
|
|
type SyncByNodePayload struct {
|
|
NodeID uint `json:"node_id"`
|
|
}
|
|
|
|
var dispatchTaskFn = task.DispatchTask
|
|
|
|
// SetDispatchTaskForTest replaces task dispatch for tests.
|
|
func SetDispatchTaskForTest(fn func(context.Context, string, []byte, string) (string, error)) func() {
|
|
previous := dispatchTaskFn
|
|
dispatchTaskFn = fn
|
|
return func() { dispatchTaskFn = previous }
|
|
}
|
|
|
|
// DispatchMemberSync queues one member reconciliation.
|
|
func DispatchMemberSync(ctx context.Context, memberID uint, triggeredBy string) (string, error) {
|
|
return dispatch(ctx, TaskTypeSyncMember, SyncMemberPayload{MemberID: memberID}, triggeredBy)
|
|
}
|
|
|
|
// DispatchGroupSync queues a group reconciliation.
|
|
func DispatchGroupSync(ctx context.Context, groupID uint, triggeredBy string) (string, error) {
|
|
return dispatch(ctx, TaskTypeSyncGroup, SyncGroupPayload{GroupID: groupID}, triggeredBy)
|
|
}
|
|
|
|
// DispatchNodeSync queues reconciliation for members targeting a node.
|
|
func DispatchNodeSync(ctx context.Context, nodeID uint, triggeredBy string) (string, error) {
|
|
return dispatch(ctx, TaskTypeSyncByNode, SyncByNodePayload{NodeID: nodeID}, triggeredBy)
|
|
}
|
|
|
|
func dispatch(ctx context.Context, taskType string, payload any, triggeredBy string) (string, error) {
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return dispatchTaskFn(ctx, taskType, encoded, triggeredBy)
|
|
}
|
|
|
|
// SyncMemberTaskHandler reconciles one member.
|
|
type SyncMemberTaskHandler struct{}
|
|
|
|
// ValidatePayload validates a one-member task payload.
|
|
func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|
if len(payload) == 0 {
|
|
return nil, errors.New("任务参数不能为空")
|
|
}
|
|
var input SyncMemberPayload
|
|
if err := decodePayload(payload, &input); err != nil {
|
|
return nil, fmt.Errorf("无效的 Cloudflare 成员同步参数: %w", err)
|
|
}
|
|
if input.MemberID == 0 {
|
|
return nil, errors.New("成员 ID 不能为空或零")
|
|
}
|
|
return json.Marshal(input)
|
|
}
|
|
|
|
// Execute reconciles one member.
|
|
func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
|
normalized, err := handler.ValidatePayload(payload)
|
|
if err != nil {
|
|
return nil, task.PermanentError(err.Error())
|
|
}
|
|
var input SyncMemberPayload
|
|
_ = json.Unmarshal(normalized, &input)
|
|
|
|
state, loadErr := repository.GetCFPointingMemberContext(ctx, input.MemberID)
|
|
if loadErr != nil {
|
|
task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr)
|
|
} else {
|
|
task.AppendLog(ctx,
|
|
"开始域名同步: domain=%s zone=%s group=%s(#%d) node=%s(%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),
|
|
state.Member.Proxied,
|
|
input.MemberID,
|
|
)
|
|
}
|
|
|
|
if err = ReconcileMember(ctx, input.MemberID); err != nil {
|
|
if state != nil {
|
|
task.AppendLog(ctx, "域名同步失败: domain=%s member_id=%d error=%v",
|
|
state.Domain.Domain, input.MemberID, err)
|
|
} else {
|
|
task.AppendLog(ctx, "域名同步失败: member_id=%d error=%v", input.MemberID, err)
|
|
}
|
|
return nil, fmt.Errorf("%s: %w", errSyncFailed, err)
|
|
}
|
|
|
|
message := "Cloudflare 域名同步成功"
|
|
if state != nil {
|
|
ip := strings.TrimSpace(state.Node.IP)
|
|
message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)",
|
|
state.Domain.Domain, ip, 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,
|
|
)
|
|
} else {
|
|
task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID)
|
|
}
|
|
return &task.TaskResult{Message: message}, nil
|
|
}
|
|
|
|
// SyncGroupTaskHandler reconciles every member in a group.
|
|
type SyncGroupTaskHandler struct{}
|
|
|
|
// ValidatePayload validates a group task payload.
|
|
func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|
if len(payload) == 0 {
|
|
return nil, errors.New("任务参数不能为空")
|
|
}
|
|
var input SyncGroupPayload
|
|
if err := decodePayload(payload, &input); err != nil {
|
|
return nil, fmt.Errorf("无效的 Cloudflare 分组同步参数: %w", err)
|
|
}
|
|
if input.GroupID == 0 {
|
|
return nil, errors.New("分组 ID 不能为空或零")
|
|
}
|
|
return json.Marshal(input)
|
|
}
|
|
|
|
// Execute reconciles every member in a group.
|
|
func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
|
normalized, err := handler.ValidatePayload(payload)
|
|
if err != nil {
|
|
return nil, task.PermanentError(err.Error())
|
|
}
|
|
var input SyncGroupPayload
|
|
if err = json.Unmarshal(normalized, &input); err != nil {
|
|
return nil, task.PermanentError(err.Error())
|
|
}
|
|
|
|
scopeName := fmt.Sprintf("#%d", input.GroupID)
|
|
activeNode := ""
|
|
if group, groupErr := repository.GetCFPointingGroup(ctx, input.GroupID); groupErr != nil {
|
|
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))
|
|
}
|
|
task.AppendLog(ctx,
|
|
"准备分组同步: group=%s id=%d enabled=%v active_node=%s default_proxied=%v",
|
|
group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied,
|
|
)
|
|
}
|
|
|
|
members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID)
|
|
return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode)
|
|
}
|
|
|
|
// SyncByNodeTaskHandler reconciles every member targeting a node.
|
|
type SyncByNodeTaskHandler struct{}
|
|
|
|
// ValidatePayload validates a node task payload.
|
|
func (handler *SyncByNodeTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|
var input SyncByNodePayload
|
|
if err := decodePayload(payload, &input); err != nil || input.NodeID == 0 {
|
|
return nil, errors.New("无效的 Cloudflare 节点同步参数")
|
|
}
|
|
return json.Marshal(input)
|
|
}
|
|
|
|
// Execute reconciles every member targeting a node.
|
|
func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
|
normalized, err := handler.ValidatePayload(payload)
|
|
if err != nil {
|
|
return nil, task.PermanentError(err.Error())
|
|
}
|
|
var input SyncByNodePayload
|
|
if err = json.Unmarshal(normalized, &input); err != nil {
|
|
return nil, task.PermanentError(err.Error())
|
|
}
|
|
|
|
scopeName := fmt.Sprintf("#%d", input.NodeID)
|
|
activeNode := ""
|
|
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, input.NodeID); nodeErr != nil {
|
|
task.AppendLog(ctx, "加载节点失败: node_id=%d error=%v", input.NodeID, nodeErr)
|
|
} else {
|
|
scopeName = node.Name
|
|
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
|
|
task.AppendLog(ctx, "准备节点同步: node=%s id=%d ip=%s",
|
|
node.Name, node.ID, strings.TrimSpace(node.IP))
|
|
}
|
|
|
|
members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID)
|
|
return executeBatchSync(ctx, members, err, "节点", scopeName, input.NodeID, activeNode)
|
|
}
|
|
|
|
func executeBatchSync(
|
|
ctx context.Context,
|
|
members []model.CFPointingMember,
|
|
listErr error,
|
|
scope, scopeName string,
|
|
scopeID uint,
|
|
activeNode string,
|
|
) (*task.TaskResult, error) {
|
|
if listErr != nil {
|
|
task.AppendLog(ctx, "列出%s成员失败: name=%s id=%d error=%v",
|
|
scope, scopeName, scopeID, listErr)
|
|
return nil, listErr
|
|
}
|
|
|
|
task.AppendLog(ctx, "开始%s同步: name=%s id=%d active_node=%s 域名数=%d",
|
|
scope, scopeName, scopeID, activeNode, len(members))
|
|
if len(members) == 0 {
|
|
message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName)
|
|
task.AppendLog(ctx, "%s", message)
|
|
return &task.TaskResult{Message: message}, nil
|
|
}
|
|
|
|
for index, member := range members {
|
|
domainName := fmt.Sprintf("zone_domain_id=%d", member.ZoneDomainID)
|
|
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
|
|
domainName = domain.Domain
|
|
}
|
|
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
|
|
index+1, len(members), domainName, member.ID, member.Proxied)
|
|
if err := ReconcileMember(ctx, member.ID); err != nil {
|
|
task.AppendLog(ctx, "[%d/%d] 失败: domain=%s member_id=%d error=%v",
|
|
index+1, len(members), domainName, member.ID, err)
|
|
return nil, err
|
|
}
|
|
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
|
|
}
|
|
|
|
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, len(members))
|
|
if activeNode != "" {
|
|
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
|
|
scope, scopeName, activeNode, len(members))
|
|
}
|
|
task.AppendLog(ctx, "%s", message)
|
|
return &task.TaskResult{Message: message}, nil
|
|
}
|
|
|
|
func decodePayload(payload []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return errors.New("unexpected trailing JSON value")
|
|
}
|
|
return nil
|
|
}
|