client.go raw

   1  package internal
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"crypto/hmac"
   7  	"crypto/sha1"
   8  	"encoding/hex"
   9  	"encoding/json"
  10  	"errors"
  11  	"fmt"
  12  	"io"
  13  	"net/http"
  14  	"net/url"
  15  	"strconv"
  16  	"time"
  17  
  18  	"github.com/go-acme/lego/v4/challenge/dns01"
  19  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  20  )
  21  
  22  // Default API endpoints.
  23  const (
  24  	DefaultSandboxBaseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0"
  25  	DefaultProdBaseURL    = "https://api.dnsmadeeasy.com/V2.0"
  26  )
  27  
  28  // Client DNSMadeEasy client.
  29  type Client struct {
  30  	apiKey    string
  31  	apiSecret string
  32  
  33  	BaseURL    *url.URL
  34  	HTTPClient *http.Client
  35  }
  36  
  37  // NewClient creates a DNSMadeEasy client.
  38  func NewClient(apiKey, apiSecret string) (*Client, error) {
  39  	if apiKey == "" {
  40  		return nil, errors.New("credentials missing: API key")
  41  	}
  42  
  43  	if apiSecret == "" {
  44  		return nil, errors.New("credentials missing: API secret")
  45  	}
  46  
  47  	baseURL, _ := url.Parse(DefaultProdBaseURL)
  48  
  49  	return &Client{
  50  		apiKey:     apiKey,
  51  		apiSecret:  apiSecret,
  52  		BaseURL:    baseURL,
  53  		HTTPClient: &http.Client{Timeout: 5 * time.Second},
  54  	}, nil
  55  }
  56  
  57  // GetDomain gets a domain.
  58  func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) {
  59  	endpoint := c.BaseURL.JoinPath("dns", "managed", "name")
  60  
  61  	query := endpoint.Query()
  62  	query.Set("domainname", dns01.UnFqdn(authZone))
  63  	endpoint.RawQuery = query.Encode()
  64  
  65  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
  66  	if err != nil {
  67  		return nil, err
  68  	}
  69  
  70  	domain := &Domain{}
  71  
  72  	err = c.do(req, domain)
  73  	if err != nil {
  74  		return nil, err
  75  	}
  76  
  77  	return domain, nil
  78  }
  79  
  80  // GetRecords gets all TXT records.
  81  func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, recordType string) (*[]Record, error) {
  82  	endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records")
  83  
  84  	query := endpoint.Query()
  85  	query.Set("recordName", recordName)
  86  	query.Set("type", recordType)
  87  	endpoint.RawQuery = query.Encode()
  88  
  89  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
  90  	if err != nil {
  91  		return nil, err
  92  	}
  93  
  94  	records := &recordsResponse{}
  95  
  96  	err = c.do(req, records)
  97  	if err != nil {
  98  		return nil, err
  99  	}
 100  
 101  	return records.Records, nil
 102  }
 103  
 104  // CreateRecord creates a TXT records.
 105  func (c *Client) CreateRecord(ctx context.Context, domain *Domain, record *Record) error {
 106  	endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records")
 107  
 108  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 109  	if err != nil {
 110  		return err
 111  	}
 112  
 113  	return c.do(req, nil)
 114  }
 115  
 116  // DeleteRecord deletes a TXT records.
 117  func (c *Client) DeleteRecord(ctx context.Context, record Record) error {
 118  	endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(record.SourceID), "records", strconv.Itoa(record.ID))
 119  
 120  	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 121  	if err != nil {
 122  		return err
 123  	}
 124  
 125  	return c.do(req, nil)
 126  }
 127  
 128  func (c *Client) do(req *http.Request, result any) error {
 129  	err := c.sign(req, time.Now().UTC().Format(time.RFC1123))
 130  	if err != nil {
 131  		return err
 132  	}
 133  
 134  	resp, err := c.HTTPClient.Do(req)
 135  	if err != nil {
 136  		return errutils.NewHTTPDoError(req, err)
 137  	}
 138  
 139  	defer func() { _ = resp.Body.Close() }()
 140  
 141  	if resp.StatusCode/100 != 2 {
 142  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 143  	}
 144  
 145  	if result == nil {
 146  		return nil
 147  	}
 148  
 149  	raw, err := io.ReadAll(resp.Body)
 150  	if err != nil {
 151  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 152  	}
 153  
 154  	if err = json.Unmarshal(raw, result); err != nil {
 155  		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 156  	}
 157  
 158  	return nil
 159  }
 160  
 161  func (c *Client) sign(req *http.Request, timestamp string) error {
 162  	signature, err := computeHMAC(timestamp, c.apiSecret)
 163  	if err != nil {
 164  		return err
 165  	}
 166  
 167  	req.Header.Set("x-dnsme-apiKey", c.apiKey)
 168  	req.Header.Set("x-dnsme-requestDate", timestamp)
 169  	req.Header.Set("x-dnsme-hmac", signature)
 170  
 171  	return nil
 172  }
 173  
 174  func computeHMAC(message, secret string) (string, error) {
 175  	key := []byte(secret)
 176  	h := hmac.New(sha1.New, key)
 177  
 178  	_, err := h.Write([]byte(message))
 179  	if err != nil {
 180  		return "", err
 181  	}
 182  
 183  	return hex.EncodeToString(h.Sum(nil)), nil
 184  }
 185  
 186  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 187  	buf := new(bytes.Buffer)
 188  
 189  	if payload != nil {
 190  		err := json.NewEncoder(buf).Encode(payload)
 191  		if err != nil {
 192  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 193  		}
 194  	}
 195  
 196  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 197  	if err != nil {
 198  		return nil, fmt.Errorf("unable to create request: %w", err)
 199  	}
 200  
 201  	req.Header.Set("Accept", "application/json")
 202  
 203  	if payload != nil {
 204  		req.Header.Set("Content-Type", "application/json")
 205  	}
 206  
 207  	return req, nil
 208  }
 209