client.go raw

   1  package internal
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"errors"
   8  	"fmt"
   9  	"io"
  10  	"net/http"
  11  	"net/url"
  12  	"path"
  13  	"strconv"
  14  	"strings"
  15  	"time"
  16  
  17  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  18  	"github.com/miekg/dns"
  19  )
  20  
  21  // APIKeyHeader API key header.
  22  const APIKeyHeader = "X-Api-Key"
  23  
  24  // Client the PowerDNS API client.
  25  type Client struct {
  26  	serverName string
  27  	apiKey     string
  28  
  29  	apiVersion int
  30  
  31  	Host       *url.URL
  32  	HTTPClient *http.Client
  33  }
  34  
  35  // NewClient creates a new Client.
  36  func NewClient(host *url.URL, serverName string, apiVersion int, apiKey string) *Client {
  37  	return &Client{
  38  		serverName: serverName,
  39  		apiKey:     apiKey,
  40  		apiVersion: apiVersion,
  41  		Host:       host,
  42  		HTTPClient: &http.Client{Timeout: 5 * time.Second},
  43  	}
  44  }
  45  
  46  func (c *Client) APIVersion() int {
  47  	return c.apiVersion
  48  }
  49  
  50  func (c *Client) SetAPIVersion(ctx context.Context) error {
  51  	var err error
  52  
  53  	c.apiVersion, err = c.getAPIVersion(ctx)
  54  
  55  	return err
  56  }
  57  
  58  func (c *Client) getAPIVersion(ctx context.Context) (int, error) {
  59  	endpoint := c.joinPath("/", "api")
  60  
  61  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
  62  	if err != nil {
  63  		return 0, err
  64  	}
  65  
  66  	result, err := c.do(req)
  67  	if err != nil {
  68  		return 0, err
  69  	}
  70  
  71  	var versions []apiVersion
  72  
  73  	err = json.Unmarshal(result, &versions)
  74  	if err != nil {
  75  		return 0, err
  76  	}
  77  
  78  	latestVersion := 0
  79  	for _, v := range versions {
  80  		if v.Version > latestVersion {
  81  			latestVersion = v.Version
  82  		}
  83  	}
  84  
  85  	return latestVersion, err
  86  }
  87  
  88  func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZone, error) {
  89  	endpoint := c.joinPath("/", "servers", c.serverName, "zones", dns.Fqdn(authZone))
  90  
  91  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
  92  	if err != nil {
  93  		return nil, err
  94  	}
  95  
  96  	result, err := c.do(req)
  97  	if err != nil {
  98  		return nil, err
  99  	}
 100  
 101  	var zone HostedZone
 102  
 103  	err = json.Unmarshal(result, &zone)
 104  	if err != nil {
 105  		return nil, err
 106  	}
 107  
 108  	// convert pre-v1 API result
 109  	if len(zone.Records) > 0 {
 110  		zone.RRSets = []RRSet{}
 111  		for _, record := range zone.Records {
 112  			set := RRSet{
 113  				Name:    record.Name,
 114  				Type:    record.Type,
 115  				Records: []Record{record},
 116  			}
 117  			zone.RRSets = append(zone.RRSets, set)
 118  		}
 119  	}
 120  
 121  	return &zone, nil
 122  }
 123  
 124  func (c *Client) UpdateRecords(ctx context.Context, zone *HostedZone, sets RRSets) error {
 125  	endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID)
 126  
 127  	req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets)
 128  	if err != nil {
 129  		return err
 130  	}
 131  
 132  	_, err = c.do(req)
 133  	if err != nil {
 134  		return err
 135  	}
 136  
 137  	return nil
 138  }
 139  
 140  func (c *Client) Notify(ctx context.Context, zone *HostedZone) error {
 141  	if c.apiVersion < 1 || zone.Kind != "Master" && zone.Kind != "Slave" {
 142  		return nil
 143  	}
 144  
 145  	endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID, "notify")
 146  
 147  	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, nil)
 148  	if err != nil {
 149  		return err
 150  	}
 151  
 152  	_, err = c.do(req)
 153  	if err != nil {
 154  		return err
 155  	}
 156  
 157  	return nil
 158  }
 159  
 160  func (c *Client) joinPath(elem ...string) *url.URL {
 161  	p := path.Join(elem...)
 162  
 163  	if p != "/api" && c.apiVersion > 0 && !strings.HasPrefix(p, "/api/v") {
 164  		p = path.Join("/api", "v"+strconv.Itoa(c.apiVersion), p)
 165  	}
 166  
 167  	return c.Host.JoinPath(p)
 168  }
 169  
 170  func (c *Client) do(req *http.Request) (json.RawMessage, error) {
 171  	req.Header.Set(APIKeyHeader, c.apiKey)
 172  
 173  	resp, err := c.HTTPClient.Do(req)
 174  	if err != nil {
 175  		return nil, errutils.NewHTTPDoError(req, err)
 176  	}
 177  
 178  	defer func() { _ = resp.Body.Close() }()
 179  
 180  	if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
 181  		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 182  	}
 183  
 184  	var msg json.RawMessage
 185  
 186  	err = json.NewDecoder(resp.Body).Decode(&msg)
 187  	if err != nil {
 188  		if errors.Is(err, io.EOF) {
 189  			// empty body
 190  			return nil, nil
 191  		}
 192  		// other error
 193  		return nil, err
 194  	}
 195  
 196  	// check for PowerDNS error message
 197  	if len(msg) > 0 && msg[0] == '{' {
 198  		var errInfo apiError
 199  
 200  		err = json.Unmarshal(msg, &errInfo)
 201  		if err != nil {
 202  			return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err)
 203  		}
 204  
 205  		if errInfo.ShortMsg != "" {
 206  			return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo)
 207  		}
 208  	}
 209  
 210  	return msg, nil
 211  }
 212  
 213  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 214  	buf := new(bytes.Buffer)
 215  
 216  	if payload != nil {
 217  		err := json.NewEncoder(buf).Encode(payload)
 218  		if err != nil {
 219  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 220  		}
 221  	}
 222  
 223  	req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(endpoint.String(), "/"), buf)
 224  	if err != nil {
 225  		return nil, fmt.Errorf("unable to create request: %w", err)
 226  	}
 227  
 228  	req.Header.Set("Accept", "application/json")
 229  
 230  	// PowerDNS doesn't follow HTTP convention about the "Content-Type" header.
 231  	if method != http.MethodGet && method != http.MethodDelete {
 232  		req.Header.Set("Content-Type", "application/json")
 233  	}
 234  
 235  	return req, nil
 236  }
 237