mittwald.go raw

   1  // Package mittwald implements a DNS provider for solving the DNS-01 challenge using Mittwald.
   2  package mittwald
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"sync"
  10  	"time"
  11  
  12  	"github.com/go-acme/lego/v4/challenge"
  13  	"github.com/go-acme/lego/v4/challenge/dns01"
  14  	"github.com/go-acme/lego/v4/platform/config/env"
  15  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  16  	"github.com/go-acme/lego/v4/providers/dns/mittwald/internal"
  17  )
  18  
  19  // Environment variables names.
  20  const (
  21  	envNamespace = "MITTWALD_"
  22  
  23  	EnvToken = envNamespace + "TOKEN"
  24  
  25  	EnvTTL                = envNamespace + "TTL"
  26  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  27  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  28  	EnvSequenceInterval   = envNamespace + "SEQUENCE_INTERVAL"
  29  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  30  )
  31  
  32  const minTTL = 300
  33  
  34  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  35  
  36  // Config is used to configure the creation of the DNSProvider.
  37  type Config struct {
  38  	Token              string
  39  	TTL                int
  40  	PropagationTimeout time.Duration
  41  	PollingInterval    time.Duration
  42  	SequenceInterval   time.Duration
  43  	HTTPClient         *http.Client
  44  }
  45  
  46  // NewDefaultConfig returns a default configuration for the DNSProvider.
  47  func NewDefaultConfig() *Config {
  48  	return &Config{
  49  		TTL:                env.GetOrDefaultInt(EnvTTL, minTTL),
  50  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
  51  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
  52  		SequenceInterval:   env.GetOrDefaultSecond(EnvSequenceInterval, 2*time.Minute),
  53  		HTTPClient: &http.Client{
  54  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  55  		},
  56  	}
  57  }
  58  
  59  // DNSProvider implements the challenge.Provider interface.
  60  type DNSProvider struct {
  61  	config *Config
  62  	client *internal.Client
  63  
  64  	zoneIDs   map[string]string
  65  	zoneIDsMu sync.Mutex
  66  }
  67  
  68  // NewDNSProvider returns a DNSProvider instance configured for Mittwald.
  69  // Credentials must be passed in the environment variables: MITTWALD_TOKEN.
  70  func NewDNSProvider() (*DNSProvider, error) {
  71  	values, err := env.Get(EnvToken)
  72  	if err != nil {
  73  		return nil, fmt.Errorf("mittwald: %w", err)
  74  	}
  75  
  76  	config := NewDefaultConfig()
  77  	config.Token = values[EnvToken]
  78  
  79  	return NewDNSProviderConfig(config)
  80  }
  81  
  82  // NewDNSProviderConfig return a DNSProvider instance configured for Mittwald.
  83  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  84  	if config == nil {
  85  		return nil, errors.New("mittwald: the configuration of the DNS provider is nil")
  86  	}
  87  
  88  	if config.Token == "" {
  89  		return nil, errors.New("mittwald: some credentials information are missing")
  90  	}
  91  
  92  	if config.TTL < minTTL {
  93  		return nil, fmt.Errorf("mittwald: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
  94  	}
  95  
  96  	client := internal.NewClient(config.Token)
  97  
  98  	if config.HTTPClient != nil {
  99  		client.HTTPClient = config.HTTPClient
 100  	}
 101  
 102  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
 103  
 104  	return &DNSProvider{
 105  		config:  config,
 106  		client:  client,
 107  		zoneIDs: map[string]string{},
 108  	}, nil
 109  }
 110  
 111  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 112  // Adjusting here to cope with spikes in propagation times.
 113  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 114  	return d.config.PropagationTimeout, d.config.PollingInterval
 115  }
 116  
 117  // Sequential All DNS challenges for this provider will be resolved sequentially.
 118  // Returns the interval between each iteration.
 119  func (d *DNSProvider) Sequential() time.Duration {
 120  	return d.config.SequenceInterval
 121  }
 122  
 123  // Present creates a TXT record to fulfill the dns-01 challenge.
 124  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 125  	ctx := context.Background()
 126  	info := dns01.GetChallengeInfo(domain, keyAuth)
 127  
 128  	zone, err := d.getOrCreateZone(ctx, info.EffectiveFQDN)
 129  	if err != nil {
 130  		return fmt.Errorf("mittwald: get effective zone: %w", err)
 131  	}
 132  
 133  	record := internal.TXTRecord{
 134  		Settings: internal.Settings{
 135  			TTL: internal.TTL{Seconds: d.config.TTL},
 136  		},
 137  		Entries: []string{info.Value},
 138  	}
 139  
 140  	err = d.client.UpdateTXTRecord(ctx, zone.ID, record)
 141  	if err != nil {
 142  		return fmt.Errorf("mittwald: update/add TXT record: %w", err)
 143  	}
 144  
 145  	d.zoneIDsMu.Lock()
 146  	d.zoneIDs[token] = zone.ID
 147  	d.zoneIDsMu.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  	ctx := context.Background()
 155  	info := dns01.GetChallengeInfo(domain, keyAuth)
 156  
 157  	// get the record's unique ID from when we created it
 158  	d.zoneIDsMu.Lock()
 159  	zoneID, ok := d.zoneIDs[token]
 160  	d.zoneIDsMu.Unlock()
 161  
 162  	if !ok {
 163  		return fmt.Errorf("mittwald: unknown zone ID for '%s'", info.EffectiveFQDN)
 164  	}
 165  
 166  	record := internal.TXTRecord{Entries: make([]string, 0)}
 167  
 168  	err := d.client.UpdateTXTRecord(ctx, zoneID, record)
 169  	if err != nil {
 170  		return fmt.Errorf("mittwald: update/delete TXT record: %w", err)
 171  	}
 172  
 173  	d.zoneIDsMu.Lock()
 174  	delete(d.zoneIDs, token)
 175  	d.zoneIDsMu.Unlock()
 176  
 177  	return nil
 178  }
 179  
 180  func (d *DNSProvider) getOrCreateZone(ctx context.Context, fqdn string) (*internal.DNSZone, error) {
 181  	domains, err := d.client.ListDomains(ctx)
 182  	if err != nil {
 183  		return nil, fmt.Errorf("list domains: %w", err)
 184  	}
 185  
 186  	dom, err := findDomain(domains, fqdn)
 187  	if err != nil {
 188  		return nil, fmt.Errorf("find domain: %w", err)
 189  	}
 190  
 191  	zones, err := d.client.ListDNSZones(ctx, dom.ProjectID)
 192  	if err != nil {
 193  		return nil, fmt.Errorf("list DNS zones: %w", err)
 194  	}
 195  
 196  	for _, zone := range zones {
 197  		if zone.Domain == dns01.UnFqdn(fqdn) {
 198  			return &zone, nil
 199  		}
 200  	}
 201  
 202  	// Looking for parent zone to create a new zone for the subdomain.
 203  
 204  	parentZone, err := findZone(zones, fqdn)
 205  	if err != nil {
 206  		return nil, fmt.Errorf("find zone: %w", err)
 207  	}
 208  
 209  	subDomain, err := dns01.ExtractSubDomain(fqdn, parentZone.Domain)
 210  	if err != nil {
 211  		return nil, err
 212  	}
 213  
 214  	request := internal.CreateDNSZoneRequest{
 215  		Name:         subDomain,
 216  		ParentZoneID: parentZone.ID,
 217  	}
 218  
 219  	zone, err := d.client.CreateDNSZone(ctx, request)
 220  	if err != nil {
 221  		return nil, fmt.Errorf("create DNS zone: %w", err)
 222  	}
 223  
 224  	return zone, nil
 225  }
 226  
 227  func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) {
 228  	for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
 229  		for _, dom := range domains {
 230  			if dom.Domain == domain {
 231  				return dom, nil
 232  			}
 233  		}
 234  	}
 235  
 236  	return internal.Domain{}, fmt.Errorf("domain %s not found", fqdn)
 237  }
 238  
 239  func findZone(zones []internal.DNSZone, fqdn string) (internal.DNSZone, error) {
 240  	for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
 241  		for _, zon := range zones {
 242  			if zon.Domain == domain {
 243  				return zon, nil
 244  			}
 245  		}
 246  	}
 247  
 248  	return internal.DNSZone{}, fmt.Errorf("zone %s not found", fqdn)
 249  }
 250