client.go raw

   1  /*
   2  Package internal Cloudflare API client.
   3  
   4  The official client is huge and still growing.
   5  - https://github.com/cloudflare/cloudflare-go/issues/4171
   6  */
   7  package internal
   8  
   9  import (
  10  	"bytes"
  11  	"context"
  12  	"encoding/json"
  13  	"errors"
  14  	"fmt"
  15  	"io"
  16  	"net/http"
  17  	"net/url"
  18  	"time"
  19  
  20  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  21  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  22  	"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
  23  )
  24  
  25  const defaultBaseURL = "https://api.cloudflare.com/client/v4"
  26  
  27  // Client the Cloudflare API client.
  28  type Client struct {
  29  	authEmail string
  30  	authKey   string
  31  	authToken string
  32  
  33  	baseURL    *url.URL
  34  	HTTPClient *http.Client
  35  }
  36  
  37  // NewClient creates a new Client.
  38  func NewClient(opts ...Option) (*Client, error) {
  39  	baseURL, _ := url.Parse(defaultBaseURL)
  40  
  41  	client := &Client{
  42  		baseURL:    baseURL,
  43  		HTTPClient: &http.Client{Timeout: 10 * time.Second},
  44  	}
  45  
  46  	for _, opt := range opts {
  47  		err := opt(client)
  48  		if err != nil {
  49  			return nil, err
  50  		}
  51  	}
  52  
  53  	if client.authToken != "" {
  54  		return client, nil
  55  	}
  56  
  57  	if client.authEmail == "" && client.authKey == "" {
  58  		return nil, errors.New("invalid credentials: authEmail, authKey or authToken must be set")
  59  	}
  60  
  61  	if client.authEmail == "" || client.authKey == "" {
  62  		return nil, errors.New("invalid credentials: authEmail and authKey must be set together")
  63  	}
  64  
  65  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
  66  
  67  	return client, nil
  68  }
  69  
  70  // CreateDNSRecord creates a new DNS record for a zone.
  71  // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/
  72  func (c *Client) CreateDNSRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
  73  	endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records")
  74  
  75  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
  76  	if err != nil {
  77  		return nil, err
  78  	}
  79  
  80  	var result APIResponse[Record]
  81  
  82  	err = c.do(req, &result)
  83  	if err != nil {
  84  		return nil, err
  85  	}
  86  
  87  	return &result.Result, nil
  88  }
  89  
  90  // DeleteDNSRecord deletes DNS record.
  91  // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/
  92  func (c *Client) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
  93  	endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records", recordID)
  94  
  95  	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
  96  	if err != nil {
  97  		return err
  98  	}
  99  
 100  	return c.do(req, nil)
 101  }
 102  
 103  // ZonesByName returns a list of zones matching the given name.
 104  // https://developers.cloudflare.com/api/resources/zones/methods/list/
 105  func (c *Client) ZonesByName(ctx context.Context, name string) ([]Zone, error) {
 106  	endpoint := c.baseURL.JoinPath("zones")
 107  
 108  	query := endpoint.Query()
 109  	query.Set("name", name)
 110  	query.Set("per_page", "50")
 111  	endpoint.RawQuery = query.Encode()
 112  
 113  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 114  	if err != nil {
 115  		return nil, err
 116  	}
 117  
 118  	var result APIResponse[[]Zone]
 119  
 120  	err = c.do(req, &result)
 121  	if err != nil {
 122  		return nil, err
 123  	}
 124  
 125  	return result.Result, nil
 126  }
 127  
 128  func (c *Client) do(req *http.Request, result any) error {
 129  	// https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/
 130  	if c.authToken != "" {
 131  		req.Header.Set("Authorization", "Bearer "+c.authToken)
 132  	} else {
 133  		req.Header.Set("X-Auth-Email", c.authEmail)
 134  		req.Header.Set("X-Auth-Key", c.authKey)
 135  	}
 136  
 137  	useragent.SetHeader(req.Header)
 138  
 139  	resp, err := c.HTTPClient.Do(req)
 140  	if err != nil {
 141  		return errutils.NewHTTPDoError(req, err)
 142  	}
 143  
 144  	defer func() { _ = resp.Body.Close() }()
 145  
 146  	if resp.StatusCode/100 != 2 {
 147  		return parseError(req, resp)
 148  	}
 149  
 150  	if result == nil {
 151  		return nil
 152  	}
 153  
 154  	raw, err := io.ReadAll(resp.Body)
 155  	if err != nil {
 156  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 157  	}
 158  
 159  	err = json.Unmarshal(raw, result)
 160  	if err != nil {
 161  		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 162  	}
 163  
 164  	return nil
 165  }
 166  
 167  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 168  	buf := new(bytes.Buffer)
 169  
 170  	if payload != nil {
 171  		err := json.NewEncoder(buf).Encode(payload)
 172  		if err != nil {
 173  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 174  		}
 175  	}
 176  
 177  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 178  	if err != nil {
 179  		return nil, fmt.Errorf("unable to create request: %w", err)
 180  	}
 181  
 182  	req.Header.Set("Accept", "application/json")
 183  
 184  	if payload != nil {
 185  		req.Header.Set("Content-Type", "application/json")
 186  	}
 187  
 188  	return req, nil
 189  }
 190  
 191  func parseError(req *http.Request, resp *http.Response) error {
 192  	raw, _ := io.ReadAll(resp.Body)
 193  
 194  	var response APIResponse[any]
 195  
 196  	err := json.Unmarshal(raw, &response)
 197  	if err != nil {
 198  		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 199  	}
 200  
 201  	return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Errors)
 202  }
 203