huaweicloud.go raw

   1  // Package huaweicloud implements a DNS provider for solving the DNS-01 challenge using Huawei Cloud.
   2  package huaweicloud
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"strconv"
   9  	"strings"
  10  	"sync"
  11  	"time"
  12  
  13  	"github.com/cenkalti/backoff/v5"
  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/platform/wait"
  18  	"github.com/go-acme/lego/v4/providers/dns/huaweicloud/internal"
  19  	"github.com/go-acme/lego/v4/providers/dns/internal/ptr"
  20  	hwauthbasic "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic"
  21  	hwconfig "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/config"
  22  	hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2"
  23  	hwmodel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model"
  24  	hwregion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/region"
  25  )
  26  
  27  // Environment variables names.
  28  const (
  29  	envNamespace = "HUAWEICLOUD_"
  30  
  31  	EnvAccessKeyID     = envNamespace + "ACCESS_KEY_ID"
  32  	EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY"
  33  	EnvRegion          = envNamespace + "REGION"
  34  
  35  	EnvTTL                = envNamespace + "TTL"
  36  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  37  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  38  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  39  )
  40  
  41  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  42  
  43  // Config is used to configure the creation of the DNSProvider.
  44  type Config struct {
  45  	AccessKeyID     string
  46  	SecretAccessKey string
  47  	Region          string
  48  
  49  	PropagationTimeout time.Duration
  50  	PollingInterval    time.Duration
  51  	TTL                int32
  52  	HTTPTimeout        time.Duration
  53  }
  54  
  55  // NewDefaultConfig returns a default configuration for the DNSProvider.
  56  func NewDefaultConfig() *Config {
  57  	return &Config{
  58  		TTL:                int32(env.GetOrDefaultInt(EnvTTL, 300)),
  59  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  60  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  61  		HTTPTimeout:        env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  62  	}
  63  }
  64  
  65  // DNSProvider implements the challenge.Provider interface.
  66  type DNSProvider struct {
  67  	config *Config
  68  	client *internal.DnsClient
  69  
  70  	recordIDs   map[string]string
  71  	recordIDsMu sync.Mutex
  72  }
  73  
  74  // NewDNSProvider returns a DNSProvider instance configured for Huawei Cloud.
  75  // Credentials must be passed in the environment variables:
  76  // HUAWEICLOUD_ACCESS_KEY_ID, HUAWEICLOUD_SECRET_ACCESS_KEY, and HUAWEICLOUD_REGION.
  77  func NewDNSProvider() (*DNSProvider, error) {
  78  	values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion)
  79  	if err != nil {
  80  		return nil, fmt.Errorf("huaweicloud: %w", err)
  81  	}
  82  
  83  	config := NewDefaultConfig()
  84  	config.AccessKeyID = values[EnvAccessKeyID]
  85  	config.SecretAccessKey = values[EnvSecretAccessKey]
  86  	config.Region = values[EnvRegion]
  87  
  88  	return NewDNSProviderConfig(config)
  89  }
  90  
  91  // NewDNSProviderConfig return a DNSProvider instance configured for Huawei Cloud.
  92  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  93  	if config == nil {
  94  		return nil, errors.New("huaweicloud: the configuration of the DNS provider is nil")
  95  	}
  96  
  97  	if config.AccessKeyID == "" || config.SecretAccessKey == "" || config.Region == "" {
  98  		return nil, errors.New("huaweicloud: credentials missing")
  99  	}
 100  
 101  	auth, err := hwauthbasic.NewCredentialsBuilder().
 102  		WithAk(config.AccessKeyID).
 103  		WithSk(config.SecretAccessKey).
 104  		SafeBuild()
 105  	if err != nil {
 106  		return nil, fmt.Errorf("huaweicloud: crendential build: %w", err)
 107  	}
 108  
 109  	region, err := hwregion.SafeValueOf(config.Region)
 110  	if err != nil {
 111  		return nil, fmt.Errorf("huaweicloud: safe region: %w", err)
 112  	}
 113  
 114  	client, err := hwdns.DnsClientBuilder().
 115  		WithHttpConfig(hwconfig.DefaultHttpConfig().WithTimeout(config.HTTPTimeout)).
 116  		WithRegion(region).
 117  		WithCredential(auth).
 118  		SafeBuild()
 119  	if err != nil {
 120  		return nil, fmt.Errorf("huaweicloud: client build: %w", err)
 121  	}
 122  
 123  	return &DNSProvider{
 124  		config:    config,
 125  		client:    internal.NewDnsClient(client),
 126  		recordIDs: map[string]string{},
 127  	}, nil
 128  }
 129  
 130  // Present creates a TXT record using the specified parameters.
 131  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 132  	info := dns01.GetChallengeInfo(domain, keyAuth)
 133  
 134  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 135  	if err != nil {
 136  		return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err)
 137  	}
 138  
 139  	zoneID, err := d.getZoneID(authZone)
 140  	if err != nil {
 141  		return fmt.Errorf("huaweicloud: %w", err)
 142  	}
 143  
 144  	recordSetID, err := d.getOrCreateRecordSetID(domain, zoneID, info)
 145  	if err != nil {
 146  		return fmt.Errorf("huaweicloud: %w", err)
 147  	}
 148  
 149  	d.recordIDsMu.Lock()
 150  	d.recordIDs[token] = recordSetID
 151  	d.recordIDsMu.Unlock()
 152  
 153  	err = wait.Retry(context.Background(),
 154  		func() error {
 155  			rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{
 156  				ZoneId:      zoneID,
 157  				RecordsetId: recordSetID,
 158  			})
 159  			if errShow != nil {
 160  				return fmt.Errorf("show record set: %w", errShow)
 161  			}
 162  
 163  			if !strings.HasSuffix(ptr.Deref(rs.Status), "PENDING_") {
 164  				return nil
 165  			}
 166  
 167  			return fmt.Errorf("status: %s", ptr.Deref(rs.Status))
 168  		},
 169  		backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
 170  		backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
 171  	)
 172  	if err != nil {
 173  		return fmt.Errorf("huaweicloud: record set sync on %s: %w", domain, err)
 174  	}
 175  
 176  	return nil
 177  }
 178  
 179  // CleanUp removes the TXT record matching the specified parameters.
 180  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 181  	info := dns01.GetChallengeInfo(domain, keyAuth)
 182  
 183  	// gets the record's unique ID from when we created it
 184  	d.recordIDsMu.Lock()
 185  	recordID, ok := d.recordIDs[token]
 186  	d.recordIDsMu.Unlock()
 187  
 188  	if !ok {
 189  		return fmt.Errorf("huaweicloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 190  	}
 191  
 192  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 193  	if err != nil {
 194  		return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err)
 195  	}
 196  
 197  	zoneID, err := d.getZoneID(authZone)
 198  	if err != nil {
 199  		return fmt.Errorf("huaweicloud: %w", err)
 200  	}
 201  
 202  	request := &hwmodel.DeleteRecordSetRequest{
 203  		ZoneId:      zoneID,
 204  		RecordsetId: recordID,
 205  	}
 206  
 207  	_, err = d.client.DeleteRecordSet(request)
 208  	if err != nil {
 209  		return fmt.Errorf("huaweicloud: delete record: %w", err)
 210  	}
 211  
 212  	d.recordIDsMu.Lock()
 213  	delete(d.recordIDs, token)
 214  	d.recordIDsMu.Unlock()
 215  
 216  	return nil
 217  }
 218  
 219  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 220  // Adjusting here to cope with spikes in propagation times.
 221  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 222  	return d.config.PropagationTimeout, d.config.PollingInterval
 223  }
 224  
 225  func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.ChallengeInfo) (string, error) {
 226  	records, err := d.client.ListRecordSetsByZone(&hwmodel.ListRecordSetsByZoneRequest{
 227  		ZoneId: zoneID,
 228  		Name:   ptr.Pointer(info.EffectiveFQDN),
 229  	})
 230  	if err != nil {
 231  		return "", fmt.Errorf("record list: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err)
 232  	}
 233  
 234  	var existingRecordSet *hwmodel.ListRecordSets
 235  
 236  	for _, record := range ptr.Deref(records.Recordsets) {
 237  		if ptr.Deref(record.Type) == "TXT" && ptr.Deref(record.Name) == info.EffectiveFQDN {
 238  			existingRecordSet = &record
 239  		}
 240  	}
 241  
 242  	value := strconv.Quote(info.Value)
 243  
 244  	if existingRecordSet == nil {
 245  		request := &hwmodel.CreateRecordSetRequest{
 246  			ZoneId: zoneID,
 247  			Body: &hwmodel.CreateRecordSetRequestBody{
 248  				Name:        info.EffectiveFQDN,
 249  				Description: ptr.Pointer("Added TXT record for ACME dns-01 challenge using lego client"),
 250  				Type:        "TXT",
 251  				Ttl:         ptr.Pointer(d.config.TTL),
 252  				Records:     []string{value},
 253  			},
 254  		}
 255  
 256  		resp, errCreate := d.client.CreateRecordSet(request)
 257  		if errCreate != nil {
 258  			return "", fmt.Errorf("create record set: %w", errCreate)
 259  		}
 260  
 261  		return ptr.Deref(resp.Id), nil
 262  	}
 263  
 264  	updateRequest := &hwmodel.UpdateRecordSetRequest{
 265  		ZoneId:      zoneID,
 266  		RecordsetId: ptr.Deref(existingRecordSet.Id),
 267  		Body: &hwmodel.UpdateRecordSetReq{
 268  			Name:        existingRecordSet.Name,
 269  			Description: existingRecordSet.Description,
 270  			Type:        existingRecordSet.Type,
 271  			Ttl:         existingRecordSet.Ttl,
 272  			Records:     ptr.Pointer(append(ptr.Deref(existingRecordSet.Records), value)),
 273  		},
 274  	}
 275  
 276  	resp, err := d.client.UpdateRecordSet(updateRequest)
 277  	if err != nil {
 278  		return "", fmt.Errorf("update record set: %w", err)
 279  	}
 280  
 281  	return ptr.Deref(resp.Id), nil
 282  }
 283  
 284  func (d *DNSProvider) getZoneID(authZone string) (string, error) {
 285  	zones, err := d.client.ListPublicZones(&hwmodel.ListPublicZonesRequest{})
 286  	if err != nil {
 287  		return "", fmt.Errorf("unable to get zone: %w", err)
 288  	}
 289  
 290  	for _, zone := range ptr.Deref(zones.Zones) {
 291  		if ptr.Deref(zone.Name) == authZone {
 292  			return ptr.Deref(zone.Id), nil
 293  		}
 294  	}
 295  
 296  	return "", fmt.Errorf("zone %q not found", authZone)
 297  }
 298