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/log"
14 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
15 )
16
17 // defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp.
18 const defaultBaseURL = "https://api.gandi.net/v5/livedns"
19
20 // Related to Personal Access Token.
21 const authorizationHeader = "Authorization"
22
23 // Client the Gandi API v5 client.
24 type Client struct {
25 apiKey string
26 pat string
27
28 BaseURL *url.URL
29 HTTPClient *http.Client
30 }
31
32 // NewClient Creates a new Client.
33 func NewClient(apiKey, pat string) *Client {
34 baseURL, _ := url.Parse(defaultBaseURL)
35
36 return &Client{
37 apiKey: apiKey,
38 pat: pat,
39 BaseURL: baseURL,
40 HTTPClient: &http.Client{Timeout: 5 * time.Second},
41 }
42 }
43
44 func (c *Client) AddTXTRecord(ctx context.Context, domain, name, value string, ttl int) error {
45 // Get exiting values for the TXT records
46 // Needed to create challenges for both wildcard and base name domains
47 txtRecord, err := c.getTXTRecord(ctx, domain, name)
48 if err != nil {
49 return err
50 }
51
52 values := []string{value}
53 if len(txtRecord.RRSetValues) > 0 {
54 values = append(values, txtRecord.RRSetValues...)
55 }
56
57 newRecord := &Record{RRSetTTL: ttl, RRSetValues: values}
58
59 err = c.addTXTRecord(ctx, domain, name, newRecord)
60 if err != nil {
61 return err
62 }
63
64 return nil
65 }
66
67 func (c *Client) getTXTRecord(ctx context.Context, domain, name string) (*Record, error) {
68 endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
69
70 // Get exiting values for the TXT records
71 // Needed to create challenges for both wildcard and base name domains
72 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
73 if err != nil {
74 return nil, err
75 }
76
77 txtRecord := &Record{}
78
79 err = c.do(req, txtRecord)
80 if err != nil {
81 return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err)
82 }
83
84 return txtRecord, nil
85 }
86
87 func (c *Client) addTXTRecord(ctx context.Context, domain, name string, newRecord *Record) error {
88 endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
89
90 req, err := newJSONRequest(ctx, http.MethodPut, endpoint, newRecord)
91 if err != nil {
92 return err
93 }
94
95 message := apiResponse{}
96
97 err = c.do(req, &message)
98 if err != nil {
99 return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err)
100 }
101
102 if message.Message != "" {
103 log.Infof("API response: %s", message.Message)
104 }
105
106 return nil
107 }
108
109 func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error {
110 endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
111
112 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
113 if err != nil {
114 return err
115 }
116
117 message := apiResponse{}
118
119 err = c.do(req, &message)
120 if err != nil {
121 return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err)
122 }
123
124 if message.Message != "" {
125 log.Infof("API response: %s", message.Message)
126 }
127
128 return nil
129 }
130
131 func (c *Client) do(req *http.Request, result any) error {
132 if c.apiKey != "" {
133 req.Header.Set(authorizationHeader, "Apikey "+c.apiKey)
134 }
135
136 if c.pat != "" {
137 req.Header.Set(authorizationHeader, "Bearer "+c.pat)
138 }
139
140 resp, err := c.HTTPClient.Do(req)
141 if err != nil {
142 return errutils.NewHTTPDoError(req, err)
143 }
144
145 defer func() { _ = resp.Body.Close() }()
146
147 err = checkResponse(req, resp)
148 if err != nil {
149 return err
150 }
151
152 if result == nil {
153 return nil
154 }
155
156 raw, err := io.ReadAll(resp.Body)
157 if err != nil {
158 return errutils.NewReadResponseError(req, resp.StatusCode, err)
159 }
160
161 if len(raw) > 0 {
162 err = json.Unmarshal(raw, result)
163 if err != nil {
164 return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
165 }
166 }
167
168 return nil
169 }
170
171 func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
172 buf := new(bytes.Buffer)
173
174 if payload != nil {
175 err := json.NewEncoder(buf).Encode(payload)
176 if err != nil {
177 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
178 }
179 }
180
181 req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
182 if err != nil {
183 return nil, fmt.Errorf("unable to create request: %w", err)
184 }
185
186 req.Header.Set("Accept", "application/json")
187
188 if payload != nil {
189 req.Header.Set("Content-Type", "application/json")
190 }
191
192 return req, nil
193 }
194
195 func checkResponse(req *http.Request, resp *http.Response) error {
196 if resp.StatusCode == http.StatusNotFound && resp.Request.Method == http.MethodGet {
197 return nil
198 }
199
200 if resp.StatusCode < http.StatusBadRequest {
201 return nil
202 }
203
204 return parseError(req, resp)
205 }
206
207 func parseError(req *http.Request, resp *http.Response) error {
208 raw, _ := io.ReadAll(resp.Body)
209
210 response := apiResponse{}
211
212 err := json.Unmarshal(raw, &response)
213 if err != nil {
214 return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
215 }
216
217 return fmt.Errorf("%d: request failed: %s", resp.StatusCode, response.Message)
218 }
219