client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/xml"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "time"
12
13 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
14 )
15
16 // defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp.
17 const defaultBaseURL = "https://rpc.gandi.net/xmlrpc/"
18
19 // Client the Gandi API client.
20 type Client struct {
21 apiKey string
22
23 BaseURL string
24 HTTPClient *http.Client
25 }
26
27 // NewClient Creates a new Client.
28 func NewClient(apiKey string) *Client {
29 return &Client{
30 apiKey: apiKey,
31 BaseURL: defaultBaseURL,
32 HTTPClient: &http.Client{Timeout: 5 * time.Second},
33 }
34 }
35
36 func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) {
37 call := &methodCall{
38 MethodName: "domain.info",
39 Params: []param{
40 paramString{Value: c.apiKey},
41 paramString{Value: domain},
42 },
43 }
44
45 resp := &responseStruct{}
46
47 err := c.rpcCall(ctx, call, resp)
48 if err != nil {
49 return 0, err
50 }
51
52 var zoneID int
53
54 for _, member := range resp.StructMembers {
55 if member.Name == "zone_id" {
56 zoneID = member.ValueInt
57 }
58 }
59
60 if zoneID == 0 {
61 return 0, fmt.Errorf("could not find zone_id for %s", domain)
62 }
63
64 return zoneID, nil
65 }
66
67 func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, error) {
68 call := &methodCall{
69 MethodName: "domain.zone.clone",
70 Params: []param{
71 paramString{Value: c.apiKey},
72 paramInt{Value: zoneID},
73 paramInt{Value: 0},
74 paramStruct{
75 StructMembers: []structMember{
76 structMemberString{
77 Name: "name",
78 Value: name,
79 },
80 },
81 },
82 },
83 }
84
85 resp := &responseStruct{}
86
87 err := c.rpcCall(ctx, call, resp)
88 if err != nil {
89 return 0, err
90 }
91
92 var newZoneID int
93
94 for _, member := range resp.StructMembers {
95 if member.Name == "id" {
96 newZoneID = member.ValueInt
97 }
98 }
99
100 if newZoneID == 0 {
101 return 0, errors.New("could not determine cloned zone_id")
102 }
103
104 return newZoneID, nil
105 }
106
107 func (c *Client) NewZoneVersion(ctx context.Context, zoneID int) (int, error) {
108 call := &methodCall{
109 MethodName: "domain.zone.version.new",
110 Params: []param{
111 paramString{Value: c.apiKey},
112 paramInt{Value: zoneID},
113 },
114 }
115
116 resp := &responseInt{}
117
118 err := c.rpcCall(ctx, call, resp)
119 if err != nil {
120 return 0, err
121 }
122
123 if resp.Value == 0 {
124 return 0, errors.New("could not create new zone version")
125 }
126
127 return resp.Value, nil
128 }
129
130 func (c *Client) AddTXTRecord(ctx context.Context, zoneID, version int, name, value string, ttl int) error {
131 call := &methodCall{
132 MethodName: "domain.zone.record.add",
133 Params: []param{
134 paramString{Value: c.apiKey},
135 paramInt{Value: zoneID},
136 paramInt{Value: version},
137 paramStruct{
138 StructMembers: []structMember{
139 structMemberString{
140 Name: "type",
141 Value: "TXT",
142 }, structMemberString{
143 Name: "name",
144 Value: name,
145 }, structMemberString{
146 Name: "value",
147 Value: value,
148 }, structMemberInt{
149 Name: "ttl",
150 Value: ttl,
151 },
152 },
153 },
154 },
155 }
156
157 resp := &responseStruct{}
158
159 return c.rpcCall(ctx, call, resp)
160 }
161
162 func (c *Client) SetZoneVersion(ctx context.Context, zoneID, version int) error {
163 call := &methodCall{
164 MethodName: "domain.zone.version.set",
165 Params: []param{
166 paramString{Value: c.apiKey},
167 paramInt{Value: zoneID},
168 paramInt{Value: version},
169 },
170 }
171
172 resp := &responseBool{}
173
174 err := c.rpcCall(ctx, call, resp)
175 if err != nil {
176 return err
177 }
178
179 if !resp.Value {
180 return errors.New("could not set zone version")
181 }
182
183 return nil
184 }
185
186 func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error {
187 call := &methodCall{
188 MethodName: "domain.zone.set",
189 Params: []param{
190 paramString{Value: c.apiKey},
191 paramString{Value: domain},
192 paramInt{Value: zoneID},
193 },
194 }
195
196 resp := &responseStruct{}
197
198 err := c.rpcCall(ctx, call, resp)
199 if err != nil {
200 return err
201 }
202
203 var respZoneID int
204
205 for _, member := range resp.StructMembers {
206 if member.Name == "zone_id" {
207 respZoneID = member.ValueInt
208 }
209 }
210
211 if respZoneID != zoneID {
212 return fmt.Errorf("could not set new zone_id for %s", domain)
213 }
214
215 return nil
216 }
217
218 func (c *Client) DeleteZone(ctx context.Context, zoneID int) error {
219 call := &methodCall{
220 MethodName: "domain.zone.delete",
221 Params: []param{
222 paramString{Value: c.apiKey},
223 paramInt{Value: zoneID},
224 },
225 }
226
227 resp := &responseBool{}
228
229 err := c.rpcCall(ctx, call, resp)
230 if err != nil {
231 return err
232 }
233
234 if !resp.Value {
235 return errors.New("could not delete zone_id")
236 }
237
238 return nil
239 }
240
241 // rpcCall makes an XML-RPC call to Gandi's RPC endpoint by marshaling the data given in the call argument to XML
242 // and sending that via HTTP Post to Gandi.
243 // The response is then unmarshalled into the resp argument.
244 func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error {
245 req, err := newXMLRequest(ctx, c.BaseURL, call)
246 if err != nil {
247 return err
248 }
249
250 resp, err := c.HTTPClient.Do(req)
251 if err != nil {
252 return errutils.NewHTTPDoError(req, err)
253 }
254
255 defer func() { _ = resp.Body.Close() }()
256
257 raw, err := io.ReadAll(resp.Body)
258 if err != nil {
259 return errutils.NewReadResponseError(req, resp.StatusCode, err)
260 }
261
262 err = xml.Unmarshal(raw, result)
263 if err != nil {
264 return fmt.Errorf("unmarshal error: %w", err)
265 }
266
267 if result.faultCode() != 0 {
268 return RPCError{
269 FaultCode: result.faultCode(),
270 FaultString: result.faultString(),
271 }
272 }
273
274 return nil
275 }
276
277 func newXMLRequest(ctx context.Context, endpoint string, payload *methodCall) (*http.Request, error) {
278 body := new(bytes.Buffer)
279 body.WriteString(xml.Header)
280
281 encoder := xml.NewEncoder(body)
282 encoder.Indent("", " ")
283
284 err := encoder.Encode(payload)
285 if err != nil {
286 return nil, err
287 }
288
289 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
290 if err != nil {
291 return nil, fmt.Errorf("unable to create request: %w", err)
292 }
293
294 req.Header.Set("Content-Type", "text/xml")
295
296 return req, nil
297 }
298