edgeone.go raw

   1  // Package edgeone implements a DNS provider for solving the DNS-01 challenge using Tencent EdgeOne.
   2  package edgeone
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"math"
   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/internal/ptr"
  15  	teo "github.com/go-acme/tencentedgdeone/v20220901"
  16  	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
  17  	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
  18  	"golang.org/x/net/idna"
  19  )
  20  
  21  // Environment variables names.
  22  const (
  23  	envNamespace = "EDGEONE_"
  24  
  25  	EnvSecretID     = envNamespace + "SECRET_ID"
  26  	EnvSecretKey    = envNamespace + "SECRET_KEY"
  27  	EnvRegion       = envNamespace + "REGION"
  28  	EnvSessionToken = envNamespace + "SESSION_TOKEN"
  29  	EnvZonesMapping = envNamespace + "ZONES_MAPPING"
  30  
  31  	EnvTTL                = envNamespace + "TTL"
  32  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  33  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  34  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  35  )
  36  
  37  // Config is used to configure the creation of the DNSProvider.
  38  type Config struct {
  39  	SecretID     string
  40  	SecretKey    string
  41  	Region       string
  42  	SessionToken string
  43  
  44  	ZonesMapping map[string]string
  45  
  46  	PropagationTimeout time.Duration
  47  	PollingInterval    time.Duration
  48  	TTL                int
  49  	HTTPTimeout        time.Duration
  50  }
  51  
  52  // NewDefaultConfig returns a default configuration for the DNSProvider.
  53  func NewDefaultConfig() *Config {
  54  	return &Config{
  55  		TTL:                env.GetOrDefaultInt(EnvTTL, 60),
  56  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute),
  57  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second),
  58  		HTTPTimeout:        env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  59  	}
  60  }
  61  
  62  // DNSProvider implements the challenge.Provider interface.
  63  type DNSProvider struct {
  64  	config *Config
  65  	client *teo.Client
  66  
  67  	recordIDs   map[string]*string
  68  	recordIDsMu sync.Mutex
  69  }
  70  
  71  // NewDNSProvider returns a DNSProvider instance configured for Tencent EdgeOne.
  72  func NewDNSProvider() (*DNSProvider, error) {
  73  	values, err := env.Get(EnvSecretID, EnvSecretKey)
  74  	if err != nil {
  75  		return nil, fmt.Errorf("edgeone: %w", err)
  76  	}
  77  
  78  	config := NewDefaultConfig()
  79  	config.SecretID = values[EnvSecretID]
  80  	config.SecretKey = values[EnvSecretKey]
  81  	config.Region = env.GetOrDefaultString(EnvRegion, "")
  82  	config.SessionToken = env.GetOrDefaultString(EnvSessionToken, "")
  83  
  84  	mapping := env.GetOrDefaultString(EnvZonesMapping, "")
  85  	if mapping != "" {
  86  		config.ZonesMapping, err = env.ParsePairs(mapping)
  87  		if err != nil {
  88  			return nil, fmt.Errorf("edgeone: zones mapping: %w", err)
  89  		}
  90  	}
  91  
  92  	return NewDNSProviderConfig(config)
  93  }
  94  
  95  // NewDNSProviderConfig return a DNSProvider instance configured for Tencent EdgeOne.
  96  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  97  	if config == nil {
  98  		return nil, errors.New("edgeone: the configuration of the DNS provider is nil")
  99  	}
 100  
 101  	var credential *common.Credential
 102  
 103  	switch {
 104  	case config.SecretID != "" && config.SecretKey != "" && config.SessionToken != "":
 105  		credential = common.NewTokenCredential(config.SecretID, config.SecretKey, config.SessionToken)
 106  	case config.SecretID != "" && config.SecretKey != "":
 107  		credential = common.NewCredential(config.SecretID, config.SecretKey)
 108  	default:
 109  		return nil, errors.New("edgeone: credentials missing")
 110  	}
 111  
 112  	cpf := profile.NewClientProfile()
 113  	cpf.HttpProfile.Endpoint = "teo.intl.tencentcloudapi.com"
 114  	cpf.HttpProfile.ReqTimeout = int(math.Round(config.HTTPTimeout.Seconds()))
 115  
 116  	client, err := teo.NewClient(credential, config.Region, cpf)
 117  	if err != nil {
 118  		return nil, fmt.Errorf("edgeone: %w", err)
 119  	}
 120  
 121  	return &DNSProvider{
 122  		config:    config,
 123  		client:    client,
 124  		recordIDs: map[string]*string{},
 125  	}, nil
 126  }
 127  
 128  // Present creates a TXT record using the specified parameters.
 129  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 130  	info := dns01.GetChallengeInfo(domain, keyAuth)
 131  
 132  	ctx := context.Background()
 133  
 134  	zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
 135  	if err != nil {
 136  		return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
 137  	}
 138  
 139  	punnyCoded, err := idna.ToASCII(dns01.UnFqdn(info.EffectiveFQDN))
 140  	if err != nil {
 141  		return fmt.Errorf("edgeone: fail to convert punycode: %w", err)
 142  	}
 143  
 144  	request := teo.NewCreateDnsRecordRequest()
 145  	request.Name = ptr.Pointer(punnyCoded)
 146  	request.ZoneId = zoneID
 147  	request.Type = ptr.Pointer("TXT")
 148  	request.Content = ptr.Pointer(info.Value)
 149  	request.TTL = ptr.Pointer(int64(d.config.TTL))
 150  
 151  	nr, err := teo.CreateDnsRecordWithContext(ctx, d.client, request)
 152  	if err != nil {
 153  		return fmt.Errorf("edgeone: API call failed: %w", err)
 154  	}
 155  
 156  	d.recordIDsMu.Lock()
 157  	d.recordIDs[token] = nr.Response.RecordId
 158  	d.recordIDsMu.Unlock()
 159  
 160  	return nil
 161  }
 162  
 163  // CleanUp removes the TXT record matching the specified parameters.
 164  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 165  	info := dns01.GetChallengeInfo(domain, keyAuth)
 166  
 167  	ctx := context.Background()
 168  
 169  	zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
 170  	if err != nil {
 171  		return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
 172  	}
 173  
 174  	// get the record's unique ID from when we created it
 175  	d.recordIDsMu.Lock()
 176  	recordID, ok := d.recordIDs[token]
 177  	d.recordIDsMu.Unlock()
 178  
 179  	if !ok {
 180  		return fmt.Errorf("edgeone: unknown record ID for '%s'", info.EffectiveFQDN)
 181  	}
 182  
 183  	request := teo.NewDeleteDnsRecordsRequest()
 184  	request.ZoneId = zoneID
 185  	request.RecordIds = []*string{recordID}
 186  
 187  	_, err = teo.DeleteDnsRecordsWithContext(ctx, d.client, request)
 188  	if err != nil {
 189  		return fmt.Errorf("edgeone: delete record failed: %w", err)
 190  	}
 191  
 192  	d.recordIDsMu.Lock()
 193  	delete(d.recordIDs, token)
 194  	d.recordIDsMu.Unlock()
 195  
 196  	return nil
 197  }
 198  
 199  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 200  // Adjusting here to cope with spikes in propagation times.
 201  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 202  	return d.config.PropagationTimeout, d.config.PollingInterval
 203  }
 204