loopia.go raw

   1  // Package loopia implements a DNS provider for solving the DNS-01 challenge using loopia DNS.
   2  package loopia
   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/loopia/internal"
  17  )
  18  
  19  // Environment variables names.
  20  const (
  21  	envNamespace = "LOOPIA_"
  22  
  23  	EnvAPIUser     = envNamespace + "API_USER"
  24  	EnvAPIPassword = envNamespace + "API_PASSWORD"
  25  	EnvAPIURL      = envNamespace + "API_URL"
  26  
  27  	EnvTTL                = envNamespace + "TTL"
  28  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  29  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  30  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  31  )
  32  
  33  const minTTL = 300
  34  
  35  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  36  
  37  type dnsClient interface {
  38  	AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error
  39  	RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error
  40  	GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error)
  41  	RemoveSubdomain(ctx context.Context, domain, subdomain string) error
  42  }
  43  
  44  // Config is used to configure the creation of the DNSProvider.
  45  type Config struct {
  46  	BaseURL            string
  47  	APIUser            string
  48  	APIPassword        string
  49  	PropagationTimeout time.Duration
  50  	PollingInterval    time.Duration
  51  	TTL                int
  52  	HTTPClient         *http.Client
  53  }
  54  
  55  // NewDefaultConfig returns a default configuration for the DNSProvider.
  56  func NewDefaultConfig() *Config {
  57  	return &Config{
  58  		TTL:                env.GetOrDefaultInt(EnvTTL, minTTL),
  59  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute),
  60  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPropagationTimeout),
  61  		HTTPClient: &http.Client{
  62  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
  63  		},
  64  	}
  65  }
  66  
  67  // DNSProvider implements the challenge.Provider interface.
  68  type DNSProvider struct {
  69  	config *Config
  70  	client dnsClient
  71  
  72  	inProgressInfo map[string]int
  73  	inProgressMu   sync.Mutex
  74  
  75  	// only for testing purpose.
  76  	findZoneByFqdn func(fqdn string) (string, error)
  77  }
  78  
  79  // NewDNSProvider returns a DNSProvider instance configured for Loopia.
  80  // Credentials must be passed in the environment variables:
  81  // LOOPIA_API_USER, LOOPIA_API_PASSWORD.
  82  func NewDNSProvider() (*DNSProvider, error) {
  83  	values, err := env.Get(EnvAPIUser, EnvAPIPassword)
  84  	if err != nil {
  85  		return nil, fmt.Errorf("loopia: %w", err)
  86  	}
  87  
  88  	config := NewDefaultConfig()
  89  	config.APIUser = values[EnvAPIUser]
  90  	config.APIPassword = values[EnvAPIPassword]
  91  	config.BaseURL = env.GetOrDefaultString(EnvAPIURL, internal.DefaultBaseURL)
  92  
  93  	return NewDNSProviderConfig(config)
  94  }
  95  
  96  // NewDNSProviderConfig return a DNSProvider instance configured for Loopia.
  97  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  98  	if config == nil {
  99  		return nil, errors.New("loopia: the configuration of the DNS provider is nil")
 100  	}
 101  
 102  	if config.APIUser == "" || config.APIPassword == "" {
 103  		return nil, errors.New("loopia: credentials missing")
 104  	}
 105  
 106  	// Min value for TTL is 300
 107  	if config.TTL < 300 {
 108  		config.TTL = 300
 109  	}
 110  
 111  	client := internal.NewClient(config.APIUser, config.APIPassword)
 112  
 113  	if config.HTTPClient != nil {
 114  		client.HTTPClient = config.HTTPClient
 115  	}
 116  
 117  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
 118  
 119  	if config.BaseURL != "" {
 120  		client.BaseURL = config.BaseURL
 121  	}
 122  
 123  	return &DNSProvider{
 124  		config:         config,
 125  		client:         client,
 126  		findZoneByFqdn: dns01.FindZoneByFqdn,
 127  		inProgressInfo: make(map[string]int),
 128  	}, nil
 129  }
 130  
 131  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 132  // Adjusting here to cope with spikes in propagation times.
 133  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 134  	return d.config.PropagationTimeout, d.config.PollingInterval
 135  }
 136  
 137  // Present creates a TXT record using the specified parameters.
 138  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 139  	info := dns01.GetChallengeInfo(domain, keyAuth)
 140  
 141  	subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN)
 142  	if err != nil {
 143  		return fmt.Errorf("loopia: %w", err)
 144  	}
 145  
 146  	ctx := context.Background()
 147  
 148  	err = d.client.AddTXTRecord(ctx, authZone, subDomain, d.config.TTL, info.Value)
 149  	if err != nil {
 150  		return fmt.Errorf("loopia: failed to add TXT record: %w", err)
 151  	}
 152  
 153  	txtRecords, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
 154  	if err != nil {
 155  		return fmt.Errorf("loopia: failed to get TXT records: %w", err)
 156  	}
 157  
 158  	d.inProgressMu.Lock()
 159  	defer d.inProgressMu.Unlock()
 160  
 161  	for _, r := range txtRecords {
 162  		if r.Rdata == info.Value {
 163  			d.inProgressInfo[token] = r.RecordID
 164  			return nil
 165  		}
 166  	}
 167  
 168  	return errors.New("loopia: failed to find the stored TXT record")
 169  }
 170  
 171  // CleanUp removes the TXT record matching the specified parameters.
 172  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 173  	info := dns01.GetChallengeInfo(domain, keyAuth)
 174  
 175  	subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN)
 176  	if err != nil {
 177  		return fmt.Errorf("loopia: %w", err)
 178  	}
 179  
 180  	d.inProgressMu.Lock()
 181  	defer d.inProgressMu.Unlock()
 182  
 183  	ctx := context.Background()
 184  
 185  	err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, d.inProgressInfo[token])
 186  	if err != nil {
 187  		return fmt.Errorf("loopia: failed to remove TXT record: %w", err)
 188  	}
 189  
 190  	records, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
 191  	if err != nil {
 192  		return fmt.Errorf("loopia: failed to get TXT records: %w", err)
 193  	}
 194  
 195  	if len(records) > 0 {
 196  		return nil
 197  	}
 198  
 199  	err = d.client.RemoveSubdomain(ctx, authZone, subDomain)
 200  	if err != nil {
 201  		return fmt.Errorf("loopia: failed to remove subdomain: %w", err)
 202  	}
 203  
 204  	return nil
 205  }
 206  
 207  func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) {
 208  	authZone, err := d.findZoneByFqdn(fqdn)
 209  	if err != nil {
 210  		return "", "", fmt.Errorf("could not find zone: %w", err)
 211  	}
 212  
 213  	subDomain, err := dns01.ExtractSubDomain(fqdn, authZone)
 214  	if err != nil {
 215  		return "", "", err
 216  	}
 217  
 218  	return subDomain, dns01.UnFqdn(authZone), nil
 219  }
 220