client.go raw

   1  package goacmedns
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"net"
  10  	"net/http"
  11  	"net/url"
  12  	"runtime"
  13  	"time"
  14  )
  15  
  16  // defaultTimeout is used for the httpClient Timeout settings.
  17  const defaultTimeout = 30 * time.Second
  18  
  19  // ua is a custom user-agent identifier.
  20  const ua = "goacmedns"
  21  
  22  // userAgent returns a string that can be used as a HTTP request `User-Agent`
  23  // header. It includes the `ua` string alongside the OS and architecture of the
  24  // system.
  25  func userAgent() string {
  26  	return fmt.Sprintf("%s (%s; %s)", ua, runtime.GOOS, runtime.GOARCH)
  27  }
  28  
  29  type Register struct {
  30  	AllowFrom []string `json:"allowfrom"`
  31  }
  32  
  33  type Update struct {
  34  	SubDomain string `json:"subdomain"`
  35  	Txt       string `json:"txt"`
  36  }
  37  
  38  // Storage is an interface describing the required functions for an ACME DNS
  39  // Account storage mechanism.
  40  type Storage interface {
  41  	// Save will persist the `Account` data that has been `Put` so far
  42  	Save(ctx context.Context) error
  43  	// Put will add an `Account` for the given domain to the storage. It may not
  44  	// be persisted until `Save` is called.
  45  	Put(ctx context.Context, domain string, account Account) error
  46  	// Fetch will retrieve an `Account` for the given domain from the storage. If
  47  	// the provided domain does not have an `Account` saved in the storage
  48  	// `ErrDomainNotFound` will be returned
  49  	Fetch(ctx context.Context, domain string) (Account, error)
  50  	// FetchAll retrieves all the `Account` objects from the storage and
  51  	// returns a map that has domain names as its keys and `Account` objects
  52  	// as values.
  53  	FetchAll(ctx context.Context) (map[string]Account, error)
  54  }
  55  
  56  type Option func(c *Client)
  57  
  58  func WithHTTPClient(client *http.Client) Option {
  59  	return func(c *Client) {
  60  		if c != nil {
  61  			c.httpClient = client
  62  		}
  63  	}
  64  }
  65  
  66  type Client struct {
  67  	httpClient *http.Client
  68  	baseURL    *url.URL
  69  }
  70  
  71  func NewClient(baseURL string, opts ...Option) (*Client, error) {
  72  	endpoint, err := url.Parse(baseURL)
  73  	if err != nil {
  74  		return nil, fmt.Errorf("could not parse base URL: %w", err)
  75  	}
  76  
  77  	client := &Client{
  78  		httpClient: &http.Client{
  79  			CheckRedirect: nil,
  80  			Jar:           nil,
  81  			Timeout:       defaultTimeout,
  82  			Transport: &http.Transport{
  83  				Proxy: http.ProxyFromEnvironment,
  84  				DialContext: (&net.Dialer{
  85  					Timeout:   defaultTimeout,
  86  					KeepAlive: defaultTimeout,
  87  				}).DialContext,
  88  				TLSHandshakeTimeout:   defaultTimeout,
  89  				ResponseHeaderTimeout: defaultTimeout,
  90  				ExpectContinueTimeout: 1 * time.Second,
  91  			},
  92  		},
  93  		baseURL: endpoint,
  94  	}
  95  
  96  	for _, opt := range opts {
  97  		opt(client)
  98  	}
  99  
 100  	return client, nil
 101  }
 102  
 103  func (c *Client) RegisterAccount(ctx context.Context, allowFrom []string) (Account, error) {
 104  	var register *Register
 105  	if len(allowFrom) > 0 {
 106  		register = &Register{AllowFrom: allowFrom}
 107  	}
 108  
 109  	req, err := newRequest(ctx, c.baseURL.JoinPath("register"), nil, register)
 110  	if err != nil {
 111  		return Account{}, err
 112  	}
 113  
 114  	var acct Account
 115  
 116  	err = c.do(req, &acct)
 117  	if err != nil {
 118  		return Account{}, fmt.Errorf("failed to register account: %w", err)
 119  	}
 120  
 121  	acct.ServerURL = c.baseURL.String()
 122  
 123  	return acct, nil
 124  }
 125  
 126  func (c *Client) UpdateTXTRecord(ctx context.Context, account Account, value string) error {
 127  	update := &Update{
 128  		SubDomain: account.SubDomain,
 129  		Txt:       value,
 130  	}
 131  
 132  	headers := map[string]string{
 133  		"X-Api-User": account.Username,
 134  		"X-Api-Key":  account.Password,
 135  	}
 136  
 137  	req, err := newRequest(ctx, c.baseURL.JoinPath("update"), headers, update)
 138  	if err != nil {
 139  		return err
 140  	}
 141  
 142  	err = c.do(req, nil)
 143  	if err != nil {
 144  		return fmt.Errorf("failed to update TXT record: %w", err)
 145  	}
 146  
 147  	return nil
 148  }
 149  
 150  func (c *Client) do(req *http.Request, result any) error {
 151  	resp, err := c.httpClient.Do(req)
 152  	if err != nil {
 153  		return fmt.Errorf("failed to do req: %w", err)
 154  	}
 155  
 156  	defer func() { _ = resp.Body.Close() }()
 157  
 158  	if resp.StatusCode/100 != 2 {
 159  		raw, _ := io.ReadAll(resp.Body)
 160  
 161  		return newClientError("response error", resp.StatusCode, raw)
 162  	}
 163  
 164  	if result == nil {
 165  		return nil
 166  	}
 167  
 168  	raw, err := io.ReadAll(resp.Body)
 169  	if err != nil {
 170  		return fmt.Errorf("failed to read body: %w", err)
 171  	}
 172  
 173  	err = json.Unmarshal(raw, result)
 174  	if err != nil {
 175  		return newClientError("failed to unmarshal response", resp.StatusCode, raw)
 176  	}
 177  
 178  	return nil
 179  }
 180  
 181  func newRequest(ctx context.Context, endpoint *url.URL, headers map[string]string, payload any) (*http.Request, error) {
 182  	buf := new(bytes.Buffer)
 183  
 184  	if payload != nil {
 185  		err := json.NewEncoder(buf).Encode(payload)
 186  		if err != nil {
 187  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 188  		}
 189  	}
 190  
 191  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), buf)
 192  	if err != nil {
 193  		return nil, fmt.Errorf("unable to create request: %w", err)
 194  	}
 195  
 196  	req.Header.Set("Accept", "application/json")
 197  	req.Header.Set("User-Agent", userAgent())
 198  
 199  	for h, v := range headers {
 200  		req.Header.Set(h, v)
 201  	}
 202  
 203  	if payload != nil {
 204  		req.Header.Set("Content-Type", "application/json")
 205  	}
 206  
 207  	return req, nil
 208  }
 209