client.go raw

   1  package internal
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"fmt"
   7  	"io"
   8  	"log"
   9  	"net/http"
  10  	"net/url"
  11  	"strings"
  12  	"sync"
  13  	"time"
  14  
  15  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  16  	"golang.org/x/time/rate"
  17  )
  18  
  19  const defaultBaseURL = "https://dyn.dns.he.net/nic/update"
  20  
  21  const (
  22  	codeGood     = "good"
  23  	codeNoChg    = "nochg"
  24  	codeAbuse    = "abuse"
  25  	codeBadAgent = "badagent"
  26  	codeBadAuth  = "badauth"
  27  	codeInterval = "interval"
  28  	codeNoHost   = "nohost"
  29  	codeNotFqdn  = "notfqdn"
  30  )
  31  
  32  const defaultBurst = 5
  33  
  34  // Client the Hurricane Electric client.
  35  type Client struct {
  36  	HTTPClient   *http.Client
  37  	rateLimiters sync.Map
  38  
  39  	baseURL string
  40  
  41  	credentials map[string]string
  42  	credMu      sync.Mutex
  43  }
  44  
  45  // NewClient Creates a new Client.
  46  func NewClient(credentials map[string]string) *Client {
  47  	return &Client{
  48  		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
  49  		baseURL:     defaultBaseURL,
  50  		credentials: credentials,
  51  	}
  52  }
  53  
  54  // UpdateTxtRecord updates a TXT record.
  55  func (c *Client) UpdateTxtRecord(ctx context.Context, hostname, txt string) error {
  56  	domain := strings.TrimPrefix(hostname, "_acme-challenge.")
  57  
  58  	c.credMu.Lock()
  59  	token, ok := c.credentials[domain]
  60  	c.credMu.Unlock()
  61  
  62  	if !ok {
  63  		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain)
  64  	}
  65  
  66  	data := url.Values{}
  67  	data.Set("password", token)
  68  	data.Set("hostname", hostname)
  69  	data.Set("txt", txt)
  70  
  71  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(data.Encode()))
  72  	if err != nil {
  73  		return fmt.Errorf("unable to create request: %w", err)
  74  	}
  75  
  76  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  77  
  78  	rl, _ := c.rateLimiters.LoadOrStore(hostname, rate.NewLimiter(limit(defaultBurst), defaultBurst))
  79  
  80  	err = rl.(*rate.Limiter).Wait(ctx)
  81  	if err != nil {
  82  		return err
  83  	}
  84  
  85  	resp, err := c.HTTPClient.Do(req)
  86  	if err != nil {
  87  		return errutils.NewHTTPDoError(req, err)
  88  	}
  89  
  90  	defer func() { _ = resp.Body.Close() }()
  91  
  92  	if resp.StatusCode != http.StatusOK {
  93  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
  94  	}
  95  
  96  	raw, err := io.ReadAll(resp.Body)
  97  	if err != nil {
  98  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
  99  	}
 100  
 101  	return evaluateBody(string(bytes.TrimSpace(raw)), hostname)
 102  }
 103  
 104  func evaluateBody(body, hostname string) error {
 105  	code, _, _ := strings.Cut(body, " ")
 106  
 107  	switch code {
 108  	case codeGood:
 109  		return nil
 110  	case codeNoChg:
 111  		log.Printf("%s: unchanged content written to TXT record %s", body, hostname)
 112  		return nil
 113  	case codeAbuse:
 114  		return fmt.Errorf("%s: blocked hostname for abuse: %s", body, hostname)
 115  	case codeBadAgent:
 116  		return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on GitHub", body)
 117  	case codeBadAuth:
 118  		return fmt.Errorf("%s: wrong authentication token provided for TXT record %s", body, hostname)
 119  	case codeInterval:
 120  		return fmt.Errorf("%s: TXT records update exceeded API rate limit", body)
 121  	case codeNoHost:
 122  		return fmt.Errorf("%s: the record provided does not exist in this account: %s", body, hostname)
 123  	case codeNotFqdn:
 124  		return fmt.Errorf("%s: the record provided isn't an FQDN: %s", body, hostname)
 125  	default:
 126  		// This is basically only server errors.
 127  		return fmt.Errorf("attempt to change TXT record %s returned %s", hostname, body)
 128  	}
 129  }
 130  
 131  // limit computes the rate based on burst.
 132  // The API rate limit per-record is 10 reqs / 2 minutes.
 133  //
 134  //	10 reqs / 2 minutes = freq 1/12 (burst = 1)
 135  //	6 reqs / 2 minutes = freq 1/20 (burst = 5)
 136  //
 137  // https://github.com/go-acme/lego/issues/1415
 138  func limit(burst int) rate.Limit {
 139  	return 1 / rate.Limit(120/(10-burst+1))
 140  }
 141