client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "time"
14
15 "github.com/cenkalti/backoff/v5"
16 "github.com/go-acme/lego/v4/log"
17 "github.com/go-acme/lego/v4/platform/wait"
18 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
19 )
20
21 const defaultBaseURL = "https://api.dynu.com/v2"
22
23 type Client struct {
24 baseURL *url.URL
25 HTTPClient *http.Client
26 }
27
28 func NewClient() *Client {
29 baseURL, _ := url.Parse(defaultBaseURL)
30
31 return &Client{
32 HTTPClient: &http.Client{Timeout: 5 * time.Second},
33 baseURL: baseURL,
34 }
35 }
36
37 // GetRecords Get DNS records based on a hostname and resource record type.
38 func (c *Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) {
39 endpoint := c.baseURL.JoinPath("dns", "record", hostname)
40
41 query := endpoint.Query()
42 query.Set("recordType", recordType)
43 endpoint.RawQuery = query.Encode()
44
45 apiResp := RecordsResponse{}
46
47 err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
48 if err != nil {
49 return nil, err
50 }
51
52 if apiResp.StatusCode/100 != 2 {
53 return nil, fmt.Errorf("API error: %w", apiResp.APIException)
54 }
55
56 return apiResp.DNSRecords, nil
57 }
58
59 // AddNewRecord Add a new DNS record for DNS service.
60 func (c *Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error {
61 endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record")
62
63 reqBody, err := json.Marshal(record)
64 if err != nil {
65 return fmt.Errorf("failed to create request JSON body: %w", err)
66 }
67
68 apiResp := RecordResponse{}
69
70 err = c.doRetry(ctx, http.MethodPost, endpoint.String(), reqBody, &apiResp)
71 if err != nil {
72 return err
73 }
74
75 if apiResp.StatusCode/100 != 2 {
76 return fmt.Errorf("API error: %w", apiResp.APIException)
77 }
78
79 return nil
80 }
81
82 // DeleteRecord Remove a DNS record from DNS service.
83 func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error {
84 endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10))
85
86 apiResp := APIException{}
87
88 err := c.doRetry(ctx, http.MethodDelete, endpoint.String(), nil, &apiResp)
89 if err != nil {
90 return err
91 }
92
93 if apiResp.StatusCode/100 != 2 {
94 return fmt.Errorf("API error: %w", apiResp)
95 }
96
97 return nil
98 }
99
100 // GetRootDomain Get the root domain name based on a hostname.
101 func (c *Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) {
102 endpoint := c.baseURL.JoinPath("dns", "getroot", hostname)
103
104 apiResp := DNSHostname{}
105
106 err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
107 if err != nil {
108 return nil, err
109 }
110
111 if apiResp.StatusCode/100 != 2 {
112 return nil, fmt.Errorf("API error: %w", apiResp.APIException)
113 }
114
115 return &apiResp, nil
116 }
117
118 // doRetry the API is really unstable, so we need to retry on EOF.
119 func (c *Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error {
120 operation := func() error {
121 return c.do(ctx, method, uri, body, result)
122 }
123
124 notify := func(err error, duration time.Duration) {
125 log.Printf("client retries because of %v", err)
126 }
127
128 bo := backoff.NewExponentialBackOff()
129 bo.InitialInterval = 1 * time.Second
130
131 return wait.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithNotify(notify))
132 }
133
134 func (c *Client) do(ctx context.Context, method, uri string, body []byte, result any) error {
135 var reqBody io.Reader
136 if len(body) > 0 {
137 reqBody = bytes.NewReader(body)
138 }
139
140 req, err := http.NewRequestWithContext(ctx, method, uri, reqBody)
141 if err != nil {
142 return fmt.Errorf("unable to create request: %w", err)
143 }
144
145 req.Header.Set("Accept", "application/json")
146 req.Header.Set("Content-Type", "application/json")
147
148 resp, err := c.HTTPClient.Do(req)
149 if errors.Is(err, io.EOF) {
150 return err
151 }
152
153 if err != nil {
154 return backoff.Permanent(fmt.Errorf("client error: %w", errutils.NewHTTPDoError(req, err)))
155 }
156
157 defer func() { _ = resp.Body.Close() }()
158
159 raw, err := io.ReadAll(resp.Body)
160 if err != nil {
161 return backoff.Permanent(errutils.NewReadResponseError(req, resp.StatusCode, err))
162 }
163
164 err = json.Unmarshal(raw, result)
165 if err != nil {
166 return backoff.Permanent(errutils.NewUnmarshalError(req, resp.StatusCode, raw, err))
167 }
168
169 return nil
170 }
171