client.go raw

   1  package internal
   2  
   3  import (
   4  	"context"
   5  	"crypto/sha1"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"math/rand"
  10  	"net/http"
  11  	"net/url"
  12  	"strconv"
  13  	"strings"
  14  	"time"
  15  
  16  	"github.com/go-acme/lego/v4/challenge/dns01"
  17  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  18  	querystring "github.com/google/go-querystring/query"
  19  )
  20  
  21  const apiURL = "https://api.nearlyfreespeech.net"
  22  
  23  const authenticationHeader = "X-NFSN-Authentication"
  24  
  25  const saltBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  26  
  27  type Client struct {
  28  	login  string
  29  	apiKey string
  30  
  31  	signer *Signer
  32  
  33  	baseURL    *url.URL
  34  	HTTPClient *http.Client
  35  }
  36  
  37  func NewClient(login, apiKey string) *Client {
  38  	baseURL, _ := url.Parse(apiURL)
  39  
  40  	return &Client{
  41  		login:      login,
  42  		apiKey:     apiKey,
  43  		signer:     NewSigner(),
  44  		baseURL:    baseURL,
  45  		HTTPClient: &http.Client{Timeout: 10 * time.Second},
  46  	}
  47  }
  48  
  49  func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error {
  50  	endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "addRR")
  51  
  52  	params, err := querystring.Values(record)
  53  	if err != nil {
  54  		return err
  55  	}
  56  
  57  	return c.doRequest(ctx, endpoint, params)
  58  }
  59  
  60  func (c *Client) RemoveRecord(ctx context.Context, domain string, record Record) error {
  61  	endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "removeRR")
  62  
  63  	params, err := querystring.Values(record)
  64  	if err != nil {
  65  		return err
  66  	}
  67  
  68  	return c.doRequest(ctx, endpoint, params)
  69  }
  70  
  71  func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error {
  72  	payload := params.Encode()
  73  
  74  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload))
  75  	if err != nil {
  76  		return fmt.Errorf("unable to create request: %w", err)
  77  	}
  78  
  79  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  80  	req.Header.Set(authenticationHeader, c.signer.Sign(endpoint.Path, payload, c.login, c.apiKey))
  81  
  82  	resp, err := c.HTTPClient.Do(req)
  83  	if err != nil {
  84  		return errutils.NewHTTPDoError(req, err)
  85  	}
  86  
  87  	defer func() { _ = resp.Body.Close() }()
  88  
  89  	if resp.StatusCode != http.StatusOK {
  90  		return parseError(req, resp)
  91  	}
  92  
  93  	return nil
  94  }
  95  
  96  func parseError(req *http.Request, resp *http.Response) error {
  97  	raw, _ := io.ReadAll(resp.Body)
  98  
  99  	errAPI := &APIError{}
 100  
 101  	err := json.Unmarshal(raw, errAPI)
 102  	if err != nil {
 103  		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 104  	}
 105  
 106  	return errAPI
 107  }
 108  
 109  type Signer struct {
 110  	saltShaker func() []byte
 111  	clock      func() time.Time
 112  }
 113  
 114  func NewSigner() *Signer {
 115  	return &Signer{saltShaker: getRandomSalt, clock: time.Now}
 116  }
 117  
 118  func (c Signer) Sign(uri, body, login, apiKey string) string {
 119  	// Header is "login;timestamp;salt;hash".
 120  	// hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash")
 121  	// and body-hash is SHA1(body).
 122  	bodyHash := sha1.Sum([]byte(body))
 123  	timestamp := strconv.FormatInt(c.clock().Unix(), 10)
 124  
 125  	// Workaround for https://golang.org/issue/58605
 126  	uri = "/" + strings.TrimLeft(uri, "/")
 127  
 128  	salt := c.saltShaker()
 129  
 130  	hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", login, timestamp, salt, apiKey, uri, bodyHash)
 131  
 132  	return fmt.Sprintf("%s;%s;%s;%02x", login, timestamp, salt, sha1.Sum([]byte(hashInput)))
 133  }
 134  
 135  func getRandomSalt() []byte {
 136  	// This is the only part of this that needs to be serialized.
 137  	salt := make([]byte, 16)
 138  	for i := range 16 {
 139  		salt[i] = saltBytes[rand.Intn(len(saltBytes))]
 140  	}
 141  
 142  	return salt
 143  }
 144