gravity.go raw

   1  // Package gravity implements a DNS provider for solving the DNS-01 challenge using Gravity.
   2  package gravity
   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/dns01"
  13  	"github.com/go-acme/lego/v4/platform/config/env"
  14  	"github.com/go-acme/lego/v4/providers/dns/gravity/internal"
  15  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  16  	"github.com/google/uuid"
  17  )
  18  
  19  // Environment variables names.
  20  const (
  21  	envNamespace = "GRAVITY_"
  22  
  23  	EnvUsername  = envNamespace + "USERNAME"
  24  	EnvPassword  = envNamespace + "PASSWORD"
  25  	EnvServerURL = envNamespace + "SERVER_URL"
  26  
  27  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  28  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  29  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  30  	EnvSequenceInterval   = envNamespace + "SEQUENCE_INTERVAL"
  31  )
  32  
  33  // Config is used to configure the creation of the DNSProvider.
  34  type Config struct {
  35  	Username  string
  36  	Password  string
  37  	ServerURL string
  38  
  39  	PropagationTimeout time.Duration
  40  	PollingInterval    time.Duration
  41  	SequenceInterval   time.Duration
  42  	HTTPClient         *http.Client
  43  }
  44  
  45  // NewDefaultConfig returns a default configuration for the DNSProvider.
  46  func NewDefaultConfig() *Config {
  47  	return &Config{
  48  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  49  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  50  		SequenceInterval:   env.GetOrDefaultSecond(EnvSequenceInterval, 1*time.Second),
  51  		HTTPClient: &http.Client{
  52  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  53  		},
  54  	}
  55  }
  56  
  57  // DNSProvider implements the challenge.Provider interface.
  58  type DNSProvider struct {
  59  	config *Config
  60  	client *internal.Client
  61  
  62  	records   map[string]internal.Record
  63  	recordsMu sync.Mutex
  64  }
  65  
  66  // NewDNSProvider returns a DNSProvider instance configured for Gravity.
  67  func NewDNSProvider() (*DNSProvider, error) {
  68  	values, err := env.Get(EnvUsername, EnvPassword, EnvServerURL)
  69  	if err != nil {
  70  		return nil, fmt.Errorf("gravity: %w", err)
  71  	}
  72  
  73  	config := NewDefaultConfig()
  74  	config.Username = values[EnvUsername]
  75  	config.Password = values[EnvPassword]
  76  	config.ServerURL = values[EnvServerURL]
  77  
  78  	return NewDNSProviderConfig(config)
  79  }
  80  
  81  // NewDNSProviderConfig return a DNSProvider instance configured for Gravity.
  82  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  83  	if config == nil {
  84  		return nil, errors.New("gravity: the configuration of the DNS provider is nil")
  85  	}
  86  
  87  	client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
  88  	if err != nil {
  89  		return nil, fmt.Errorf("gravity: %w", err)
  90  	}
  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  		records: make(map[string]internal.Record),
 102  	}, nil
 103  }
 104  
 105  // Present creates a TXT record using the specified parameters.
 106  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 107  	ctx := context.Background()
 108  
 109  	info := dns01.GetChallengeInfo(domain, keyAuth)
 110  
 111  	_, err := d.client.Login(ctx)
 112  	if err != nil {
 113  		return fmt.Errorf("gravity: login: %w", err)
 114  	}
 115  
 116  	zone, err := d.findZone(ctx, info.EffectiveFQDN)
 117  	if err != nil {
 118  		return fmt.Errorf("gravity: %w", err)
 119  	}
 120  
 121  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
 122  	if err != nil {
 123  		return fmt.Errorf("gravity: %w", err)
 124  	}
 125  
 126  	id := uuid.New()
 127  
 128  	record := internal.Record{
 129  		Data:     info.Value,
 130  		Hostname: subDomain,
 131  		Type:     "TXT",
 132  		UID:      id.String(),
 133  	}
 134  
 135  	err = d.client.CreateDNSRecord(ctx, zone, record)
 136  	if err != nil {
 137  		return fmt.Errorf("gravity: create DNS record: %w", err)
 138  	}
 139  
 140  	d.recordsMu.Lock()
 141  
 142  	record.Fqdn = zone
 143  	d.records[token] = record
 144  	d.recordsMu.Unlock()
 145  
 146  	return nil
 147  }
 148  
 149  // CleanUp removes the TXT record matching the specified parameters.
 150  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 151  	info := dns01.GetChallengeInfo(domain, keyAuth)
 152  
 153  	d.recordsMu.Lock()
 154  	record, ok := d.records[token]
 155  	d.recordsMu.Unlock()
 156  
 157  	if !ok {
 158  		return fmt.Errorf("gravity: unknown record for '%s' '%s'", info.EffectiveFQDN, token)
 159  	}
 160  
 161  	err := d.client.DeleteDNSRecord(context.Background(), record.Fqdn, record)
 162  	if err != nil {
 163  		return fmt.Errorf("gravity: delete record: %w", err)
 164  	}
 165  
 166  	d.recordsMu.Lock()
 167  	delete(d.records, token)
 168  	d.recordsMu.Unlock()
 169  
 170  	return nil
 171  }
 172  
 173  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 174  // Adjusting here to cope with spikes in propagation times.
 175  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 176  	return d.config.PropagationTimeout, d.config.PollingInterval
 177  }
 178  
 179  // Sequential implements the [dns01.sequential] interface.
 180  // It changes the behavior of the provider to resolve DNS challenges sequentially.
 181  // Returns the interval between each iteration.
 182  //
 183  // Gravity supports adding multiple records for the same domain, but the DNS server doesn't work as expected:
 184  // if you call the DNS server, it will answer only the latest record instead of all of them.
 185  func (d *DNSProvider) Sequential() time.Duration {
 186  	return d.config.SequenceInterval
 187  }
 188  
 189  func (d *DNSProvider) findZone(ctx context.Context, effectiveFQDN string) (string, error) {
 190  	var zone string
 191  
 192  	for fqdn := range dns01.DomainsSeq(effectiveFQDN) {
 193  		zones, err := d.client.GetDNSZones(ctx, fqdn)
 194  		if err != nil {
 195  			return "", fmt.Errorf("get DNS zones: %w", err)
 196  		}
 197  
 198  		if len(zones) != 0 {
 199  			zone = zones[0].Name
 200  			break
 201  		}
 202  	}
 203  
 204  	if zone == "" {
 205  		return "", fmt.Errorf("could not find zone for %q", effectiveFQDN)
 206  	}
 207  
 208  	return zone, nil
 209  }
 210