client.go raw
1 package goacmedns
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/url"
12 "runtime"
13 "time"
14 )
15
16 // defaultTimeout is used for the httpClient Timeout settings.
17 const defaultTimeout = 30 * time.Second
18
19 // ua is a custom user-agent identifier.
20 const ua = "goacmedns"
21
22 // userAgent returns a string that can be used as a HTTP request `User-Agent`
23 // header. It includes the `ua` string alongside the OS and architecture of the
24 // system.
25 func userAgent() string {
26 return fmt.Sprintf("%s (%s; %s)", ua, runtime.GOOS, runtime.GOARCH)
27 }
28
29 type Register struct {
30 AllowFrom []string `json:"allowfrom"`
31 }
32
33 type Update struct {
34 SubDomain string `json:"subdomain"`
35 Txt string `json:"txt"`
36 }
37
38 // Storage is an interface describing the required functions for an ACME DNS
39 // Account storage mechanism.
40 type Storage interface {
41 // Save will persist the `Account` data that has been `Put` so far
42 Save(ctx context.Context) error
43 // Put will add an `Account` for the given domain to the storage. It may not
44 // be persisted until `Save` is called.
45 Put(ctx context.Context, domain string, account Account) error
46 // Fetch will retrieve an `Account` for the given domain from the storage. If
47 // the provided domain does not have an `Account` saved in the storage
48 // `ErrDomainNotFound` will be returned
49 Fetch(ctx context.Context, domain string) (Account, error)
50 // FetchAll retrieves all the `Account` objects from the storage and
51 // returns a map that has domain names as its keys and `Account` objects
52 // as values.
53 FetchAll(ctx context.Context) (map[string]Account, error)
54 }
55
56 type Option func(c *Client)
57
58 func WithHTTPClient(client *http.Client) Option {
59 return func(c *Client) {
60 if c != nil {
61 c.httpClient = client
62 }
63 }
64 }
65
66 type Client struct {
67 httpClient *http.Client
68 baseURL *url.URL
69 }
70
71 func NewClient(baseURL string, opts ...Option) (*Client, error) {
72 endpoint, err := url.Parse(baseURL)
73 if err != nil {
74 return nil, fmt.Errorf("could not parse base URL: %w", err)
75 }
76
77 client := &Client{
78 httpClient: &http.Client{
79 CheckRedirect: nil,
80 Jar: nil,
81 Timeout: defaultTimeout,
82 Transport: &http.Transport{
83 Proxy: http.ProxyFromEnvironment,
84 DialContext: (&net.Dialer{
85 Timeout: defaultTimeout,
86 KeepAlive: defaultTimeout,
87 }).DialContext,
88 TLSHandshakeTimeout: defaultTimeout,
89 ResponseHeaderTimeout: defaultTimeout,
90 ExpectContinueTimeout: 1 * time.Second,
91 },
92 },
93 baseURL: endpoint,
94 }
95
96 for _, opt := range opts {
97 opt(client)
98 }
99
100 return client, nil
101 }
102
103 func (c *Client) RegisterAccount(ctx context.Context, allowFrom []string) (Account, error) {
104 var register *Register
105 if len(allowFrom) > 0 {
106 register = &Register{AllowFrom: allowFrom}
107 }
108
109 req, err := newRequest(ctx, c.baseURL.JoinPath("register"), nil, register)
110 if err != nil {
111 return Account{}, err
112 }
113
114 var acct Account
115
116 err = c.do(req, &acct)
117 if err != nil {
118 return Account{}, fmt.Errorf("failed to register account: %w", err)
119 }
120
121 acct.ServerURL = c.baseURL.String()
122
123 return acct, nil
124 }
125
126 func (c *Client) UpdateTXTRecord(ctx context.Context, account Account, value string) error {
127 update := &Update{
128 SubDomain: account.SubDomain,
129 Txt: value,
130 }
131
132 headers := map[string]string{
133 "X-Api-User": account.Username,
134 "X-Api-Key": account.Password,
135 }
136
137 req, err := newRequest(ctx, c.baseURL.JoinPath("update"), headers, update)
138 if err != nil {
139 return err
140 }
141
142 err = c.do(req, nil)
143 if err != nil {
144 return fmt.Errorf("failed to update TXT record: %w", err)
145 }
146
147 return nil
148 }
149
150 func (c *Client) do(req *http.Request, result any) error {
151 resp, err := c.httpClient.Do(req)
152 if err != nil {
153 return fmt.Errorf("failed to do req: %w", err)
154 }
155
156 defer func() { _ = resp.Body.Close() }()
157
158 if resp.StatusCode/100 != 2 {
159 raw, _ := io.ReadAll(resp.Body)
160
161 return newClientError("response error", resp.StatusCode, raw)
162 }
163
164 if result == nil {
165 return nil
166 }
167
168 raw, err := io.ReadAll(resp.Body)
169 if err != nil {
170 return fmt.Errorf("failed to read body: %w", err)
171 }
172
173 err = json.Unmarshal(raw, result)
174 if err != nil {
175 return newClientError("failed to unmarshal response", resp.StatusCode, raw)
176 }
177
178 return nil
179 }
180
181 func newRequest(ctx context.Context, endpoint *url.URL, headers map[string]string, payload any) (*http.Request, error) {
182 buf := new(bytes.Buffer)
183
184 if payload != nil {
185 err := json.NewEncoder(buf).Encode(payload)
186 if err != nil {
187 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
188 }
189 }
190
191 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), buf)
192 if err != nil {
193 return nil, fmt.Errorf("unable to create request: %w", err)
194 }
195
196 req.Header.Set("Accept", "application/json")
197 req.Header.Set("User-Agent", userAgent())
198
199 for h, v := range headers {
200 req.Header.Set(h, v)
201 }
202
203 if payload != nil {
204 req.Header.Set("Content-Type", "application/json")
205 }
206
207 return req, nil
208 }
209