jdcloud.go raw

   1  // Package jdcloud implements a DNS provider for solving the DNS-01 challenge using JD Cloud.
   2  package jdcloud
   3  
   4  import (
   5  	"errors"
   6  	"fmt"
   7  	"strconv"
   8  	"sync"
   9  	"time"
  10  
  11  	"github.com/go-acme/jdcloud-sdk-go/core"
  12  	"github.com/go-acme/jdcloud-sdk-go/services/domainservice/apis"
  13  	jdcclient "github.com/go-acme/jdcloud-sdk-go/services/domainservice/client"
  14  	domainservice "github.com/go-acme/jdcloud-sdk-go/services/domainservice/models"
  15  	"github.com/go-acme/lego/v4/challenge/dns01"
  16  	"github.com/go-acme/lego/v4/platform/config/env"
  17  )
  18  
  19  // Environment variables names.
  20  const (
  21  	envNamespace = "JDCLOUD_"
  22  
  23  	EnvAccessKeyID     = envNamespace + "ACCESS_KEY_ID"
  24  	EnvAccessKeySecret = envNamespace + "ACCESS_KEY_SECRET"
  25  	EnvRegionID        = envNamespace + "REGION_ID"
  26  
  27  	EnvTTL                = envNamespace + "TTL"
  28  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  29  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  30  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  31  )
  32  
  33  // Config is used to configure the creation of the DNSProvider.
  34  type Config struct {
  35  	AccessKeyID     string
  36  	AccessKeySecret string
  37  	RegionID        string
  38  
  39  	PropagationTimeout time.Duration
  40  	PollingInterval    time.Duration
  41  	TTL                int
  42  	HTTPTimeout        time.Duration
  43  }
  44  
  45  // NewDefaultConfig returns a default configuration for the DNSProvider.
  46  func NewDefaultConfig() *Config {
  47  	return &Config{
  48  		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  49  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  50  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  51  		HTTPTimeout:        env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  52  	}
  53  }
  54  
  55  // DNSProvider implements the challenge.Provider interface.
  56  type DNSProvider struct {
  57  	config *Config
  58  	client *jdcclient.DomainserviceClient
  59  
  60  	recordIDs   map[string]int
  61  	domainIDs   map[string]int
  62  	recordIDsMu sync.Mutex
  63  }
  64  
  65  // NewDNSProvider returns a DNSProvider instance configured for JD Cloud.
  66  func NewDNSProvider() (*DNSProvider, error) {
  67  	values, err := env.Get(EnvAccessKeyID, EnvAccessKeySecret)
  68  	if err != nil {
  69  		return nil, fmt.Errorf("jdcloud: %w", err)
  70  	}
  71  
  72  	config := NewDefaultConfig()
  73  	config.AccessKeyID = values[EnvAccessKeyID]
  74  	config.AccessKeySecret = values[EnvAccessKeySecret]
  75  
  76  	// https://docs.jdcloud.com/en/common-declaration/api/introduction#Region%20Code
  77  	config.RegionID = env.GetOrDefaultString(EnvRegionID, "cn-north-1")
  78  
  79  	return NewDNSProviderConfig(config)
  80  }
  81  
  82  // NewDNSProviderConfig return a DNSProvider instance configured for JD Cloud.
  83  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  84  	if config == nil {
  85  		return nil, errors.New("jdcloud: the configuration of the DNS provider is nil")
  86  	}
  87  
  88  	if config.AccessKeyID == "" || config.AccessKeySecret == "" {
  89  		return nil, errors.New("jdcloud: missing credentials")
  90  	}
  91  
  92  	cred := core.NewCredentials(config.AccessKeyID, config.AccessKeySecret)
  93  
  94  	client := jdcclient.NewDomainserviceClient(cred)
  95  	client.DisableLogger()
  96  	client.Config.SetTimeout(config.HTTPTimeout)
  97  
  98  	return &DNSProvider{
  99  		config:    config,
 100  		client:    client,
 101  		recordIDs: make(map[string]int),
 102  		domainIDs: make(map[string]int),
 103  	}, nil
 104  }
 105  
 106  // Present creates a TXT record using the specified parameters.
 107  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 108  	info := dns01.GetChallengeInfo(domain, keyAuth)
 109  
 110  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 111  	if err != nil {
 112  		return fmt.Errorf("jdcloud: could not find zone for domain %q: %w", domain, err)
 113  	}
 114  
 115  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 116  	if err != nil {
 117  		return fmt.Errorf("jdcloud: %w", err)
 118  	}
 119  
 120  	zone, err := d.findZone(dns01.UnFqdn(authZone))
 121  	if err != nil {
 122  		return fmt.Errorf("jdcloud: %w", err)
 123  	}
 124  
 125  	// https://docs.jdcloud.com/cn/jd-cloud-dns/api/createresourcerecord
 126  	crrr := apis.NewCreateResourceRecordRequestWithAllParams(
 127  		d.config.RegionID,
 128  		strconv.Itoa(zone.Id),
 129  		&domainservice.AddRR{
 130  			HostRecord: subDomain,
 131  			HostValue:  info.Value,
 132  			Ttl:        d.config.TTL,
 133  			Type:       "TXT",
 134  			ViewValue:  -1,
 135  		},
 136  	)
 137  
 138  	record, err := jdcclient.CreateResourceRecord(d.client, crrr)
 139  	if err != nil {
 140  		return fmt.Errorf("jdcloud: create resource record: %w", err)
 141  	}
 142  
 143  	d.recordIDsMu.Lock()
 144  	d.domainIDs[token] = zone.Id
 145  	d.recordIDs[token] = record.Result.DataList.Id
 146  	d.recordIDsMu.Unlock()
 147  
 148  	return nil
 149  }
 150  
 151  // CleanUp removes the TXT record matching the specified parameters.
 152  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 153  	info := dns01.GetChallengeInfo(domain, keyAuth)
 154  
 155  	d.recordIDsMu.Lock()
 156  	recordID, recordOK := d.recordIDs[token]
 157  	domainID, domainOK := d.domainIDs[token]
 158  	d.recordIDsMu.Unlock()
 159  
 160  	if !recordOK {
 161  		return fmt.Errorf("jdcloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 162  	}
 163  
 164  	if !domainOK {
 165  		return fmt.Errorf("jdcloud: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token)
 166  	}
 167  
 168  	// https://docs.jdcloud.com/cn/jd-cloud-dns/api/deleteresourcerecord
 169  	drrr := apis.NewDeleteResourceRecordRequestWithAllParams(
 170  		d.config.RegionID,
 171  		strconv.Itoa(domainID),
 172  		strconv.Itoa(recordID),
 173  	)
 174  
 175  	_, err := jdcclient.DeleteResourceRecord(d.client, drrr)
 176  	if err != nil {
 177  		return fmt.Errorf("jdcloud: delete resource record: %w", err)
 178  	}
 179  
 180  	return nil
 181  }
 182  
 183  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 184  // Adjusting here to cope with spikes in propagation times.
 185  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 186  	return d.config.PropagationTimeout, d.config.PollingInterval
 187  }
 188  
 189  func (d *DNSProvider) findZone(zone string) (*domainservice.DomainInfo, error) {
 190  	// https://docs.jdcloud.com/cn/jd-cloud-dns/api/describedomains
 191  	ddr := apis.NewDescribeDomainsRequestWithoutParam()
 192  	ddr.SetRegionId(d.config.RegionID)
 193  	ddr.SetPageNumber(1)
 194  	ddr.SetPageSize(10)
 195  	ddr.SetDomainName(zone)
 196  
 197  	for {
 198  		response, err := jdcclient.DescribeDomains(d.client, ddr)
 199  		if err != nil {
 200  			return nil, fmt.Errorf("describe domains: %w", err)
 201  		}
 202  
 203  		for _, d := range response.Result.DataList {
 204  			if d.DomainName == zone {
 205  				return &d, nil
 206  			}
 207  		}
 208  
 209  		if len(response.Result.DataList) < ddr.PageSize || response.Result.TotalPage <= ddr.PageNumber {
 210  			break
 211  		}
 212  
 213  		ddr.SetPageNumber(ddr.PageNumber + 1)
 214  	}
 215  
 216  	return nil, errors.New("zone not found")
 217  }
 218