mirror of
https://github.com/go-acme/lego
synced 2026-03-14 14:35:48 +01:00
319 lines
8.9 KiB
Go
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
|
|
}
|