lego/providers/dns/azion/azion.go
2025-07-08 17:23:52 +02:00

319 lines
8.9 KiB
Go

// Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS.
package azion
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/aziontech/azionapi-go-sdk/idns"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
)
// Environment variables names.
const (
envNamespace = "AZION_"
EnvPersonalToken = envNamespace + "PERSONAL_TOKEN"
EnvPageSize = envNamespace + "PAGE_SIZE"
EnvTTL = envNamespace + "TTL"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
PersonalToken string
PageSize int
PollingInterval time.Duration
PropagationTimeout time.Duration
TTL int
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
PageSize: env.GetOrDefaultInt(EnvPageSize, 50),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client *idns.APIClient
recordIDs map[string]int32
recordIDsMu sync.Mutex
}
// NewDNSProvider returns a DNSProvider instance configured for Azion.
// Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvPersonalToken)
if err != nil {
return nil, fmt.Errorf("azion: %w", err)
}
config := NewDefaultConfig()
config.PersonalToken = values[EnvPersonalToken]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for Azion.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("azion: the configuration of the DNS provider is nil")
}
if config.PersonalToken == "" {
return nil, errors.New("azion: missing credentials")
}
clientConfig := idns.NewConfiguration()
clientConfig.AddDefaultHeader("Accept", "application/json; version=3")
clientConfig.UserAgent = "lego-dns/azion"
if config.HTTPClient != nil {
clientConfig.HTTPClient = config.HTTPClient
}
client := idns.NewAPIClient(clientConfig)
return &DNSProvider{
config: config,
client: client,
recordIDs: make(map[string]int32),
}, nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
ctxAuth := authContext(context.Background(), d.config.PersonalToken)
zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := extractSubDomain(info, zone)
if err != nil {
return fmt.Errorf("azion: %w", err)
}
// Check if a TXT record with the same name already exists
existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
if err != nil {
return fmt.Errorf("azion: check existing records: %w", err)
}
record := idns.NewRecordPostOrPut()
record.SetEntry(subDomain)
record.SetRecordType("TXT")
record.SetTtl(int32(d.config.TTL))
var resp *idns.PostOrPutRecordResponse
if existingRecord != nil {
// Update existing record by adding the new value to the existing ones
record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value))
// Use PUT to update the existing record
resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
if err != nil {
return fmt.Errorf("azion: update existing record: %w", err)
}
} else {
// Create a new record
record.SetAnswersList([]string{info.Value})
resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute()
if err != nil {
return fmt.Errorf("azion: create new zone record: %w", err)
}
}
if resp == nil || resp.Results == nil {
return errors.New("azion: create zone record error")
}
results := resp.GetResults()
d.recordIDsMu.Lock()
d.recordIDs[token] = results.GetId()
d.recordIDsMu.Unlock()
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
ctxAuth := authContext(context.Background(), d.config.PersonalToken)
zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := extractSubDomain(info, zone)
if err != nil {
return fmt.Errorf("azion: %w", err)
}
defer func() {
// Cleans the record ID.
d.recordIDsMu.Lock()
delete(d.recordIDs, token)
d.recordIDsMu.Unlock()
}()
existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
if err != nil {
return fmt.Errorf("azion: find existing record: %w", err)
}
if existingRecord == nil {
return nil
}
currentAnswers := existingRecord.GetAnswersList()
var updatedAnswers []string
for _, answer := range currentAnswers {
if answer != info.Value {
updatedAnswers = append(updatedAnswers, answer)
}
}
// If no answers remain, delete the entire record
if len(updatedAnswers) == 0 {
_, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute()
if errDelete != nil {
// If a record doesn't exist (404), consider cleanup successful
if resp != nil && resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("azion: delete record: %w", errDelete)
}
return nil
}
// Update the record with remaining answers
record := idns.NewRecordPostOrPut()
record.SetEntry(subDomain)
record.SetRecordType("TXT")
record.SetAnswersList(updatedAnswers)
record.SetTtl(existingRecord.GetTtl())
_, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
if err != nil {
return fmt.Errorf("azion: update record: %w", err)
}
return nil
}
func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) {
resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute()
if err != nil {
return nil, fmt.Errorf("get zones: %w", err)
}
if resp == nil {
return nil, errors.New("get zones: no results")
}
for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
for _, zone := range resp.GetResults() {
if zone.GetDomain() == domain {
return &zone, nil
}
}
}
return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn)
}
// findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone.
// It handles pagination to search through all pages of results.
func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) {
var page int64 = 1
for {
resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute()
if err != nil {
return nil, fmt.Errorf("get zone records (page %d): %w", page, err)
}
if resp == nil {
return nil, errors.New("get zone records: no results")
}
results, ok := resp.GetResultsOk()
if !ok || results == nil {
return nil, errors.New("get zone records: empty")
}
// Search for existing TXT record with the same name in current page
for _, record := range results.GetRecords() {
if record.GetRecordType() == "TXT" && record.GetEntry() == recordName {
return &record, nil
}
}
// Check if there are more pages to search
if page >= int64(resp.GetTotalPages()) {
break
}
page++
}
// No existing record found in any page
return nil, nil
}
func authContext(ctx context.Context, key string) context.Context {
return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{
"tokenAuth": {
Key: key,
Prefix: "Token",
},
})
}
func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) {
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName())
if err != nil {
return "", err
}
if subDomain != "" {
return subDomain, nil
}
return "@", nil
}