aliesa.go raw

   1  // Package aliesa implements a DNS provider for solving the DNS-01 challenge using AlibabaCloud ESA.
   2  package aliesa
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"sync"
   9  	"time"
  10  
  11  	openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
  12  	"github.com/alibabacloud-go/tea/dara"
  13  	"github.com/aliyun/credentials-go/credentials"
  14  	esa "github.com/go-acme/esa-20240910/v2/client"
  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  )
  19  
  20  // Environment variables names.
  21  const (
  22  	envNamespace = "ALIESA_"
  23  
  24  	EnvRAMRole       = envNamespace + "RAM_ROLE"
  25  	EnvAccessKey     = envNamespace + "ACCESS_KEY"
  26  	EnvSecretKey     = envNamespace + "SECRET_KEY"
  27  	EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
  28  	EnvRegionID      = envNamespace + "REGION_ID"
  29  
  30  	EnvTTL                = envNamespace + "TTL"
  31  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  32  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  33  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  34  )
  35  
  36  const defaultRegionID = "cn-hangzhou"
  37  
  38  // Config is used to configure the creation of the DNSProvider.
  39  type Config struct {
  40  	RAMRole       string
  41  	APIKey        string
  42  	SecretKey     string
  43  	SecurityToken string
  44  	RegionID      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, dns01.DefaultTTL),
  56  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  57  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  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 *esa.Client
  66  
  67  	recordIDs   map[string]int64
  68  	recordIDsMu sync.Mutex
  69  }
  70  
  71  // NewDNSProvider returns a DNSProvider instance configured for AlibabaCloud ESA.
  72  func NewDNSProvider() (*DNSProvider, error) {
  73  	config := NewDefaultConfig()
  74  	config.RegionID = env.GetOrFile(EnvRegionID)
  75  
  76  	values, err := env.Get(EnvRAMRole)
  77  	if err == nil {
  78  		config.RAMRole = values[EnvRAMRole]
  79  		return NewDNSProviderConfig(config)
  80  	}
  81  
  82  	values, err = env.Get(EnvAccessKey, EnvSecretKey)
  83  	if err != nil {
  84  		return nil, fmt.Errorf("aliesa: %w", err)
  85  	}
  86  
  87  	config.APIKey = values[EnvAccessKey]
  88  	config.SecretKey = values[EnvSecretKey]
  89  	config.SecurityToken = env.GetOrFile(EnvSecurityToken)
  90  
  91  	return NewDNSProviderConfig(config)
  92  }
  93  
  94  // NewDNSProviderConfig return a DNSProvider instance configured for AlibabaCloud ESA.
  95  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  96  	if config == nil {
  97  		return nil, errors.New("aliesa: the configuration of the DNS provider is nil")
  98  	}
  99  
 100  	if config.RegionID == "" {
 101  		config.RegionID = defaultRegionID
 102  	}
 103  
 104  	cfg := new(openapi.Config).
 105  		SetRegionId(config.RegionID).
 106  		SetReadTimeout(int(config.HTTPTimeout.Milliseconds()))
 107  
 108  	switch {
 109  	case config.RAMRole != "":
 110  		// https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance
 111  		credentialsCfg := new(credentials.Config).
 112  			SetType("ecs_ram_role").
 113  			SetRoleName(config.RAMRole)
 114  
 115  		credentialClient, err := credentials.NewCredential(credentialsCfg)
 116  		if err != nil {
 117  			return nil, fmt.Errorf("aliesa: new credential: %w", err)
 118  		}
 119  
 120  		cfg = cfg.SetCredential(credentialClient)
 121  
 122  	case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "":
 123  		cfg = cfg.
 124  			SetAccessKeyId(config.APIKey).
 125  			SetAccessKeySecret(config.SecretKey).
 126  			SetSecurityToken(config.SecurityToken)
 127  
 128  	case config.APIKey != "" && config.SecretKey != "":
 129  		cfg = cfg.
 130  			SetAccessKeyId(config.APIKey).
 131  			SetAccessKeySecret(config.SecretKey)
 132  
 133  	default:
 134  		return nil, errors.New("aliesa: ram role or credentials missing")
 135  	}
 136  
 137  	client, err := esa.NewClient(cfg)
 138  	if err != nil {
 139  		return nil, fmt.Errorf("aliesa: new client: %w", err)
 140  	}
 141  
 142  	// Workaround to get a regional URL.
 143  	// https://github.com/alibabacloud-go/esa-20240910/blame/7660e3aab2045d4820e4b83427a154efe0c79319/client/client.go#L27
 144  	// The `EndpointRule` is hardcoded with an empty string, so the region is ignored.
 145  	client.Endpoint = nil
 146  	client.EndpointRule = ptr.Pointer("regional")
 147  
 148  	client.Endpoint, err = esa.GetEndpoint(client, dara.String("esa"), client.RegionId, client.EndpointRule, client.Network, client.Suffix, client.EndpointMap, client.Endpoint)
 149  	if err != nil {
 150  		return nil, fmt.Errorf("aliesa: get endpoint: %w", err)
 151  	}
 152  
 153  	return &DNSProvider{
 154  		config:    config,
 155  		client:    client,
 156  		recordIDs: make(map[string]int64),
 157  	}, nil
 158  }
 159  
 160  // Present creates a TXT record using the specified parameters.
 161  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 162  	ctx := context.Background()
 163  
 164  	info := dns01.GetChallengeInfo(domain, keyAuth)
 165  
 166  	siteID, err := d.getSiteID(ctx, info.EffectiveFQDN)
 167  	if err != nil {
 168  		return fmt.Errorf("aliesa: %w", err)
 169  	}
 170  
 171  	crReq := new(esa.CreateRecordRequest).
 172  		SetSiteId(siteID).
 173  		SetType("TXT").
 174  		SetRecordName(dns01.UnFqdn(info.EffectiveFQDN)).
 175  		SetTtl(int32(d.config.TTL)).
 176  		SetData(new(esa.CreateRecordRequestData).SetValue(info.Value))
 177  
 178  	// https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
 179  	crResp, err := esa.CreateRecordWithContext(ctx, d.client, crReq, &dara.RuntimeOptions{})
 180  	if err != nil {
 181  		return fmt.Errorf("aliesa: create record: %w", err)
 182  	}
 183  
 184  	d.recordIDsMu.Lock()
 185  	d.recordIDs[token] = ptr.Deref(crResp.Body.GetRecordId())
 186  	d.recordIDsMu.Unlock()
 187  
 188  	return nil
 189  }
 190  
 191  // CleanUp removes the TXT record matching the specified parameters.
 192  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 193  	ctx := context.Background()
 194  
 195  	info := dns01.GetChallengeInfo(domain, keyAuth)
 196  
 197  	// gets the record's unique ID
 198  	d.recordIDsMu.Lock()
 199  	recordID, ok := d.recordIDs[token]
 200  	d.recordIDsMu.Unlock()
 201  
 202  	if !ok {
 203  		return fmt.Errorf("aliesa: unknown record ID for '%s'", info.EffectiveFQDN)
 204  	}
 205  
 206  	drReq := new(esa.DeleteRecordRequest).
 207  		SetRecordId(recordID)
 208  
 209  	// https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord
 210  	_, err := esa.DeleteRecordWithContext(ctx, d.client, drReq, &dara.RuntimeOptions{})
 211  	if err != nil {
 212  		return fmt.Errorf("aliesa: delete record: %w", err)
 213  	}
 214  
 215  	d.recordIDsMu.Lock()
 216  	delete(d.recordIDs, token)
 217  	d.recordIDsMu.Unlock()
 218  
 219  	return nil
 220  }
 221  
 222  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 223  // Adjusting here to cope with spikes in propagation times.
 224  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 225  	return d.config.PropagationTimeout, d.config.PollingInterval
 226  }
 227  
 228  func (d *DNSProvider) getSiteID(ctx context.Context, fqdn string) (int64, error) {
 229  	authZone, err := dns01.FindZoneByFqdn(fqdn)
 230  	if err != nil {
 231  		return 0, fmt.Errorf("aliesa: could not find zone for domain %q: %w", fqdn, err)
 232  	}
 233  
 234  	lsReq := new(esa.ListSitesRequest).
 235  		SetSiteName(dns01.UnFqdn(authZone)).
 236  		SetSiteSearchType("suffix")
 237  
 238  	// https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
 239  	lsResp, err := esa.ListSitesWithContext(ctx, d.client, lsReq, &dara.RuntimeOptions{})
 240  	if err != nil {
 241  		return 0, fmt.Errorf("list sites: %w", err)
 242  	}
 243  
 244  	for f := range dns01.UnFqdnDomainsSeq(fqdn) {
 245  		domain := dns01.UnFqdn(f)
 246  
 247  		for _, site := range lsResp.Body.GetSites() {
 248  			if ptr.Deref(site.GetSiteName()) == domain {
 249  				return ptr.Deref(site.GetSiteId()), nil
 250  			}
 251  		}
 252  	}
 253  
 254  	return 0, fmt.Errorf("site not found (fqdn: %q)", fqdn)
 255  }
 256