client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "time"
12
13 "github.com/go-acme/lego/v4/challenge/dns01"
14 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
15 )
16
17 const AuthToken = "X-Auth-Token"
18
19 type Client struct {
20 token string
21
22 baseURL *url.URL
23 HTTPClient *http.Client
24 }
25
26 func NewClient(endpoint, token string) (*Client, error) {
27 baseURL, err := url.Parse(endpoint)
28 if err != nil {
29 return nil, err
30 }
31
32 return &Client{
33 token: token,
34 baseURL: baseURL,
35 HTTPClient: &http.Client{Timeout: 5 * time.Second},
36 }, nil
37 }
38
39 // AddRecord Adds one record to a specified domain.
40 // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records
41 func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error {
42 endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
43
44 records := Records{Records: []Record{record}}
45
46 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, records)
47 if err != nil {
48 return err
49 }
50
51 err = c.do(req, nil)
52 if err != nil {
53 return err
54 }
55
56 return nil
57 }
58
59 // DeleteRecord Deletes a record from the domain.
60 // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#delete-records
61 func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
62 endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
63
64 query := endpoint.Query()
65 query.Set("id", recordID)
66 endpoint.RawQuery = query.Encode()
67
68 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
69 if err != nil {
70 return err
71 }
72
73 err = c.do(req, nil)
74 if err != nil {
75 return err
76 }
77
78 return nil
79 }
80
81 // GetHostedZoneID performs a lookup to get the DNS zone which needs modifying for a given FQDN.
82 func (c *Client) GetHostedZoneID(ctx context.Context, fqdn string) (string, error) {
83 authZone, err := dns01.FindZoneByFqdn(fqdn)
84 if err != nil {
85 return "", fmt.Errorf("could not find zone: %w", err)
86 }
87
88 zoneSearchResponse, err := c.listDomainsByName(ctx, dns01.UnFqdn(authZone))
89 if err != nil {
90 return "", err
91 }
92
93 // If nothing was returned, or for whatever reason more than 1 was returned (the search uses exact match, so should not occur)
94 if zoneSearchResponse.TotalEntries != 1 {
95 return "", fmt.Errorf("found %d zones for %s in Rackspace for domain %s", zoneSearchResponse.TotalEntries, authZone, fqdn)
96 }
97
98 return zoneSearchResponse.HostedZones[0].ID, nil
99 }
100
101 // listDomainsByName Filters domains by domain name.
102 // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/domains#list-domains-by-name
103 func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSearchResponse, error) {
104 endpoint := c.baseURL.JoinPath("domains")
105
106 query := endpoint.Query()
107 query.Set("name", domain)
108 endpoint.RawQuery = query.Encode()
109
110 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
111 if err != nil {
112 return nil, err
113 }
114
115 var zoneSearchResponse ZoneSearchResponse
116
117 err = c.do(req, &zoneSearchResponse)
118 if err != nil {
119 return nil, err
120 }
121
122 return &zoneSearchResponse, nil
123 }
124
125 // FindTxtRecord searches a DNS zone for a TXT record with a specific name.
126 func (c *Client) FindTxtRecord(ctx context.Context, fqdn, zoneID string) (*Record, error) {
127 records, err := c.searchRecords(ctx, zoneID, dns01.UnFqdn(fqdn), "TXT")
128 if err != nil {
129 return nil, err
130 }
131
132 switch len(records.Records) {
133 case 1:
134 case 0:
135 return nil, fmt.Errorf("no TXT record found for %s", fqdn)
136 default:
137 return nil, fmt.Errorf("more than 1 TXT record found for %s", fqdn)
138 }
139
140 return &records.Records[0], nil
141 }
142
143 // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#search-records
144 func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordType string) (*Records, error) {
145 endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
146
147 query := endpoint.Query()
148 query.Set("type", recordType)
149 query.Set("name", recordName)
150 endpoint.RawQuery = query.Encode()
151
152 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
153 if err != nil {
154 return nil, err
155 }
156
157 var records Records
158
159 err = c.do(req, &records)
160 if err != nil {
161 return nil, err
162 }
163
164 return &records, nil
165 }
166
167 func (c *Client) do(req *http.Request, result any) error {
168 req.Header.Set(AuthToken, c.token)
169
170 resp, err := c.HTTPClient.Do(req)
171 if err != nil {
172 return errutils.NewHTTPDoError(req, err)
173 }
174
175 defer func() { _ = resp.Body.Close() }()
176
177 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
178 return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
179 }
180
181 if result == nil {
182 return nil
183 }
184
185 raw, err := io.ReadAll(resp.Body)
186 if err != nil {
187 return errutils.NewReadResponseError(req, resp.StatusCode, err)
188 }
189
190 err = json.Unmarshal(raw, result)
191 if err != nil {
192 return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
193 }
194
195 return nil
196 }
197
198 func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) {
199 buf := new(bytes.Buffer)
200
201 if payload != nil {
202 err := json.NewEncoder(buf).Encode(payload)
203 if err != nil {
204 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
205 }
206 }
207
208 req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s", endpoint), buf)
209 if err != nil {
210 return nil, fmt.Errorf("unable to create request: %w", err)
211 }
212
213 req.Header.Set("Accept", "application/json")
214
215 if payload != nil {
216 req.Header.Set("Content-Type", "application/json")
217 }
218
219 return req, nil
220 }
221