azion.go raw

   1  // Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS.
   2  package azion
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"time"
  10  
  11  	"github.com/aziontech/azionapi-go-sdk/idns"
  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/clientdebug"
  15  )
  16  
  17  // Environment variables names.
  18  const (
  19  	envNamespace = "AZION_"
  20  
  21  	EnvPersonalToken = envNamespace + "PERSONAL_TOKEN"
  22  	EnvPageSize      = envNamespace + "PAGE_SIZE"
  23  
  24  	EnvTTL                = envNamespace + "TTL"
  25  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  26  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  27  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  28  )
  29  
  30  // Config is used to configure the creation of the DNSProvider.
  31  type Config struct {
  32  	PersonalToken string
  33  	PageSize      int
  34  
  35  	PollingInterval    time.Duration
  36  	PropagationTimeout time.Duration
  37  	TTL                int
  38  	HTTPClient         *http.Client
  39  }
  40  
  41  // NewDefaultConfig returns a default configuration for the DNSProvider.
  42  func NewDefaultConfig() *Config {
  43  	return &Config{
  44  		PageSize:           env.GetOrDefaultInt(EnvPageSize, 50),
  45  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  46  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  47  		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  48  		HTTPClient: &http.Client{
  49  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  50  		},
  51  	}
  52  }
  53  
  54  // DNSProvider implements the challenge.Provider interface.
  55  type DNSProvider struct {
  56  	config *Config
  57  	client *idns.APIClient
  58  }
  59  
  60  // NewDNSProvider returns a DNSProvider instance configured for Azion.
  61  // Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN.
  62  func NewDNSProvider() (*DNSProvider, error) {
  63  	values, err := env.Get(EnvPersonalToken)
  64  	if err != nil {
  65  		return nil, fmt.Errorf("azion: %w", err)
  66  	}
  67  
  68  	config := NewDefaultConfig()
  69  	config.PersonalToken = values[EnvPersonalToken]
  70  
  71  	return NewDNSProviderConfig(config)
  72  }
  73  
  74  // NewDNSProviderConfig return a DNSProvider instance configured for Azion.
  75  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  76  	if config == nil {
  77  		return nil, errors.New("azion: the configuration of the DNS provider is nil")
  78  	}
  79  
  80  	if config.PersonalToken == "" {
  81  		return nil, errors.New("azion: missing credentials")
  82  	}
  83  
  84  	clientConfig := idns.NewConfiguration()
  85  	clientConfig.AddDefaultHeader("Accept", "application/json; version=3")
  86  	clientConfig.UserAgent = "lego-dns/azion"
  87  
  88  	if config.HTTPClient != nil {
  89  		clientConfig.HTTPClient = config.HTTPClient
  90  	}
  91  
  92  	clientConfig.HTTPClient = clientdebug.Wrap(clientConfig.HTTPClient)
  93  
  94  	client := idns.NewAPIClient(clientConfig)
  95  
  96  	return &DNSProvider{
  97  		config: config,
  98  		client: client,
  99  	}, nil
 100  }
 101  
 102  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 103  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 104  	return d.config.PropagationTimeout, d.config.PollingInterval
 105  }
 106  
 107  // Present creates a TXT record using the specified parameters.
 108  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 109  	info := dns01.GetChallengeInfo(domain, keyAuth)
 110  
 111  	ctxAuth := authContext(context.Background(), d.config.PersonalToken)
 112  
 113  	zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
 114  	if err != nil {
 115  		return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
 116  	}
 117  
 118  	subDomain, err := extractSubDomain(info, zone)
 119  	if err != nil {
 120  		return fmt.Errorf("azion: %w", err)
 121  	}
 122  
 123  	// Check if a TXT record with the same name already exists
 124  	existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
 125  	if err != nil {
 126  		return fmt.Errorf("azion: check existing records: %w", err)
 127  	}
 128  
 129  	record := idns.NewRecordPostOrPut()
 130  	record.SetEntry(subDomain)
 131  	record.SetRecordType("TXT")
 132  	record.SetTtl(int32(d.config.TTL))
 133  
 134  	var resp *idns.PostOrPutRecordResponse
 135  
 136  	if existingRecord != nil {
 137  		// Update existing record by adding the new value to the existing ones
 138  		record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value))
 139  
 140  		// Use PUT to update the existing record
 141  		resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
 142  		if err != nil {
 143  			return fmt.Errorf("azion: update existing record: %w", err)
 144  		}
 145  	} else {
 146  		// Create a new record
 147  		record.SetAnswersList([]string{info.Value})
 148  
 149  		resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute()
 150  		if err != nil {
 151  			return fmt.Errorf("azion: create new zone record: %w", err)
 152  		}
 153  	}
 154  
 155  	if resp == nil || resp.Results == nil {
 156  		return errors.New("azion: create zone record error")
 157  	}
 158  
 159  	return nil
 160  }
 161  
 162  // CleanUp removes the TXT record matching the specified parameters.
 163  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 164  	info := dns01.GetChallengeInfo(domain, keyAuth)
 165  
 166  	ctxAuth := authContext(context.Background(), d.config.PersonalToken)
 167  
 168  	zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
 169  	if err != nil {
 170  		return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
 171  	}
 172  
 173  	subDomain, err := extractSubDomain(info, zone)
 174  	if err != nil {
 175  		return fmt.Errorf("azion: %w", err)
 176  	}
 177  
 178  	existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
 179  	if err != nil {
 180  		return fmt.Errorf("azion: find existing record: %w", err)
 181  	}
 182  
 183  	if existingRecord == nil {
 184  		return nil
 185  	}
 186  
 187  	currentAnswers := existingRecord.GetAnswersList()
 188  
 189  	var updatedAnswers []string
 190  
 191  	for _, answer := range currentAnswers {
 192  		if answer != info.Value {
 193  			updatedAnswers = append(updatedAnswers, answer)
 194  		}
 195  	}
 196  
 197  	// If no answers remain, delete the entire record
 198  	if len(updatedAnswers) == 0 {
 199  		_, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute()
 200  		if errDelete != nil {
 201  			// If a record doesn't exist (404), consider cleanup successful
 202  			if resp != nil && resp.StatusCode == http.StatusNotFound {
 203  				return nil
 204  			}
 205  
 206  			return fmt.Errorf("azion: delete record: %w", errDelete)
 207  		}
 208  
 209  		return nil
 210  	}
 211  
 212  	// Update the record with remaining answers
 213  	record := idns.NewRecordPostOrPut()
 214  	record.SetEntry(subDomain)
 215  	record.SetRecordType("TXT")
 216  	record.SetAnswersList(updatedAnswers)
 217  	record.SetTtl(existingRecord.GetTtl())
 218  
 219  	_, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
 220  	if err != nil {
 221  		return fmt.Errorf("azion: update record: %w", err)
 222  	}
 223  
 224  	return nil
 225  }
 226  
 227  func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) {
 228  	resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute()
 229  	if err != nil {
 230  		return nil, fmt.Errorf("get zones: %w", err)
 231  	}
 232  
 233  	if resp == nil {
 234  		return nil, errors.New("get zones: no results")
 235  	}
 236  
 237  	for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
 238  		for _, zone := range resp.GetResults() {
 239  			if zone.GetDomain() == domain {
 240  				return &zone, nil
 241  			}
 242  		}
 243  	}
 244  
 245  	return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn)
 246  }
 247  
 248  // findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone.
 249  // It handles pagination to search through all pages of results.
 250  func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) {
 251  	var page int64 = 1
 252  
 253  	for {
 254  		resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute()
 255  		if err != nil {
 256  			return nil, fmt.Errorf("get zone records (page %d): %w", page, err)
 257  		}
 258  
 259  		if resp == nil {
 260  			return nil, errors.New("get zone records: no results")
 261  		}
 262  
 263  		results, ok := resp.GetResultsOk()
 264  		if !ok || results == nil {
 265  			return nil, errors.New("get zone records: empty")
 266  		}
 267  
 268  		// Search for existing TXT record with the same name in current page
 269  		for _, record := range results.GetRecords() {
 270  			if record.GetRecordType() == "TXT" && record.GetEntry() == recordName {
 271  				return &record, nil
 272  			}
 273  		}
 274  
 275  		// Check if there are more pages to search
 276  		if page >= int64(resp.GetTotalPages()) {
 277  			break
 278  		}
 279  
 280  		page++
 281  	}
 282  
 283  	// No existing record found in any page
 284  	return nil, nil
 285  }
 286  
 287  func authContext(ctx context.Context, key string) context.Context {
 288  	return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{
 289  		"tokenAuth": {
 290  			Key:    key,
 291  			Prefix: "Token",
 292  		},
 293  	})
 294  }
 295  
 296  func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) {
 297  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName())
 298  	if err != nil {
 299  		return "", err
 300  	}
 301  
 302  	if subDomain != "" {
 303  		return subDomain, nil
 304  	}
 305  
 306  	return "@", nil
 307  }
 308