alidns.go raw

   1  // Package alidns implements a DNS provider for solving the DNS-01 challenge using Alibaba Cloud DNS.
   2  package alidns
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"time"
   9  
  10  	openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
  11  	"github.com/alibabacloud-go/tea/dara"
  12  	"github.com/aliyun/credentials-go/credentials"
  13  	alidns "github.com/go-acme/alidns-20150109/v4/client"
  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/ptr"
  18  	"golang.org/x/net/idna"
  19  )
  20  
  21  // Environment variables names.
  22  const (
  23  	envNamespace = "ALICLOUD_"
  24  
  25  	EnvRAMRole       = envNamespace + "RAM_ROLE"
  26  	EnvAccessKey     = envNamespace + "ACCESS_KEY"
  27  	EnvSecretKey     = envNamespace + "SECRET_KEY"
  28  	EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
  29  	EnvRegionID      = envNamespace + "REGION_ID"
  30  
  31  	EnvTTL                = envNamespace + "TTL"
  32  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  33  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  34  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  35  )
  36  
  37  const defaultRegionID = "cn-hangzhou"
  38  
  39  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  40  
  41  // Config is used to configure the creation of the DNSProvider.
  42  type Config struct {
  43  	RAMRole            string
  44  	APIKey             string
  45  	SecretKey          string
  46  	SecurityToken      string
  47  	RegionID           string
  48  	PropagationTimeout time.Duration
  49  	PollingInterval    time.Duration
  50  	TTL                int
  51  	HTTPTimeout        time.Duration
  52  }
  53  
  54  // NewDefaultConfig returns a default configuration for the DNSProvider.
  55  func NewDefaultConfig() *Config {
  56  	return &Config{
  57  		TTL:                env.GetOrDefaultInt(EnvTTL, 600),
  58  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  59  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  60  		HTTPTimeout:        env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
  61  	}
  62  }
  63  
  64  // DNSProvider implements the challenge.Provider interface.
  65  type DNSProvider struct {
  66  	config *Config
  67  	client *alidns.Client
  68  }
  69  
  70  // NewDNSProvider returns a DNSProvider instance configured for Alibaba Cloud DNS.
  71  // - If you're using the instance RAM role, the RAM role environment variable must be passed in: ALICLOUD_RAM_ROLE.
  72  // - Other than that, credentials must be passed in the environment variables:
  73  // ALICLOUD_ACCESS_KEY, ALICLOUD_SECRET_KEY, and optionally ALICLOUD_SECURITY_TOKEN.
  74  func NewDNSProvider() (*DNSProvider, error) {
  75  	config := NewDefaultConfig()
  76  	config.RegionID = env.GetOrFile(EnvRegionID)
  77  
  78  	values, err := env.Get(EnvRAMRole)
  79  	if err == nil {
  80  		config.RAMRole = values[EnvRAMRole]
  81  		return NewDNSProviderConfig(config)
  82  	}
  83  
  84  	values, err = env.Get(EnvAccessKey, EnvSecretKey)
  85  	if err != nil {
  86  		return nil, fmt.Errorf("alicloud: %w", err)
  87  	}
  88  
  89  	config.APIKey = values[EnvAccessKey]
  90  	config.SecretKey = values[EnvSecretKey]
  91  	config.SecurityToken = env.GetOrFile(EnvSecurityToken)
  92  
  93  	return NewDNSProviderConfig(config)
  94  }
  95  
  96  // NewDNSProviderConfig return a DNSProvider instance configured for alidns.
  97  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  98  	if config == nil {
  99  		return nil, errors.New("alicloud: the configuration of the DNS provider is nil")
 100  	}
 101  
 102  	if config.RegionID == "" {
 103  		config.RegionID = defaultRegionID
 104  	}
 105  
 106  	cfg := new(openapi.Config).
 107  		SetRegionId(config.RegionID).
 108  		SetReadTimeout(int(config.HTTPTimeout.Milliseconds()))
 109  
 110  	switch {
 111  	case config.RAMRole != "":
 112  		// https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance
 113  		credentialsCfg := new(credentials.Config).
 114  			SetType("ecs_ram_role").
 115  			SetRoleName(config.RAMRole)
 116  
 117  		credentialClient, err := credentials.NewCredential(credentialsCfg)
 118  		if err != nil {
 119  			return nil, fmt.Errorf("alicloud: new credential: %w", err)
 120  		}
 121  
 122  		cfg = cfg.SetCredential(credentialClient)
 123  
 124  	case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "":
 125  		cfg = cfg.
 126  			SetAccessKeyId(config.APIKey).
 127  			SetAccessKeySecret(config.SecretKey).
 128  			SetSecurityToken(config.SecurityToken)
 129  
 130  	case config.APIKey != "" && config.SecretKey != "":
 131  		cfg = cfg.
 132  			SetAccessKeyId(config.APIKey).
 133  			SetAccessKeySecret(config.SecretKey)
 134  
 135  	default:
 136  		return nil, errors.New("alicloud: ram role or credentials missing")
 137  	}
 138  
 139  	client, err := alidns.NewClient(cfg)
 140  	if err != nil {
 141  		return nil, fmt.Errorf("alicloud: new client: %w", err)
 142  	}
 143  
 144  	return &DNSProvider{config: config, client: client}, nil
 145  }
 146  
 147  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 148  // Adjusting here to cope with spikes in propagation times.
 149  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 150  	return d.config.PropagationTimeout, d.config.PollingInterval
 151  }
 152  
 153  // Present creates a TXT record to fulfill the dns-01 challenge.
 154  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 155  	ctx := context.Background()
 156  
 157  	info := dns01.GetChallengeInfo(domain, keyAuth)
 158  
 159  	zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN)
 160  	if err != nil {
 161  		return fmt.Errorf("alicloud: %w", err)
 162  	}
 163  
 164  	recordRequest, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value)
 165  	if err != nil {
 166  		return err
 167  	}
 168  
 169  	_, err = alidns.AddDomainRecordWithContext(ctx, d.client, recordRequest, &dara.RuntimeOptions{})
 170  	if err != nil {
 171  		return fmt.Errorf("alicloud: API call failed: %w", err)
 172  	}
 173  
 174  	return nil
 175  }
 176  
 177  // CleanUp removes the TXT record matching the specified parameters.
 178  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 179  	ctx := context.Background()
 180  
 181  	info := dns01.GetChallengeInfo(domain, keyAuth)
 182  
 183  	records, err := d.findTxtRecords(ctx, info.EffectiveFQDN)
 184  	if err != nil {
 185  		return fmt.Errorf("alicloud: %w", err)
 186  	}
 187  
 188  	_, err = d.getHostedZone(ctx, info.EffectiveFQDN)
 189  	if err != nil {
 190  		return fmt.Errorf("alicloud: %w", err)
 191  	}
 192  
 193  	for _, rec := range records {
 194  		request := &alidns.DeleteDomainRecordRequest{
 195  			RecordId: rec.RecordId,
 196  		}
 197  
 198  		_, err = alidns.DeleteDomainRecordWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
 199  		if err != nil {
 200  			return fmt.Errorf("alicloud: %w", err)
 201  		}
 202  	}
 203  
 204  	return nil
 205  }
 206  
 207  func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) {
 208  	request := new(alidns.DescribeDomainsRequest)
 209  
 210  	var domains []*alidns.DescribeDomainsResponseBodyDomainsDomain
 211  
 212  	var startPage int64 = 1
 213  
 214  	for {
 215  		request.SetPageNumber(startPage)
 216  
 217  		response, err := alidns.DescribeDomainsWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
 218  		if err != nil {
 219  			return "", fmt.Errorf("API call failed: %w", err)
 220  		}
 221  
 222  		domains = append(domains, response.Body.Domains.Domain...)
 223  
 224  		if ptr.Deref(response.Body.PageNumber)*ptr.Deref(response.Body.PageSize) >= ptr.Deref(response.Body.TotalCount) {
 225  			break
 226  		}
 227  
 228  		startPage++
 229  	}
 230  
 231  	authZone, err := dns01.FindZoneByFqdn(domain)
 232  	if err != nil {
 233  		return "", fmt.Errorf("could not find zone: %w", err)
 234  	}
 235  
 236  	var hostedZone *alidns.DescribeDomainsResponseBodyDomainsDomain
 237  
 238  	for _, zone := range domains {
 239  		if ptr.Deref(zone.DomainName) == dns01.UnFqdn(authZone) || ptr.Deref(zone.PunyCode) == dns01.UnFqdn(authZone) {
 240  			hostedZone = zone
 241  		}
 242  	}
 243  
 244  	if hostedZone == nil || ptr.Deref(hostedZone.DomainId) == "" {
 245  		return "", fmt.Errorf("zone %s not found in AliDNS for domain %s", authZone, domain)
 246  	}
 247  
 248  	return ptr.Deref(hostedZone.DomainName), nil
 249  }
 250  
 251  func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainRecordRequest, error) {
 252  	rr, err := extractRecordName(fqdn, zone)
 253  	if err != nil {
 254  		return nil, err
 255  	}
 256  
 257  	return new(alidns.AddDomainRecordRequest).
 258  		SetType("TXT").
 259  		SetDomainName(zone).
 260  		SetRR(rr).
 261  		SetValue(value).
 262  		SetTTL(int64(d.config.TTL)), nil
 263  }
 264  
 265  func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) {
 266  	zoneName, err := d.getHostedZone(ctx, fqdn)
 267  	if err != nil {
 268  		return nil, err
 269  	}
 270  
 271  	request := new(alidns.DescribeDomainRecordsRequest).
 272  		SetDomainName(zoneName).
 273  		SetPageSize(500)
 274  
 275  	var records []*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord
 276  
 277  	result, err := alidns.DescribeDomainRecordsWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
 278  	if err != nil {
 279  		return records, fmt.Errorf("API call has failed: %w", err)
 280  	}
 281  
 282  	recordName, err := extractRecordName(fqdn, zoneName)
 283  	if err != nil {
 284  		return nil, err
 285  	}
 286  
 287  	for _, record := range result.Body.DomainRecords.Record {
 288  		if ptr.Deref(record.RR) == recordName && ptr.Deref(record.Type) == "TXT" {
 289  			records = append(records, record)
 290  		}
 291  	}
 292  
 293  	return records, nil
 294  }
 295  
 296  func extractRecordName(fqdn, zone string) (string, error) {
 297  	asciiDomain, err := idna.ToASCII(zone)
 298  	if err != nil {
 299  		return "", fmt.Errorf("fail to convert punycode: %w", err)
 300  	}
 301  
 302  	subDomain, err := dns01.ExtractSubDomain(fqdn, asciiDomain)
 303  	if err != nil {
 304  		return "", err
 305  	}
 306  
 307  	return subDomain, nil
 308  }
 309