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