1// Package edgedns replaces fastdns, implementing a DNS provider for solving the DNS-01 challenge using Akamai EdgeDNS.
2package edgedns
3
4import (
5	"errors"
6	"fmt"
7	"strings"
8	"time"
9
10	configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
11	"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
12	"github.com/go-acme/lego/v4/challenge/dns01"
13	"github.com/go-acme/lego/v4/log"
14	"github.com/go-acme/lego/v4/platform/config/env"
15)
16
17// Environment variables names.
18const (
19	envNamespace = "AKAMAI_"
20
21	EnvEdgeRc        = envNamespace + "EDGERC"
22	EnvEdgeRcSection = envNamespace + "EDGERC_SECTION"
23
24	EnvHost         = envNamespace + "HOST"
25	EnvClientToken  = envNamespace + "CLIENT_TOKEN"
26	EnvClientSecret = envNamespace + "CLIENT_SECRET"
27	EnvAccessToken  = envNamespace + "ACCESS_TOKEN"
28
29	EnvTTL                = envNamespace + "TTL"
30	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
31	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
32)
33
34const (
35	defaultPropagationTimeout = 3 * time.Minute
36	defaultPollInterval       = 15 * time.Second
37)
38
39const maxBody = 131072
40
41// Config is used to configure the creation of the DNSProvider.
42type Config struct {
43	edgegrid.Config
44	PropagationTimeout time.Duration
45	PollingInterval    time.Duration
46	TTL                int
47}
48
49// NewDefaultConfig returns a default configuration for the DNSProvider.
50func NewDefaultConfig() *Config {
51	return &Config{
52		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
53		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
54		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, defaultPollInterval),
55		Config:             edgegrid.Config{MaxBody: maxBody},
56	}
57}
58
59// DNSProvider implements the challenge.Provider interface.
60type DNSProvider struct {
61	config *Config
62}
63
64// NewDNSProvider returns a DNSProvider instance configured for Akamai EdgeDNS:
65// Akamai credentials are automatically detected in the following locations and prioritized in the following order:
66//
67// 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`
68// 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`
69// 3. .edgerc file located at `AKAMAI_EDGERC` (defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION`)
70// 4. Default environment variables: `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET`
71//
72// See also: https://developer.akamai.com/api/getting-started
73func NewDNSProvider() (*DNSProvider, error) {
74	config := NewDefaultConfig()
75
76	rcPath := env.GetOrDefaultString(EnvEdgeRc, "")
77	rcSection := env.GetOrDefaultString(EnvEdgeRcSection, "")
78
79	conf, err := edgegrid.Init(rcPath, rcSection)
80	if err != nil {
81		return nil, fmt.Errorf("edgedns: %w", err)
82	}
83
84	conf.MaxBody = maxBody
85
86	config.Config = conf
87
88	return NewDNSProviderConfig(config)
89}
90
91// NewDNSProviderConfig return a DNSProvider instance configured for EdgeDNS.
92func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
93	if config == nil {
94		return nil, errors.New("edgedns: the configuration of the DNS provider is nil")
95	}
96
97	configdns.Init(config.Config)
98
99	return &DNSProvider{config: config}, nil
100}
101
102// Timeout returns the timeout and interval to use when checking for DNS propagation.
103// Adjusting here to cope with spikes in propagation times.
104func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
105	return d.config.PropagationTimeout, d.config.PollingInterval
106}
107
108// Present creates a TXT record to fulfill the dns-01 challenge.
109func (d *DNSProvider) Present(domain, token, keyAuth string) error {
110	fqdn, value := dns01.GetRecord(domain, keyAuth)
111
112	zone, err := findZone(domain)
113	if err != nil {
114		return fmt.Errorf("edgedns: %w", err)
115	}
116
117	record, err := configdns.GetRecord(zone, fqdn, "TXT")
118	if err != nil && !isNotFound(err) {
119		return fmt.Errorf("edgedns: %w", err)
120	}
121
122	if err == nil && record == nil {
123		return fmt.Errorf("edgedns: unknown error")
124	}
125
126	if record != nil {
127		log.Infof("TXT record already exists. Updating target")
128
129		if containsValue(record.Target, value) {
130			// have a record and have entry already
131			return nil
132		}
133
134		record.Target = append(record.Target, `"`+value+`"`)
135		record.TTL = d.config.TTL
136
137		err = record.Update(zone)
138		if err != nil {
139			return fmt.Errorf("edgedns: %w", err)
140		}
141
142		return nil
143	}
144
145	record = &configdns.RecordBody{
146		Name:       fqdn,
147		RecordType: "TXT",
148		TTL:        d.config.TTL,
149		Target:     []string{`"` + value + `"`},
150	}
151
152	err = record.Save(zone)
153	if err != nil {
154		return fmt.Errorf("edgedns: %w", err)
155	}
156
157	return nil
158}
159
160// CleanUp removes the record matching the specified parameters.
161func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
162	fqdn, value := dns01.GetRecord(domain, keyAuth)
163
164	zone, err := findZone(domain)
165	if err != nil {
166		return fmt.Errorf("edgedns: %w", err)
167	}
168
169	existingRec, err := configdns.GetRecord(zone, fqdn, "TXT")
170	if err != nil {
171		if isNotFound(err) {
172			return nil
173		}
174		return fmt.Errorf("edgedns: %w", err)
175	}
176
177	if existingRec == nil {
178		return fmt.Errorf("edgedns: unknown failure")
179	}
180
181	if len(existingRec.Target) == 0 {
182		return fmt.Errorf("edgedns: TXT record is invalid")
183	}
184
185	if !containsValue(existingRec.Target, value) {
186		return nil
187	}
188
189	var newRData []string
190	for _, val := range existingRec.Target {
191		val = strings.Trim(val, `"`)
192		if val == value {
193			continue
194		}
195		newRData = append(newRData, val)
196	}
197
198	if len(newRData) > 0 {
199		existingRec.Target = newRData
200
201		err = existingRec.Update(zone)
202		if err != nil {
203			return fmt.Errorf("edgedns: %w", err)
204		}
205
206		return nil
207	}
208
209	err = existingRec.Delete(zone)
210	if err != nil {
211		return fmt.Errorf("edgedns: %w", err)
212	}
213
214	return nil
215}
216
217func findZone(domain string) (string, error) {
218	zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
219	if err != nil {
220		return "", err
221	}
222
223	return dns01.UnFqdn(zone), nil
224}
225
226func containsValue(values []string, value string) bool {
227	for _, val := range values {
228		if strings.Trim(val, `"`) == value {
229			return true
230		}
231	}
232
233	return false
234}
235
236func isNotFound(err error) bool {
237	if err == nil {
238		return false
239	}
240
241	var e configdns.ConfigDNSError
242	return errors.As(err, &e) && e.NotFound()
243}
244