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  	"strings"
  12  	"time"
  13  
  14  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  15  )
  16  
  17  // DefaultBaseURL is url to the XML-RPC api.
  18  const DefaultBaseURL = "https://api.loopia.se/RPCSERV"
  19  
  20  // Client the Loopia client.
  21  type Client struct {
  22  	apiUser     string
  23  	apiPassword string
  24  
  25  	BaseURL    string
  26  	HTTPClient *http.Client
  27  }
  28  
  29  // NewClient creates a new Loopia Client.
  30  func NewClient(apiUser, apiPassword string) *Client {
  31  	return &Client{
  32  		apiUser:     apiUser,
  33  		apiPassword: apiPassword,
  34  		BaseURL:     DefaultBaseURL,
  35  		HTTPClient:  &http.Client{Timeout: 10 * time.Second},
  36  	}
  37  }
  38  
  39  // AddTXTRecord adds a TXT record.
  40  func (c *Client) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error {
  41  	call := &methodCall{
  42  		MethodName: "addZoneRecord",
  43  		Params: []param{
  44  			paramString{Value: c.apiUser},
  45  			paramString{Value: c.apiPassword},
  46  			paramString{Value: domain},
  47  			paramString{Value: subdomain},
  48  			paramStruct{
  49  				StructMembers: []structMember{
  50  					structMemberString{Name: "type", Value: "TXT"},
  51  					structMemberInt{Name: "ttl", Value: ttl},
  52  					structMemberInt{Name: "priority", Value: 0},
  53  					structMemberString{Name: "rdata", Value: value},
  54  					structMemberInt{Name: "record_id", Value: 0},
  55  				},
  56  			},
  57  		},
  58  	}
  59  	resp := &responseString{}
  60  
  61  	err := c.rpcCall(ctx, call, resp)
  62  	if err != nil {
  63  		return err
  64  	}
  65  
  66  	return checkResponse(resp.Value)
  67  }
  68  
  69  // RemoveTXTRecord removes a TXT record.
  70  func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error {
  71  	call := &methodCall{
  72  		MethodName: "removeZoneRecord",
  73  		Params: []param{
  74  			paramString{Value: c.apiUser},
  75  			paramString{Value: c.apiPassword},
  76  			paramString{Value: domain},
  77  			paramString{Value: subdomain},
  78  			paramInt{Value: recordID},
  79  		},
  80  	}
  81  	resp := &responseString{}
  82  
  83  	err := c.rpcCall(ctx, call, resp)
  84  	if err != nil {
  85  		return err
  86  	}
  87  
  88  	return checkResponse(resp.Value)
  89  }
  90  
  91  // GetTXTRecords gets TXT records.
  92  func (c *Client) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]RecordObj, error) {
  93  	call := &methodCall{
  94  		MethodName: "getZoneRecords",
  95  		Params: []param{
  96  			paramString{Value: c.apiUser},
  97  			paramString{Value: c.apiPassword},
  98  			paramString{Value: domain},
  99  			paramString{Value: subdomain},
 100  		},
 101  	}
 102  	resp := &recordObjectsResponse{}
 103  
 104  	err := c.rpcCall(ctx, call, resp)
 105  
 106  	return resp.Params, err
 107  }
 108  
 109  // RemoveSubdomain remove a sub-domain.
 110  func (c *Client) RemoveSubdomain(ctx context.Context, domain, subdomain string) error {
 111  	call := &methodCall{
 112  		MethodName: "removeSubdomain",
 113  		Params: []param{
 114  			paramString{Value: c.apiUser},
 115  			paramString{Value: c.apiPassword},
 116  			paramString{Value: domain},
 117  			paramString{Value: subdomain},
 118  		},
 119  	}
 120  	resp := &responseString{}
 121  
 122  	err := c.rpcCall(ctx, call, resp)
 123  	if err != nil {
 124  		return err
 125  	}
 126  
 127  	return checkResponse(resp.Value)
 128  }
 129  
 130  // rpcCall makes an XML-RPC call to Loopia's RPC endpoint by marshaling the data given in the call argument to XML
 131  // and sending that via HTTP Post to Loopia.
 132  // The response is then unmarshalled into the resp argument.
 133  func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error {
 134  	req, err := newXMLRequest(ctx, c.BaseURL, call)
 135  	if err != nil {
 136  		return err
 137  	}
 138  
 139  	resp, err := c.HTTPClient.Do(req)
 140  	if err != nil {
 141  		return errutils.NewHTTPDoError(req, err)
 142  	}
 143  
 144  	defer func() { _ = resp.Body.Close() }()
 145  
 146  	if resp.StatusCode != http.StatusOK {
 147  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 148  	}
 149  
 150  	raw, err := io.ReadAll(resp.Body)
 151  	if err != nil {
 152  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 153  	}
 154  
 155  	err = xml.Unmarshal(raw, result)
 156  	if err != nil {
 157  		return fmt.Errorf("unmarshal error: %w", err)
 158  	}
 159  
 160  	if result.faultCode() != 0 {
 161  		return RPCError{
 162  			FaultCode:   result.faultCode(),
 163  			FaultString: strings.TrimSpace(result.faultString()),
 164  		}
 165  	}
 166  
 167  	return nil
 168  }
 169  
 170  func newXMLRequest(ctx context.Context, endpoint string, payload any) (*http.Request, error) {
 171  	body := new(bytes.Buffer)
 172  	body.WriteString(xml.Header)
 173  
 174  	encoder := xml.NewEncoder(body)
 175  	encoder.Indent("", "  ")
 176  
 177  	err := encoder.Encode(payload)
 178  	if err != nil {
 179  		return nil, err
 180  	}
 181  
 182  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
 183  	if err != nil {
 184  		return nil, fmt.Errorf("unable to create request: %w", err)
 185  	}
 186  
 187  	req.Header.Set("Content-Type", "text/xml")
 188  
 189  	return req, nil
 190  }
 191  
 192  func checkResponse(value string) error {
 193  	switch v := strings.TrimSpace(value); v {
 194  	case "OK":
 195  		return nil
 196  	case "AUTH_ERROR":
 197  		return errors.New("authentication error")
 198  	default:
 199  		return fmt.Errorf("unknown error: %q", v)
 200  	}
 201  }
 202