client.go raw
1 package internal
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strconv"
12 "time"
13
14 "github.com/go-acme/lego/v4/challenge/dns01"
15 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
16 )
17
18 const defaultBaseURL = "https://api.cloudns.net/dns/"
19
20 // Client the ClouDNS client.
21 type Client struct {
22 authID string
23 subAuthID string
24 authPassword string
25
26 BaseURL *url.URL
27 HTTPClient *http.Client
28 }
29
30 // NewClient creates a ClouDNS client.
31 func NewClient(authID, subAuthID, authPassword string) (*Client, error) {
32 if authID == "" && subAuthID == "" {
33 return nil, errors.New("credentials missing: authID or subAuthID")
34 }
35
36 if authPassword == "" {
37 return nil, errors.New("credentials missing: authPassword")
38 }
39
40 baseURL, err := url.Parse(defaultBaseURL)
41 if err != nil {
42 return nil, err
43 }
44
45 return &Client{
46 authID: authID,
47 subAuthID: subAuthID,
48 authPassword: authPassword,
49 BaseURL: baseURL,
50 HTTPClient: &http.Client{Timeout: 10 * time.Second},
51 }, nil
52 }
53
54 // GetZone Get domain name information for a FQDN.
55 func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) {
56 authZone, err := dns01.FindZoneByFqdn(authFQDN)
57 if err != nil {
58 return nil, fmt.Errorf("could not find zone: %w", err)
59 }
60
61 authZoneName := dns01.UnFqdn(authZone)
62
63 endpoint := c.BaseURL.JoinPath("get-zone-info.json")
64
65 q := endpoint.Query()
66 q.Set("domain-name", authZoneName)
67 endpoint.RawQuery = q.Encode()
68
69 req, err := c.newRequest(ctx, http.MethodGet, endpoint)
70 if err != nil {
71 return nil, err
72 }
73
74 rawMessage, err := c.do(req)
75 if err != nil {
76 return nil, err
77 }
78
79 var zone Zone
80
81 if len(rawMessage) > 0 {
82 if err = json.Unmarshal(rawMessage, &zone); err != nil {
83 return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
84 }
85 }
86
87 if zone.Name == authZoneName {
88 return &zone, nil
89 }
90
91 return nil, fmt.Errorf("zone %s not found for authFQDN %s", authZoneName, authFQDN)
92 }
93
94 // FindTxtRecord returns the TXT record a zone ID and a FQDN.
95 func (c *Client) FindTxtRecord(ctx context.Context, zoneName, fqdn string) (*TXTRecord, error) {
96 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
97 if err != nil {
98 return nil, err
99 }
100
101 endpoint := c.BaseURL.JoinPath("records.json")
102
103 q := endpoint.Query()
104 q.Set("domain-name", zoneName)
105 q.Set("host", subDomain)
106 q.Set("type", "TXT")
107 endpoint.RawQuery = q.Encode()
108
109 req, err := c.newRequest(ctx, http.MethodGet, endpoint)
110 if err != nil {
111 return nil, err
112 }
113
114 rawMessage, err := c.do(req)
115 if err != nil {
116 return nil, err
117 }
118
119 // the API returns [] when there is no records.
120 if string(rawMessage) == "[]" {
121 return nil, nil
122 }
123
124 var records map[string]TXTRecord
125 if err = json.Unmarshal(rawMessage, &records); err != nil {
126 return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
127 }
128
129 for _, record := range records {
130 if record.Host == subDomain && record.Type == "TXT" {
131 return &record, nil
132 }
133 }
134
135 return nil, nil
136 }
137
138 // ListTxtRecords returns the TXT records a zone ID and a FQDN.
139 func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]TXTRecord, error) {
140 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
141 if err != nil {
142 return nil, err
143 }
144
145 endpoint := c.BaseURL.JoinPath("records.json")
146
147 q := endpoint.Query()
148 q.Set("domain-name", zoneName)
149 q.Set("host", subDomain)
150 q.Set("type", "TXT")
151 endpoint.RawQuery = q.Encode()
152
153 req, err := c.newRequest(ctx, http.MethodGet, endpoint)
154 if err != nil {
155 return nil, err
156 }
157
158 rawMessage, err := c.do(req)
159 if err != nil {
160 return nil, err
161 }
162
163 // the API returns [] when there is no records.
164 if string(rawMessage) == "[]" {
165 return nil, nil
166 }
167
168 var raw map[string]TXTRecord
169 if err = json.Unmarshal(rawMessage, &raw); err != nil {
170 return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
171 }
172
173 var records []TXTRecord
174
175 for _, record := range raw {
176 if record.Host == subDomain && record.Type == "TXT" {
177 records = append(records, record)
178 }
179 }
180
181 return records, nil
182 }
183
184 // AddTxtRecord adds a TXT record.
185 func (c *Client) AddTxtRecord(ctx context.Context, zoneName, fqdn, value string, ttl int) error {
186 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
187 if err != nil {
188 return err
189 }
190
191 endpoint := c.BaseURL.JoinPath("add-record.json")
192
193 q := endpoint.Query()
194 q.Set("domain-name", zoneName)
195 q.Set("host", subDomain)
196 q.Set("record", value)
197 q.Set("ttl", strconv.Itoa(ttlRounder(ttl)))
198 q.Set("record-type", "TXT")
199 endpoint.RawQuery = q.Encode()
200
201 req, err := c.newRequest(ctx, http.MethodPost, endpoint)
202 if err != nil {
203 return err
204 }
205
206 rawMessage, err := c.do(req)
207 if err != nil {
208 return err
209 }
210
211 resp := apiResponse{}
212 if err = json.Unmarshal(rawMessage, &resp); err != nil {
213 return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
214 }
215
216 if resp.Status != "Success" {
217 return fmt.Errorf("failed to add TXT record: %s %s", resp.Status, resp.StatusDescription)
218 }
219
220 return nil
221 }
222
223 // RemoveTxtRecord removes a TXT record.
224 func (c *Client) RemoveTxtRecord(ctx context.Context, recordID int, zoneName string) error {
225 endpoint := c.BaseURL.JoinPath("delete-record.json")
226
227 q := endpoint.Query()
228 q.Set("domain-name", zoneName)
229 q.Set("record-id", strconv.Itoa(recordID))
230 endpoint.RawQuery = q.Encode()
231
232 req, err := c.newRequest(ctx, http.MethodPost, endpoint)
233 if err != nil {
234 return err
235 }
236
237 rawMessage, err := c.do(req)
238 if err != nil {
239 return err
240 }
241
242 resp := apiResponse{}
243 if err = json.Unmarshal(rawMessage, &resp); err != nil {
244 return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
245 }
246
247 if resp.Status != "Success" {
248 return fmt.Errorf("failed to remove TXT record: %s %s", resp.Status, resp.StatusDescription)
249 }
250
251 return nil
252 }
253
254 // GetUpdateStatus gets sync progress of all CloudDNS NS servers.
255 func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncProgress, error) {
256 endpoint := c.BaseURL.JoinPath("update-status.json")
257
258 q := endpoint.Query()
259 q.Set("domain-name", zoneName)
260 endpoint.RawQuery = q.Encode()
261
262 req, err := c.newRequest(ctx, http.MethodGet, endpoint)
263 if err != nil {
264 return nil, err
265 }
266
267 rawMessage, err := c.do(req)
268 if err != nil {
269 return nil, err
270 }
271
272 // the API returns [] when there is no records.
273 if string(rawMessage) == "[]" {
274 return nil, errors.New("no nameservers records returned")
275 }
276
277 var records []UpdateRecord
278 if err = json.Unmarshal(rawMessage, &records); err != nil {
279 return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
280 }
281
282 updatedCount := 0
283
284 for _, record := range records {
285 if record.Updated {
286 updatedCount++
287 }
288 }
289
290 return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil
291 }
292
293 func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) {
294 q := endpoint.Query()
295
296 if c.subAuthID != "" {
297 q.Set("sub-auth-id", c.subAuthID)
298 } else {
299 q.Set("auth-id", c.authID)
300 }
301
302 q.Set("auth-password", c.authPassword)
303
304 endpoint.RawQuery = q.Encode()
305
306 req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil)
307 if err != nil {
308 return nil, fmt.Errorf("unable to create request: %w", err)
309 }
310
311 return req, nil
312 }
313
314 func (c *Client) do(req *http.Request) (json.RawMessage, error) {
315 resp, err := c.HTTPClient.Do(req)
316 if err != nil {
317 return nil, errutils.NewHTTPDoError(req, err)
318 }
319
320 defer func() { _ = resp.Body.Close() }()
321
322 if resp.StatusCode != http.StatusOK {
323 return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
324 }
325
326 raw, err := io.ReadAll(resp.Body)
327 if err != nil {
328 return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
329 }
330
331 return raw, nil
332 }
333
334 // Rounds the given TTL in seconds to the next accepted value.
335 // Accepted TTL values are:
336 // - 60 = 1 minute
337 // - 300 = 5 minutes
338 // - 900 = 15 minutes
339 // - 1800 = 30 minutes
340 // - 3600 = 1 hour
341 // - 21600 = 6 hours
342 // - 43200 = 12 hours
343 // - 86400 = 1 day
344 // - 172800 = 2 days
345 // - 259200 = 3 days
346 // - 604800 = 1 week
347 // - 1209600 = 2 weeks
348 // - 2592000 = 1 month
349 //
350 // See https://www.cloudns.net/wiki/article/58/ for details.
351 func ttlRounder(ttl int) int {
352 for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} {
353 if ttl <= validTTL {
354 return validTTL
355 }
356 }
357
358 return 2592000
359 }
360