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