exoscale.go raw

   1  // Package exoscale implements a DNS provider for solving the DNS-01 challenge using Exoscale DNS.
   2  package exoscale
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"strconv"
  10  	"time"
  11  
  12  	egoscale "github.com/exoscale/egoscale/v3"
  13  	"github.com/exoscale/egoscale/v3/credentials"
  14  	"github.com/go-acme/lego/v4/challenge"
  15  	"github.com/go-acme/lego/v4/challenge/dns01"
  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/internal/useragent"
  19  )
  20  
  21  // Environment variables names.
  22  const (
  23  	envNamespace = "EXOSCALE_"
  24  
  25  	EnvAPISecret = envNamespace + "API_SECRET"
  26  	EnvAPIKey    = envNamespace + "API_KEY"
  27  	EnvEndpoint  = envNamespace + "ENDPOINT"
  28  
  29  	EnvTTL                = envNamespace + "TTL"
  30  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  31  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  32  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  33  )
  34  
  35  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  36  
  37  // Config is used to configure the creation of the DNSProvider.
  38  type Config struct {
  39  	APIKey             string
  40  	APISecret          string
  41  	Endpoint           string
  42  	HTTPTimeout        time.Duration
  43  	PropagationTimeout time.Duration
  44  	PollingInterval    time.Duration
  45  	TTL                int64
  46  }
  47  
  48  // NewDefaultConfig returns a default configuration for the DNSProvider.
  49  func NewDefaultConfig() *Config {
  50  	return &Config{
  51  		TTL:                int64(env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL)),
  52  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  53  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  54  		HTTPTimeout:        env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
  55  	}
  56  }
  57  
  58  // DNSProvider implements the challenge.Provider interface.
  59  type DNSProvider struct {
  60  	config *Config
  61  	client *egoscale.Client
  62  }
  63  
  64  // NewDNSProvider Credentials must be passed in the environment variables:
  65  // EXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT.
  66  func NewDNSProvider() (*DNSProvider, error) {
  67  	values, err := env.Get(EnvAPIKey, EnvAPISecret)
  68  	if err != nil {
  69  		return nil, fmt.Errorf("exoscale: %w", err)
  70  	}
  71  
  72  	config := NewDefaultConfig()
  73  	config.APIKey = values[EnvAPIKey]
  74  	config.APISecret = values[EnvAPISecret]
  75  	config.Endpoint = env.GetOrDefaultString(EnvEndpoint, string(egoscale.CHGva2))
  76  
  77  	return NewDNSProviderConfig(config)
  78  }
  79  
  80  // NewDNSProviderConfig return a DNSProvider instance configured for Exoscale.
  81  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  82  	if config == nil {
  83  		return nil, errors.New("exoscale: the configuration of the DNS provider is nil")
  84  	}
  85  
  86  	if config.APIKey == "" || config.APISecret == "" {
  87  		return nil, errors.New("exoscale: credentials missing")
  88  	}
  89  
  90  	client, err := egoscale.NewClient(
  91  		credentials.NewStaticCredentials(config.APIKey, config.APISecret),
  92  		egoscale.ClientOptWithEndpoint(egoscale.Endpoint(config.Endpoint)),
  93  		egoscale.ClientOptWithHTTPClient(clientdebug.Wrap(&http.Client{Timeout: config.HTTPTimeout})),
  94  		egoscale.ClientOptWithUserAgent(useragent.Get()),
  95  	)
  96  	if err != nil {
  97  		return nil, fmt.Errorf("exoscale: initializing client: %w", err)
  98  	}
  99  
 100  	return &DNSProvider{
 101  		client: client,
 102  		config: config,
 103  	}, nil
 104  }
 105  
 106  // Present creates a TXT record to fulfill the dns-01 challenge.
 107  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 108  	ctx := context.Background()
 109  
 110  	info := dns01.GetChallengeInfo(domain, keyAuth)
 111  
 112  	zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
 113  	if err != nil {
 114  		return fmt.Errorf("exoscale: %w", err)
 115  	}
 116  
 117  	zone, err := d.findExistingZone(ctx, zoneName)
 118  	if err != nil {
 119  		return fmt.Errorf("exoscale: %w", err)
 120  	}
 121  
 122  	if zone == nil {
 123  		return fmt.Errorf("exoscale: zone %q not found", zoneName)
 124  	}
 125  
 126  	recordRequest := egoscale.CreateDNSDomainRecordRequest{
 127  		Name:    recordName,
 128  		Ttl:     d.config.TTL,
 129  		Content: info.Value,
 130  		Type:    egoscale.CreateDNSDomainRecordRequestTypeTXT,
 131  	}
 132  
 133  	op, err := d.client.CreateDNSDomainRecord(ctx, zone.ID, recordRequest)
 134  	if err != nil {
 135  		return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
 136  	}
 137  
 138  	_, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess)
 139  	if err != nil {
 140  		return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
 141  	}
 142  
 143  	return nil
 144  }
 145  
 146  // CleanUp removes the record matching the specified parameters.
 147  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 148  	ctx := context.Background()
 149  
 150  	info := dns01.GetChallengeInfo(domain, keyAuth)
 151  
 152  	zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
 153  	if err != nil {
 154  		return fmt.Errorf("exoscale: %w", err)
 155  	}
 156  
 157  	zone, err := d.findExistingZone(ctx, zoneName)
 158  	if err != nil {
 159  		return fmt.Errorf("exoscale: %w", err)
 160  	}
 161  
 162  	if zone == nil {
 163  		return fmt.Errorf("exoscale: zone %q not found", zoneName)
 164  	}
 165  
 166  	recordID, err := d.findExistingRecordID(ctx, zone.ID, recordName, info.Value)
 167  	if err != nil {
 168  		return err
 169  	}
 170  
 171  	if recordID == "" {
 172  		return nil
 173  	}
 174  
 175  	op, err := d.client.DeleteDNSDomainRecord(ctx, zone.ID, recordID)
 176  	if err != nil {
 177  		return fmt.Errorf("exoscale: error while deleting DNS record: %w", err)
 178  	}
 179  
 180  	_, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess)
 181  	if err != nil {
 182  		return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
 183  	}
 184  
 185  	return nil
 186  }
 187  
 188  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 189  // Adjusting here to cope with spikes in propagation times.
 190  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 191  	return d.config.PropagationTimeout, d.config.PollingInterval
 192  }
 193  
 194  // findExistingZone Query Exoscale to find an existing zone for this name.
 195  // Returns nil result if no zone could be found.
 196  func (d *DNSProvider) findExistingZone(ctx context.Context, zoneName string) (*egoscale.DNSDomain, error) {
 197  	zones, err := d.client.ListDNSDomains(ctx)
 198  	if err != nil {
 199  		return nil, fmt.Errorf("error while retrieving DNS zones: %w", err)
 200  	}
 201  
 202  	for _, zone := range zones.DNSDomains {
 203  		if zone.UnicodeName == zoneName {
 204  			return &zone, nil
 205  		}
 206  	}
 207  
 208  	return nil, nil
 209  }
 210  
 211  // findExistingRecordID Query Exoscale to find an existing record for this name.
 212  // Returns empty result if no record could be found.
 213  func (d *DNSProvider) findExistingRecordID(ctx context.Context, zoneID egoscale.UUID, recordName, value string) (egoscale.UUID, error) {
 214  	records, err := d.client.ListDNSDomainRecords(ctx, zoneID)
 215  	if err != nil {
 216  		return "", fmt.Errorf("error while retrieving DNS records: %w", err)
 217  	}
 218  
 219  	for _, record := range records.DNSDomainRecords {
 220  		if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT &&
 221  			(record.Content == value || record.Content == strconv.Quote(value)) {
 222  			return record.ID, nil
 223  		}
 224  	}
 225  
 226  	return "", nil
 227  }
 228  
 229  // findZoneAndRecordName Extract DNS zone and DNS entry name.
 230  func (d *DNSProvider) findZoneAndRecordName(fqdn string) (string, string, error) {
 231  	zone, err := dns01.FindZoneByFqdn(fqdn)
 232  	if err != nil {
 233  		return "", "", fmt.Errorf("could not find zone: %w", err)
 234  	}
 235  
 236  	zone = dns01.UnFqdn(zone)
 237  
 238  	subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
 239  	if err != nil {
 240  		return "", "", err
 241  	}
 242  
 243  	return zone, subDomain, nil
 244  }
 245