Files
OpenFlare/internal/apps/openflare/cloudflare/client.go
T
ryan 3edcdb9e9f 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.
2026-08-04 12:32:37 +08:00

294 lines
9.0 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cloudflare manages Cloudflare DNS pointing for OpenFlare domains.
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/pkg/httppool"
)
const (
defaultAPIBaseURL = "https://api.cloudflare.com/client/v4"
defaultHTTPTimeout = 20 * time.Second
maxRequestAttempts = 3
maxResponseBodyBytes = 1 << 20
defaultRetryDelay = 200 * time.Millisecond
maxRetryAfterSeconds = 2
)
// Zone is a Cloudflare DNS zone.
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// DNSRecord is a Cloudflare DNS record.
type DNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
TTL int `json:"ttl"`
}
// RecordInput is the desired Cloudflare DNS record payload.
type RecordInput struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
TTL int `json:"ttl"`
}
// Client describes the Cloudflare operations used by pointing reconciliation.
type Client interface {
VerifyToken(context.Context) error
FindZone(context.Context, string) (*Zone, error)
GetRecord(context.Context, string, string) (*DNSRecord, error)
ListARecords(context.Context, string, string) ([]DNSRecord, error)
CreateARecord(context.Context, string, RecordInput) (*DNSRecord, error)
UpdateARecord(context.Context, string, string, RecordInput) (*DNSRecord, error)
DeleteRecord(context.Context, string, string) error
}
// HTTPClient implements Client with Cloudflare's v4 HTTP API.
type HTTPClient struct {
token string
baseURL string
httpClient *http.Client
}
// ClientOption configures HTTPClient.
type ClientOption func(*HTTPClient)
// WithBaseURL overrides the Cloudflare API base URL.
func WithBaseURL(baseURL string) ClientOption {
return func(client *HTTPClient) { client.baseURL = strings.TrimRight(baseURL, "/") }
}
// WithHTTPClient overrides the HTTP transport.
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(client *HTTPClient) { client.httpClient = httpClient }
}
// NewHTTPClient creates a Cloudflare HTTP client.
func NewHTTPClient(token string, options ...ClientOption) *HTTPClient {
client := &HTTPClient{
token: strings.TrimSpace(token),
baseURL: defaultAPIBaseURL,
httpClient: httppool.NewClient(defaultHTTPTimeout),
}
for _, option := range options {
option(client)
}
return client
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type apiEnvelope[T any] struct {
Success bool `json:"success"`
Errors []apiError `json:"errors"`
Result T `json:"result"`
}
// VerifyToken verifies that the configured API token is active.
func (client *HTTPClient) VerifyToken(ctx context.Context) error {
var result struct {
Status string `json:"status"`
}
if err := client.do(ctx, http.MethodGet, "/user/tokens/verify", nil, nil, &result); err != nil {
return err
}
if result.Status != "active" {
return errors.New("cloudflare API Token 未激活")
}
return nil
}
// FindZone returns the exact Cloudflare zone name.
func (client *HTTPClient) FindZone(ctx context.Context, name string) (*Zone, error) {
query := url.Values{"name": {strings.TrimSpace(name)}, "status": {"active"}, "per_page": {"2"}}
var zones []Zone
if err := client.do(ctx, http.MethodGet, "/zones", query, nil, &zones); err != nil {
return nil, err
}
if len(zones) != 1 {
return nil, fmt.Errorf("cloudflare 中未找到唯一 Zone %s", name)
}
return &zones[0], nil
}
// GetRecord returns a DNS record by ID.
func (client *HTTPClient) GetRecord(ctx context.Context, zoneID, recordID string) (*DNSRecord, error) {
var record DNSRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
if err := client.do(ctx, http.MethodGet, path, nil, nil, &record); err != nil {
return nil, err
}
return &record, nil
}
// ListARecords lists exact-name A records.
func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) ([]DNSRecord, error) {
query := url.Values{"type": {"A"}, "name": {strings.TrimSpace(name)}, "per_page": {"100"}}
var records []DNSRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
if err := client.do(ctx, http.MethodGet, path, query, nil, &records); err != nil {
return nil, err
}
return records, nil
}
// CreateARecord creates an A record.
func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) {
var record DNSRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
if err := client.do(ctx, http.MethodPost, path, nil, input, &record); err != nil {
return nil, err
}
return &record, nil
}
// UpdateARecord replaces an A record.
func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) {
var record DNSRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
if err := client.do(ctx, http.MethodPut, path, nil, input, &record); err != nil {
return nil, err
}
return &record, nil
}
// DeleteRecord deletes a DNS record.
func (client *HTTPClient) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
var result struct {
ID string `json:"id"`
}
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
return client.do(ctx, http.MethodDelete, path, nil, nil, &result)
}
func (client *HTTPClient) do(ctx context.Context, method, path string, query url.Values, body, result any) error {
encodedBody, err := encodeRequestBody(body)
if err != nil {
return err
}
requestURL := buildRequestURL(client.baseURL, path, query)
for attempt := 0; attempt < maxRequestAttempts; attempt++ {
statusCode, retryHeader, responseBody, requestErr := client.send(ctx, method, requestURL, encodedBody)
if requestErr != nil {
return requestErr
}
if statusCode == http.StatusTooManyRequests && attempt < maxRequestAttempts-1 {
if waitErr := waitForRetry(ctx, retryAfter(retryHeader)); waitErr != nil {
return waitErr
}
continue
}
return decodeAPIResponse(statusCode, responseBody, result)
}
return errors.New("cloudflare API 请求超过重试次数")
}
func encodeRequestBody(body any) ([]byte, error) {
if body == nil {
return nil, nil
}
encodedBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode Cloudflare request: %w", err)
}
return encodedBody, nil
}
func buildRequestURL(baseURL, path string, query url.Values) string {
requestURL := baseURL + path
if len(query) > 0 {
requestURL += "?" + query.Encode()
}
return requestURL
}
func (client *HTTPClient) send(ctx context.Context, method, requestURL string, body []byte) (int, string, []byte, error) {
request, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(body))
if err != nil {
return 0, "", nil, fmt.Errorf("create Cloudflare request: %w", err)
}
request.Header.Set("Authorization", "Bearer "+client.token)
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return 0, "", nil, fmt.Errorf("cloudflare API 请求失败: %w", err)
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes))
closeErr := response.Body.Close()
if readErr != nil {
return 0, "", nil, fmt.Errorf("read Cloudflare response: %w", readErr)
}
if closeErr != nil {
return 0, "", nil, fmt.Errorf("close Cloudflare response: %w", closeErr)
}
return response.StatusCode, response.Header.Get("Retry-After"), responseBody, nil
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func decodeAPIResponse(statusCode int, responseBody []byte, result any) error {
var envelope apiEnvelope[json.RawMessage]
if err := json.Unmarshal(responseBody, &envelope); err != nil {
return fmt.Errorf("decode Cloudflare response: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices || !envelope.Success {
message := "cloudflare API 请求失败"
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
message = envelope.Errors[0].Message
}
return errors.New(message)
}
if result == nil || len(envelope.Result) == 0 || string(envelope.Result) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Result, result); err != nil {
return fmt.Errorf("decode Cloudflare result: %w", err)
}
return nil
}
func retryAfter(value string) time.Duration {
seconds, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || seconds <= 0 {
return defaultRetryDelay
}
if seconds > maxRetryAfterSeconds {
seconds = maxRetryAfterSeconds
}
return time.Duration(seconds) * time.Second
}