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