pdns.go raw

   1  // Package pdns implements a DNS provider for solving the DNS-01 challenge using PowerDNS nameserver.
   2  package pdns
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"net/url"
  10  	"strconv"
  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/log"
  16  	"github.com/go-acme/lego/v4/platform/config/env"
  17  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  18  	"github.com/go-acme/lego/v4/providers/dns/pdns/internal"
  19  )
  20  
  21  // Environment variables names.
  22  const (
  23  	envNamespace = "PDNS_"
  24  
  25  	EnvAPIKey = envNamespace + "API_KEY"
  26  	EnvAPIURL = envNamespace + "API_URL"
  27  
  28  	EnvTTL                = envNamespace + "TTL"
  29  	EnvAPIVersion         = envNamespace + "API_VERSION"
  30  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  31  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  32  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  33  	EnvServerName         = envNamespace + "SERVER_NAME"
  34  )
  35  
  36  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  37  
  38  // Config is used to configure the creation of the DNSProvider.
  39  type Config struct {
  40  	APIKey             string
  41  	Host               *url.URL
  42  	ServerName         string
  43  	APIVersion         int
  44  	PropagationTimeout time.Duration
  45  	PollingInterval    time.Duration
  46  	TTL                int
  47  	HTTPClient         *http.Client
  48  }
  49  
  50  // NewDefaultConfig returns a default configuration for the DNSProvider.
  51  func NewDefaultConfig() *Config {
  52  	return &Config{
  53  		ServerName:         env.GetOrDefaultString(EnvServerName, "localhost"),
  54  		APIVersion:         env.GetOrDefaultInt(EnvAPIVersion, 0),
  55  		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  56  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
  57  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
  58  		HTTPClient: &http.Client{
  59  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  60  		},
  61  	}
  62  }
  63  
  64  // DNSProvider implements the challenge.Provider interface.
  65  type DNSProvider struct {
  66  	config *Config
  67  	client *internal.Client
  68  }
  69  
  70  // NewDNSProvider returns a DNSProvider instance configured for pdns.
  71  // Credentials must be passed in the environment variable:
  72  // PDNS_API_URL and PDNS_API_KEY.
  73  func NewDNSProvider() (*DNSProvider, error) {
  74  	values, err := env.Get(EnvAPIKey, EnvAPIURL)
  75  	if err != nil {
  76  		return nil, fmt.Errorf("pdns: %w", err)
  77  	}
  78  
  79  	hostURL, err := url.Parse(values[EnvAPIURL])
  80  	if err != nil {
  81  		return nil, fmt.Errorf("pdns: %w", err)
  82  	}
  83  
  84  	config := NewDefaultConfig()
  85  	config.Host = hostURL
  86  	config.APIKey = values[EnvAPIKey]
  87  
  88  	return NewDNSProviderConfig(config)
  89  }
  90  
  91  // NewDNSProviderConfig return a DNSProvider instance configured for pdns.
  92  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  93  	if config == nil {
  94  		return nil, errors.New("pdns: the configuration of the DNS provider is nil")
  95  	}
  96  
  97  	if config.APIKey == "" {
  98  		return nil, errors.New("pdns: API key missing")
  99  	}
 100  
 101  	if config.Host == nil || config.Host.Host == "" {
 102  		return nil, errors.New("pdns: API URL missing")
 103  	}
 104  
 105  	client := internal.NewClient(config.Host, config.ServerName, config.APIVersion, config.APIKey)
 106  
 107  	if config.HTTPClient != nil {
 108  		client.HTTPClient = config.HTTPClient
 109  	}
 110  
 111  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
 112  
 113  	if config.APIVersion <= 0 {
 114  		err := client.SetAPIVersion(context.Background())
 115  		if err != nil {
 116  			log.Warnf("pdns: failed to get API version %v", err)
 117  		}
 118  	}
 119  
 120  	return &DNSProvider{config: config, client: client}, nil
 121  }
 122  
 123  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 124  // Adjusting here to cope with spikes in propagation times.
 125  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 126  	return d.config.PropagationTimeout, d.config.PollingInterval
 127  }
 128  
 129  // Present creates a TXT record to fulfill the dns-01 challenge.
 130  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 131  	ctx := context.Background()
 132  
 133  	info := dns01.GetChallengeInfo(domain, keyAuth)
 134  
 135  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 136  	if err != nil {
 137  		return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err)
 138  	}
 139  
 140  	zone, err := d.client.GetHostedZone(ctx, authZone)
 141  	if err != nil {
 142  		return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err)
 143  	}
 144  
 145  	name := info.EffectiveFQDN
 146  	if d.client.APIVersion() == 0 {
 147  		// pre-v1 API wants non-fqdn
 148  		name = dns01.UnFqdn(info.EffectiveFQDN)
 149  	}
 150  
 151  	// Look for existing records.
 152  	existingRRSet := findTxtRecord(zone, info.EffectiveFQDN)
 153  
 154  	var records []internal.Record
 155  	if existingRRSet != nil {
 156  		records = existingRRSet.Records
 157  	}
 158  
 159  	records = append(records, internal.Record{
 160  		Content:  strconv.Quote(info.Value),
 161  		Disabled: false,
 162  
 163  		// pre-v1 API
 164  		Type: "TXT",
 165  		Name: name,
 166  		TTL:  d.config.TTL,
 167  	})
 168  
 169  	rrSets := internal.RRSets{
 170  		RRSets: []internal.RRSet{{
 171  			Name:       name,
 172  			ChangeType: "REPLACE",
 173  			Type:       "TXT",
 174  			Kind:       "Master",
 175  			TTL:        d.config.TTL,
 176  			Records:    records,
 177  		}},
 178  	}
 179  
 180  	err = d.client.UpdateRecords(ctx, zone, rrSets)
 181  	if err != nil {
 182  		return fmt.Errorf("pdns: update records: %w", err)
 183  	}
 184  
 185  	err = d.client.Notify(ctx, zone)
 186  	if err != nil {
 187  		return fmt.Errorf("pdns: notify: %w", err)
 188  	}
 189  
 190  	return nil
 191  }
 192  
 193  // CleanUp removes the TXT record matching the specified parameters.
 194  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 195  	ctx := context.Background()
 196  
 197  	info := dns01.GetChallengeInfo(domain, keyAuth)
 198  
 199  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 200  	if err != nil {
 201  		return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err)
 202  	}
 203  
 204  	zone, err := d.client.GetHostedZone(ctx, authZone)
 205  	if err != nil {
 206  		return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err)
 207  	}
 208  
 209  	// Look for existing records.
 210  	set := findTxtRecord(zone, info.EffectiveFQDN)
 211  	if set == nil {
 212  		return fmt.Errorf("pdns: no existing record found for %s", info.EffectiveFQDN)
 213  	}
 214  
 215  	var records []internal.Record
 216  
 217  	for _, r := range set.Records {
 218  		if r.Content != strconv.Quote(info.Value) {
 219  			records = append(records, r)
 220  		}
 221  	}
 222  
 223  	rrSet := internal.RRSet{
 224  		Name: set.Name,
 225  		Type: set.Type,
 226  	}
 227  
 228  	if len(records) > 0 {
 229  		rrSet.ChangeType = "REPLACE"
 230  		rrSet.TTL = d.config.TTL
 231  		rrSet.Records = records
 232  	} else {
 233  		rrSet.ChangeType = "DELETE"
 234  	}
 235  
 236  	err = d.client.UpdateRecords(ctx, zone, internal.RRSets{RRSets: []internal.RRSet{rrSet}})
 237  	if err != nil {
 238  		return fmt.Errorf("pdns: update records: %w", err)
 239  	}
 240  
 241  	err = d.client.Notify(ctx, zone)
 242  	if err != nil {
 243  		return fmt.Errorf("pdns: notify: %w", err)
 244  	}
 245  
 246  	return nil
 247  }
 248  
 249  func findTxtRecord(zone *internal.HostedZone, fqdn string) *internal.RRSet {
 250  	for _, set := range zone.RRSets {
 251  		if set.Type == "TXT" && (set.Name == dns01.UnFqdn(fqdn) || set.Name == fqdn) {
 252  			return &set
 253  		}
 254  	}
 255  
 256  	return nil
 257  }
 258