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