variomedia.go raw

   1  // Package variomedia implements a DNS provider for solving the DNS-01 challenge using Variomedia DNS.
   2  package variomedia
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"strings"
  10  	"sync"
  11  	"time"
  12  
  13  	"github.com/cenkalti/backoff/v5"
  14  	"github.com/go-acme/lego/v4/challenge"
  15  	"github.com/go-acme/lego/v4/challenge/dns01"
  16  	"github.com/go-acme/lego/v4/log"
  17  	"github.com/go-acme/lego/v4/platform/config/env"
  18  	"github.com/go-acme/lego/v4/platform/wait"
  19  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  20  	"github.com/go-acme/lego/v4/providers/dns/variomedia/internal"
  21  )
  22  
  23  // Environment variables names.
  24  const (
  25  	envNamespace = "VARIOMEDIA_"
  26  
  27  	EnvAPIToken = envNamespace + "API_TOKEN"
  28  
  29  	EnvTTL                = envNamespace + "TTL"
  30  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  31  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  32  	EnvSequenceInterval   = envNamespace + "SEQUENCE_INTERVAL"
  33  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  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  	APIToken string
  41  
  42  	PropagationTimeout time.Duration
  43  	PollingInterval    time.Duration
  44  	SequenceInterval   time.Duration
  45  	TTL                int
  46  	HTTPClient         *http.Client
  47  }
  48  
  49  // NewDefaultConfig returns a default configuration for the DNSProvider.
  50  func NewDefaultConfig() *Config {
  51  	return &Config{
  52  		TTL:                env.GetOrDefaultInt(EnvTTL, 300),
  53  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  54  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  55  		SequenceInterval:   env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
  56  		HTTPClient: &http.Client{
  57  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  58  		},
  59  	}
  60  }
  61  
  62  // DNSProvider implements the challenge.Provider interface.
  63  type DNSProvider struct {
  64  	config *Config
  65  	client *internal.Client
  66  
  67  	recordIDs   map[string]string
  68  	recordIDsMu sync.Mutex
  69  }
  70  
  71  // NewDNSProvider returns a DNSProvider instance.
  72  func NewDNSProvider() (*DNSProvider, error) {
  73  	values, err := env.Get(EnvAPIToken)
  74  	if err != nil {
  75  		return nil, fmt.Errorf("variomedia: %w", err)
  76  	}
  77  
  78  	config := NewDefaultConfig()
  79  	config.APIToken = values[EnvAPIToken]
  80  
  81  	return NewDNSProviderConfig(config)
  82  }
  83  
  84  // NewDNSProviderConfig return a DNSProvider instance configured for Variomedia.
  85  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  86  	if config.APIToken == "" {
  87  		return nil, errors.New("variomedia: missing credentials")
  88  	}
  89  
  90  	client := internal.NewClient(config.APIToken)
  91  
  92  	if config.HTTPClient != nil {
  93  		client.HTTPClient = config.HTTPClient
  94  	}
  95  
  96  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
  97  
  98  	return &DNSProvider{
  99  		config:    config,
 100  		client:    client,
 101  		recordIDs: make(map[string]string),
 102  	}, nil
 103  }
 104  
 105  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 106  // Adjusting here to cope with spikes in propagation times.
 107  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 108  	return d.config.PropagationTimeout, d.config.PollingInterval
 109  }
 110  
 111  // Sequential All DNS challenges for this provider will be resolved sequentially.
 112  // Returns the interval between each iteration.
 113  func (d *DNSProvider) Sequential() time.Duration {
 114  	return d.config.SequenceInterval
 115  }
 116  
 117  // Present creates a TXT record to fulfill the dns-01 challenge.
 118  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 119  	info := dns01.GetChallengeInfo(domain, keyAuth)
 120  
 121  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 122  	if err != nil {
 123  		return fmt.Errorf("variomedia: could not find zone for domain %q: %w", domain, err)
 124  	}
 125  
 126  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 127  	if err != nil {
 128  		return fmt.Errorf("variomedia: %w", err)
 129  	}
 130  
 131  	ctx := context.Background()
 132  
 133  	record := internal.DNSRecord{
 134  		RecordType: "TXT",
 135  		Name:       subDomain,
 136  		Domain:     dns01.UnFqdn(authZone),
 137  		Data:       info.Value,
 138  		TTL:        d.config.TTL,
 139  	}
 140  
 141  	cdrr, err := d.client.CreateDNSRecord(ctx, record)
 142  	if err != nil {
 143  		return fmt.Errorf("variomedia: %w", err)
 144  	}
 145  
 146  	err = d.waitJob(ctx, domain, cdrr.Data.ID)
 147  	if err != nil {
 148  		return fmt.Errorf("variomedia: %w", err)
 149  	}
 150  
 151  	d.recordIDsMu.Lock()
 152  	d.recordIDs[token] = strings.TrimPrefix(cdrr.Data.Links.DNSRecord, "https://api.variomedia.de/dns-records/")
 153  	d.recordIDsMu.Unlock()
 154  
 155  	return nil
 156  }
 157  
 158  // CleanUp removes the TXT record previously created.
 159  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 160  	info := dns01.GetChallengeInfo(domain, keyAuth)
 161  
 162  	ctx := context.Background()
 163  
 164  	// get the record's unique ID from when we created it
 165  	d.recordIDsMu.Lock()
 166  	recordID, ok := d.recordIDs[token]
 167  	d.recordIDsMu.Unlock()
 168  
 169  	if !ok {
 170  		return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN)
 171  	}
 172  
 173  	ddrr, err := d.client.DeleteDNSRecord(ctx, recordID)
 174  	if err != nil {
 175  		return fmt.Errorf("variomedia: %w", err)
 176  	}
 177  
 178  	err = d.waitJob(ctx, domain, ddrr.Data.ID)
 179  	if err != nil {
 180  		return fmt.Errorf("variomedia: %w", err)
 181  	}
 182  
 183  	d.recordIDsMu.Lock()
 184  	delete(d.recordIDs, token)
 185  	d.recordIDsMu.Unlock()
 186  
 187  	return nil
 188  }
 189  
 190  func (d *DNSProvider) waitJob(ctx context.Context, domain, id string) error {
 191  	return wait.Retry(ctx,
 192  		func() error {
 193  			result, err := d.client.GetJob(ctx, id)
 194  			if err != nil {
 195  				return fmt.Errorf("apply change on %s: %w", domain, err)
 196  			}
 197  
 198  			log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status)
 199  
 200  			if result.Data.Attributes.Status != "done" {
 201  				return fmt.Errorf("apply change on %s: status: %s", domain, result.Data.Attributes.Status)
 202  			}
 203  
 204  			return nil
 205  		},
 206  		backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
 207  		backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
 208  	)
 209  }
 210