liquidweb.go raw

   1  // Package liquidweb implements a DNS provider for solving the DNS-01 challenge using Liquid Web.
   2  package liquidweb
   3  
   4  import (
   5  	"errors"
   6  	"fmt"
   7  	"sort"
   8  	"strconv"
   9  	"strings"
  10  	"sync"
  11  	"time"
  12  
  13  	"github.com/go-acme/lego/v4/challenge"
  14  	"github.com/go-acme/lego/v4/challenge/dns01"
  15  	"github.com/go-acme/lego/v4/platform/config/env"
  16  	lw "github.com/liquidweb/liquidweb-go/client"
  17  	"github.com/liquidweb/liquidweb-go/network"
  18  )
  19  
  20  // Environment variables names.
  21  const (
  22  	envNamespace    = "LIQUID_WEB_"
  23  	altEnvNamespace = "LWAPI_"
  24  
  25  	EnvURL      = envNamespace + "URL"
  26  	EnvUsername = envNamespace + "USERNAME"
  27  	EnvPassword = envNamespace + "PASSWORD"
  28  	EnvZone     = envNamespace + "ZONE"
  29  
  30  	EnvTTL                = envNamespace + "TTL"
  31  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  32  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  33  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  34  )
  35  
  36  const defaultBaseURL = "https://api.liquidweb.com"
  37  
  38  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  39  
  40  // Config is used to configure the creation of the DNSProvider.
  41  type Config struct {
  42  	BaseURL            string
  43  	Username           string
  44  	Password           string
  45  	Zone               string
  46  	TTL                int
  47  	PollingInterval    time.Duration
  48  	PropagationTimeout time.Duration
  49  	HTTPTimeout        time.Duration
  50  }
  51  
  52  // NewDefaultConfig returns a default configuration for the DNSProvider.
  53  func NewDefaultConfig() *Config {
  54  	return &Config{
  55  		BaseURL:            defaultBaseURL,
  56  		TTL:                env.GetOneWithFallback(EnvTTL, 300, strconv.Atoi, altEnvName(EnvTTL)),
  57  		PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)),
  58  		PollingInterval:    env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
  59  		HTTPTimeout:        env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
  60  	}
  61  }
  62  
  63  // DNSProvider implements the challenge.Provider interface.
  64  type DNSProvider struct {
  65  	config *Config
  66  	client *lw.API
  67  
  68  	recordIDs   map[string]int
  69  	recordIDsMu sync.Mutex
  70  }
  71  
  72  // NewDNSProvider returns a DNSProvider instance configured for Liquid Web.
  73  func NewDNSProvider() (*DNSProvider, error) {
  74  	values, err := env.GetWithFallback(
  75  		[]string{EnvUsername, altEnvName(EnvUsername)},
  76  		[]string{EnvPassword, altEnvName(EnvPassword)},
  77  	)
  78  	if err != nil {
  79  		return nil, fmt.Errorf("liquidweb: %w", err)
  80  	}
  81  
  82  	config := NewDefaultConfig()
  83  	config.BaseURL = env.GetOneWithFallback(EnvURL, defaultBaseURL, env.ParseString, altEnvName(EnvURL))
  84  	config.Username = values[EnvUsername]
  85  	config.Password = values[EnvPassword]
  86  	config.Zone = env.GetOneWithFallback(EnvZone, "", env.ParseString, altEnvName(EnvZone))
  87  
  88  	return NewDNSProviderConfig(config)
  89  }
  90  
  91  // NewDNSProviderConfig return a DNSProvider instance configured for Liquid Web.
  92  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  93  	if config == nil {
  94  		return nil, errors.New("liquidweb: the configuration of the DNS provider is nil")
  95  	}
  96  
  97  	if config.BaseURL == "" {
  98  		config.BaseURL = defaultBaseURL
  99  	}
 100  
 101  	client, err := lw.NewAPI(config.Username, config.Password, config.BaseURL, int(config.HTTPTimeout.Seconds()))
 102  	if err != nil {
 103  		return nil, fmt.Errorf("liquidweb: could not create Liquid Web API client: %w", err)
 104  	}
 105  
 106  	return &DNSProvider{
 107  		config:    config,
 108  		recordIDs: make(map[string]int),
 109  		client:    client,
 110  	}, nil
 111  }
 112  
 113  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 114  // Adjusting here to cope with spikes in propagation times.
 115  func (d *DNSProvider) Timeout() (time.Duration, time.Duration) {
 116  	return d.config.PropagationTimeout, d.config.PollingInterval
 117  }
 118  
 119  // Present creates a TXT record using the specified parameters.
 120  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 121  	info := dns01.GetChallengeInfo(domain, keyAuth)
 122  
 123  	params := &network.DNSRecordParams{
 124  		Name:  dns01.UnFqdn(info.EffectiveFQDN),
 125  		RData: strconv.Quote(info.Value),
 126  		Type:  "TXT",
 127  		Zone:  d.config.Zone,
 128  		TTL:   d.config.TTL,
 129  	}
 130  
 131  	if params.Zone == "" {
 132  		bestZone, err := d.findZone(params.Name)
 133  		if err != nil {
 134  			return fmt.Errorf("liquidweb: %w", err)
 135  		}
 136  
 137  		params.Zone = bestZone
 138  	}
 139  
 140  	dnsEntry, err := d.client.NetworkDNS.Create(params)
 141  	if err != nil {
 142  		return fmt.Errorf("liquidweb: could not create TXT record: %w", err)
 143  	}
 144  
 145  	d.recordIDsMu.Lock()
 146  	d.recordIDs[token] = int(dnsEntry.ID)
 147  	d.recordIDsMu.Unlock()
 148  
 149  	return nil
 150  }
 151  
 152  // CleanUp removes the TXT record matching the specified parameters.
 153  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 154  	d.recordIDsMu.Lock()
 155  	recordID, ok := d.recordIDs[token]
 156  	d.recordIDsMu.Unlock()
 157  
 158  	if !ok {
 159  		return fmt.Errorf("liquidweb: unknown record ID for '%s'", domain)
 160  	}
 161  
 162  	params := &network.DNSRecordParams{ID: recordID}
 163  
 164  	_, err := d.client.NetworkDNS.Delete(params)
 165  	if err != nil {
 166  		return fmt.Errorf("liquidweb: could not remove TXT record: %w", err)
 167  	}
 168  
 169  	d.recordIDsMu.Lock()
 170  	delete(d.recordIDs, token)
 171  	d.recordIDsMu.Unlock()
 172  
 173  	return nil
 174  }
 175  
 176  func (d *DNSProvider) findZone(domain string) (string, error) {
 177  	zones, err := d.client.NetworkDNSZone.ListAll()
 178  	if err != nil {
 179  		return "", fmt.Errorf("failed to retrieve zones for account: %w", err)
 180  	}
 181  
 182  	// filter the zones on the account to only ones that match
 183  	var zs []network.DNSZone
 184  
 185  	for _, item := range zones.Items {
 186  		if strings.HasSuffix(domain, item.Name) {
 187  			zs = append(zs, item)
 188  		}
 189  	}
 190  
 191  	if len(zs) < 1 {
 192  		return "", fmt.Errorf("no valid zone in account for certificate '%s'", domain)
 193  	}
 194  
 195  	// powerdns _only_ looks for records on the longest matching subdomain zone aka,
 196  	// for test.sub.example.com if sub.example.com exists,
 197  	// it will look there it will not look atexample.com even if it also exists
 198  	sort.Slice(zs, func(i, j int) bool {
 199  		return len(zs[i].Name) > len(zs[j].Name)
 200  	})
 201  
 202  	return zs[0].Name, nil
 203  }
 204  
 205  func altEnvName(v string) string {
 206  	return strings.ReplaceAll(v, envNamespace, altEnvNamespace)
 207  }
 208