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