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