edgedns.go raw

   1  // Package edgedns replaces fastdns, implementing a DNS provider for solving the DNS-01 challenge using Akamai EdgeDNS.
   2  package edgedns
   3  
   4  import (
   5  	"context"
   6  	"errors"
   7  	"fmt"
   8  	"net/http"
   9  	"slices"
  10  	"strings"
  11  	"time"
  12  
  13  	edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns"
  14  	"github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid"
  15  	"github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session"
  16  	"github.com/go-acme/lego/v4/challenge"
  17  	"github.com/go-acme/lego/v4/challenge/dns01"
  18  	"github.com/go-acme/lego/v4/log"
  19  	"github.com/go-acme/lego/v4/platform/config/env"
  20  )
  21  
  22  // Environment variables names.
  23  const (
  24  	envNamespace = "AKAMAI_"
  25  
  26  	EnvEdgeRc           = envNamespace + "EDGERC"
  27  	EnvEdgeRcSection    = envNamespace + "EDGERC_SECTION"
  28  	EnvAccountSwitchKey = envNamespace + "ACCOUNT_SWITCH_KEY"
  29  
  30  	EnvTTL                = envNamespace + "TTL"
  31  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  32  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  33  )
  34  
  35  // Test Environment variables names (unused).
  36  // TODO(ldez): must be moved into test files.
  37  const (
  38  	EnvHost         = envNamespace + "HOST"
  39  	EnvClientToken  = envNamespace + "CLIENT_TOKEN"
  40  	EnvClientSecret = envNamespace + "CLIENT_SECRET"
  41  	EnvAccessToken  = envNamespace + "ACCESS_TOKEN"
  42  )
  43  
  44  const (
  45  	defaultPropagationTimeout = 3 * time.Minute
  46  	defaultPollInterval       = 15 * time.Second
  47  )
  48  
  49  const maxBody = 131072
  50  
  51  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  52  
  53  // Config is used to configure the creation of the DNSProvider.
  54  type Config struct {
  55  	*edgegrid.Config
  56  
  57  	PropagationTimeout time.Duration
  58  	PollingInterval    time.Duration
  59  	TTL                int
  60  }
  61  
  62  // NewDefaultConfig returns a default configuration for the DNSProvider.
  63  func NewDefaultConfig() *Config {
  64  	return &Config{
  65  		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  66  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
  67  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, defaultPollInterval),
  68  		Config:             &edgegrid.Config{MaxBody: maxBody},
  69  	}
  70  }
  71  
  72  // DNSProvider implements the challenge.Provider interface.
  73  type DNSProvider struct {
  74  	config *Config
  75  }
  76  
  77  // NewDNSProvider returns a DNSProvider instance configured for Akamai EdgeDNS:
  78  // Akamai's credentials are automatically detected in the following locations and prioritized in the following order:
  79  //
  80  // 1. Section-specific environment variables `AKAMAI_{SECTION}_HOST`, `AKAMAI_{SECTION}_ACCESS_TOKEN`, `AKAMAI_{SECTION}_CLIENT_TOKEN`, `AKAMAI_{SECTION}_CLIENT_SECRET` where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION`
  81  // 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`: Environment variables `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET`
  82  // 3. .edgerc file located at `AKAMAI_EDGERC` (defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION`)
  83  //
  84  // See also: https://developer.akamai.com/api/getting-started
  85  func NewDNSProvider() (*DNSProvider, error) {
  86  	conf, err := edgegrid.New(
  87  		edgegrid.WithEnv(true),
  88  		edgegrid.WithFile(env.GetOrDefaultString(EnvEdgeRc, "~/.edgerc")),
  89  		edgegrid.WithSection(env.GetOrDefaultString(EnvEdgeRcSection, "default")),
  90  	)
  91  	if err != nil {
  92  		return nil, fmt.Errorf("edgedns: %w", err)
  93  	}
  94  
  95  	conf.MaxBody = maxBody
  96  
  97  	accountSwitchKey := env.GetOrDefaultString(EnvAccountSwitchKey, "")
  98  
  99  	if accountSwitchKey != "" {
 100  		conf.AccountKey = accountSwitchKey
 101  	}
 102  
 103  	config := NewDefaultConfig()
 104  	config.Config = conf
 105  
 106  	return NewDNSProviderConfig(config)
 107  }
 108  
 109  // NewDNSProviderConfig return a DNSProvider instance configured for EdgeDNS.
 110  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 111  	if config == nil {
 112  		return nil, errors.New("edgedns: the configuration of the DNS provider is nil")
 113  	}
 114  
 115  	err := config.Validate()
 116  	if err != nil {
 117  		return nil, fmt.Errorf("edgedns: %w", err)
 118  	}
 119  
 120  	return &DNSProvider{config: config}, nil
 121  }
 122  
 123  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 124  // Adjusting here to cope with spikes in propagation times.
 125  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 126  	return d.config.PropagationTimeout, d.config.PollingInterval
 127  }
 128  
 129  // Present creates a TXT record to fulfill the dns-01 challenge.
 130  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 131  	ctx := context.Background()
 132  
 133  	info := dns01.GetChallengeInfo(domain, keyAuth)
 134  
 135  	sess, err := session.New(session.WithSigner(d.config))
 136  	if err != nil {
 137  		return fmt.Errorf("edgedns: %w", err)
 138  	}
 139  
 140  	client := edgegriddns.Client(sess)
 141  
 142  	zone, err := getZone(info.EffectiveFQDN)
 143  	if err != nil {
 144  		return fmt.Errorf("edgedns: %w", err)
 145  	}
 146  
 147  	record, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{
 148  		Zone:       zone,
 149  		Name:       info.EffectiveFQDN,
 150  		RecordType: "TXT",
 151  	})
 152  	if err != nil && !isNotFound(err) {
 153  		return fmt.Errorf("edgedns: %w", err)
 154  	}
 155  
 156  	if err == nil && record == nil {
 157  		return errors.New("edgedns: unknown error")
 158  	}
 159  
 160  	if record != nil {
 161  		log.Infof("TXT record already exists. Updating target")
 162  
 163  		if containsValue(record.Target, info.Value) {
 164  			// have a record and have entry already
 165  			return nil
 166  		}
 167  
 168  		record.Target = append(record.Target, `"`+info.Value+`"`)
 169  		record.TTL = d.config.TTL
 170  
 171  		err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{
 172  			Record: &edgegriddns.RecordBody{
 173  				Name:       record.Name,
 174  				RecordType: record.RecordType,
 175  				TTL:        record.TTL,
 176  				Active:     record.Active,
 177  				Target:     record.Target,
 178  			},
 179  			Zone: zone,
 180  		})
 181  		if err != nil {
 182  			return fmt.Errorf("edgedns: %w", err)
 183  		}
 184  
 185  		return nil
 186  	}
 187  
 188  	err = client.CreateRecord(ctx, edgegriddns.CreateRecordRequest{
 189  		Record: &edgegriddns.RecordBody{
 190  			Name:       info.EffectiveFQDN,
 191  			RecordType: "TXT",
 192  			TTL:        d.config.TTL,
 193  			Target:     []string{`"` + info.Value + `"`},
 194  		},
 195  		Zone:    zone,
 196  		RecLock: nil,
 197  	})
 198  	if err != nil {
 199  		return fmt.Errorf("edgedns: %w", err)
 200  	}
 201  
 202  	return nil
 203  }
 204  
 205  // CleanUp removes the record matching the specified parameters.
 206  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 207  	ctx := context.Background()
 208  
 209  	info := dns01.GetChallengeInfo(domain, keyAuth)
 210  
 211  	sess, err := session.New(session.WithSigner(d.config))
 212  	if err != nil {
 213  		return fmt.Errorf("edgedns: %w", err)
 214  	}
 215  
 216  	client := edgegriddns.Client(sess)
 217  
 218  	zone, err := getZone(info.EffectiveFQDN)
 219  	if err != nil {
 220  		return fmt.Errorf("edgedns: %w", err)
 221  	}
 222  
 223  	existingRec, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{
 224  		Zone:       zone,
 225  		Name:       info.EffectiveFQDN,
 226  		RecordType: "TXT",
 227  	})
 228  	if err != nil {
 229  		if isNotFound(err) {
 230  			return nil
 231  		}
 232  
 233  		return fmt.Errorf("edgedns: %w", err)
 234  	}
 235  
 236  	if existingRec == nil {
 237  		return errors.New("edgedns: unknown failure")
 238  	}
 239  
 240  	if len(existingRec.Target) == 0 {
 241  		return errors.New("edgedns: TXT record is invalid")
 242  	}
 243  
 244  	if !containsValue(existingRec.Target, info.Value) {
 245  		return nil
 246  	}
 247  
 248  	newRData := filterRData(existingRec, info)
 249  
 250  	if len(newRData) > 0 {
 251  		existingRec.Target = newRData
 252  
 253  		err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{
 254  			Record: &edgegriddns.RecordBody{
 255  				Name:       existingRec.Name,
 256  				RecordType: existingRec.RecordType,
 257  				TTL:        existingRec.TTL,
 258  				Active:     existingRec.Active,
 259  				Target:     existingRec.Target,
 260  			},
 261  			Zone: zone,
 262  		})
 263  		if err != nil {
 264  			return fmt.Errorf("edgedns: %w", err)
 265  		}
 266  
 267  		return nil
 268  	}
 269  
 270  	err = client.DeleteRecord(ctx, edgegriddns.DeleteRecordRequest{
 271  		Zone:       zone,
 272  		Name:       existingRec.Name,
 273  		RecordType: "TXT",
 274  		RecLock:    nil,
 275  	})
 276  	if err != nil {
 277  		return fmt.Errorf("edgedns: %w", err)
 278  	}
 279  
 280  	return nil
 281  }
 282  
 283  func getZone(domain string) (string, error) {
 284  	zone, err := dns01.FindZoneByFqdn(domain)
 285  	if err != nil {
 286  		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 287  	}
 288  
 289  	return dns01.UnFqdn(zone), nil
 290  }
 291  
 292  func containsValue(values []string, value string) bool {
 293  	return slices.ContainsFunc(values, func(val string) bool {
 294  		return strings.Trim(val, `"`) == value
 295  	})
 296  }
 297  
 298  func isNotFound(err error) bool {
 299  	if err == nil {
 300  		return false
 301  	}
 302  
 303  	var e *edgegriddns.Error
 304  
 305  	return errors.As(err, &e) && e.StatusCode == http.StatusNotFound
 306  }
 307  
 308  func filterRData(existingRec *edgegriddns.GetRecordResponse, info dns01.ChallengeInfo) []string {
 309  	var newRData []string
 310  
 311  	for _, val := range existingRec.Target {
 312  		val = strings.Trim(val, `"`)
 313  		if val == info.Value {
 314  			continue
 315  		}
 316  
 317  		newRData = append(newRData, val)
 318  	}
 319  
 320  	return newRData
 321  }
 322