mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
b76f707c8b
Result: {"status":"keep","total_issues":74,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
294 lines
9.0 KiB
Go
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 := range maxRequestAttempts {
|
|
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
|
|
}
|