mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06: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,293 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user