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  	"sync"
  13  	"time"
  14  
  15  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  16  )
  17  
  18  // Default API endpoints.
  19  const (
  20  	APIBaseURL  = "https://api.mythic-beasts.com/dns/v2"
  21  	AuthBaseURL = "https://auth.mythic-beasts.com/login"
  22  )
  23  
  24  // Client the Mythic Beasts API client.
  25  type Client struct {
  26  	username string
  27  	password string
  28  
  29  	APIEndpoint  *url.URL
  30  	AuthEndpoint *url.URL
  31  	HTTPClient   *http.Client
  32  
  33  	token   *Token
  34  	muToken sync.Mutex
  35  }
  36  
  37  // NewClient Creates a new Client.
  38  func NewClient(username, password string) *Client {
  39  	apiEndpoint, _ := url.Parse(APIBaseURL)
  40  	authEndpoint, _ := url.Parse(AuthBaseURL)
  41  
  42  	return &Client{
  43  		username:     username,
  44  		password:     password,
  45  		APIEndpoint:  apiEndpoint,
  46  		AuthEndpoint: authEndpoint,
  47  		HTTPClient:   &http.Client{Timeout: 5 * time.Second},
  48  	}
  49  }
  50  
  51  // CreateTXTRecord creates a TXT record.
  52  // https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords
  53  func (c *Client) CreateTXTRecord(ctx context.Context, zone, leaf, value string, ttl int) error {
  54  	resp, err := c.createTXTRecord(ctx, zone, leaf, "TXT", value, ttl)
  55  	if err != nil {
  56  		return err
  57  	}
  58  
  59  	if resp.Added != 1 {
  60  		return fmt.Errorf("did not add TXT record for some reason: %s", resp.Message)
  61  	}
  62  
  63  	// Success
  64  	return nil
  65  }
  66  
  67  // RemoveTXTRecord removes a TXT records.
  68  // https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
  69  func (c *Client) RemoveTXTRecord(ctx context.Context, zone, leaf, value string) error {
  70  	resp, err := c.removeTXTRecord(ctx, zone, leaf, "TXT", value)
  71  	if err != nil {
  72  		return err
  73  	}
  74  
  75  	if resp.Removed != 1 {
  76  		return fmt.Errorf("did not remove TXT record for some reason: %s", resp.Message)
  77  	}
  78  
  79  	// Success
  80  	return nil
  81  }
  82  
  83  // https://www.mythic-beasts.com/support/api/dnsv2#ep-post-zoneszonerecords
  84  func (c *Client) createTXTRecord(ctx context.Context, zone, leaf, recordType, value string, ttl int) (*createTXTResponse, error) {
  85  	endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType)
  86  
  87  	createReq := createTXTRequest{
  88  		Records: []createTXTRecord{{
  89  			Host: leaf,
  90  			TTL:  ttl,
  91  			Type: "TXT",
  92  			Data: value,
  93  		}},
  94  	}
  95  
  96  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, createReq)
  97  	if err != nil {
  98  		return nil, err
  99  	}
 100  
 101  	resp := &createTXTResponse{}
 102  
 103  	err = c.do(req, resp)
 104  	if err != nil {
 105  		return nil, err
 106  	}
 107  
 108  	return resp, nil
 109  }
 110  
 111  // https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
 112  func (c *Client) removeTXTRecord(ctx context.Context, zone, leaf, recordType, value string) (*deleteTXTResponse, error) {
 113  	endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType)
 114  
 115  	query := endpoint.Query()
 116  	query.Add("data", value)
 117  	endpoint.RawQuery = query.Encode()
 118  
 119  	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 120  	if err != nil {
 121  		return nil, err
 122  	}
 123  
 124  	resp := &deleteTXTResponse{}
 125  
 126  	err = c.do(req, resp)
 127  	if err != nil {
 128  		return nil, err
 129  	}
 130  
 131  	return resp, nil
 132  }
 133  
 134  func (c *Client) do(req *http.Request, result any) error {
 135  	tok := getToken(req.Context())
 136  	if tok != nil {
 137  		req.Header.Set("Authorization", "Bearer "+tok.Token)
 138  	} else {
 139  		return errors.New("not logged in")
 140  	}
 141  
 142  	resp, err := c.HTTPClient.Do(req)
 143  	if err != nil {
 144  		return errutils.NewHTTPDoError(req, err)
 145  	}
 146  
 147  	defer func() { _ = resp.Body.Close() }()
 148  
 149  	if resp.StatusCode != http.StatusOK {
 150  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 151  	}
 152  
 153  	raw, err := io.ReadAll(resp.Body)
 154  	if err != nil {
 155  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 156  	}
 157  
 158  	err = json.Unmarshal(raw, result)
 159  	if err != nil {
 160  		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 161  	}
 162  
 163  	return nil
 164  }
 165  
 166  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 167  	buf := new(bytes.Buffer)
 168  
 169  	if payload != nil {
 170  		err := json.NewEncoder(buf).Encode(payload)
 171  		if err != nil {
 172  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 173  		}
 174  	}
 175  
 176  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 177  	if err != nil {
 178  		return nil, fmt.Errorf("unable to create request: %w", err)
 179  	}
 180  
 181  	req.Header.Set("Accept", "application/json")
 182  
 183  	if payload != nil {
 184  		req.Header.Set("Content-Type", "application/json")
 185  	}
 186  
 187  	return req, nil
 188  }
 189