anexia.go raw
1 // Package anexia implements a DNS provider for solving the DNS-01 challenge using Anexia CloudDNS.
2 package anexia
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "strconv"
11 "time"
12
13 "github.com/cenkalti/backoff/v5"
14 "github.com/go-acme/lego/v4/challenge"
15 "github.com/go-acme/lego/v4/challenge/dns01"
16 "github.com/go-acme/lego/v4/platform/config/env"
17 "github.com/go-acme/lego/v4/providers/dns/anexia/internal"
18 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
19 )
20
21 // Environment variables names.
22 const (
23 envNamespace = "ANEXIA_"
24
25 EnvToken = envNamespace + "TOKEN"
26 EnvAPIURL = envNamespace + "API_URL"
27
28 EnvTTL = envNamespace + "TTL"
29 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
30 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
31 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
32 )
33
34 const defaultTTL = 300
35
36 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
37
38 // Config is used to configure the creation of the DNSProvider.
39 type Config struct {
40 Token string
41 APIURL string
42
43 TTL int
44 PropagationTimeout time.Duration
45 PollingInterval time.Duration
46 HTTPClient *http.Client
47 }
48
49 // NewDefaultConfig returns a default configuration for the DNSProvider.
50 func NewDefaultConfig() *Config {
51 return &Config{
52 TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
53 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
54 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
55 HTTPClient: &http.Client{
56 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
57 },
58 }
59 }
60
61 // DNSProvider implements the challenge.Provider interface.
62 type DNSProvider struct {
63 config *Config
64 client *internal.Client
65 }
66
67 // NewDNSProvider returns a DNSProvider instance configured for Anexia CloudDNS.
68 // Credentials must be passed in the environment variable: ANEXIA_TOKEN.
69 func NewDNSProvider() (*DNSProvider, error) {
70 values, err := env.Get(EnvToken)
71 if err != nil {
72 return nil, fmt.Errorf("anexia: %w", err)
73 }
74
75 config := NewDefaultConfig()
76 config.Token = values[EnvToken]
77 config.APIURL = env.GetOrFile(EnvAPIURL)
78
79 return NewDNSProviderConfig(config)
80 }
81
82 // NewDNSProviderConfig return a DNSProvider instance configured for Anexia CloudDNS.
83 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
84 if config == nil {
85 return nil, errors.New("anexia: the configuration of the DNS provider is nil")
86 }
87
88 if config.Token == "" {
89 return nil, errors.New("anexia: incomplete credentials, missing token")
90 }
91
92 client, err := internal.NewClient(config.Token)
93 if err != nil {
94 return nil, fmt.Errorf("anexia: %w", err)
95 }
96
97 if config.APIURL != "" {
98 var err error
99
100 client.BaseURL, err = url.Parse(config.APIURL)
101 if err != nil {
102 return nil, fmt.Errorf("anexia: %w", err)
103 }
104 }
105
106 if config.HTTPClient != nil {
107 client.HTTPClient = config.HTTPClient
108 }
109
110 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
111
112 return &DNSProvider{
113 config: config,
114 client: client,
115 }, nil
116 }
117
118 // Timeout returns the timeout and interval to use when checking for DNS propagation.
119 // Adjusting here to cope with spikes in propagation times.
120 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
121 return d.config.PropagationTimeout, d.config.PollingInterval
122 }
123
124 // Present creates a TXT record to fulfill the dns-01 challenge.
125 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
126 ctx := context.Background()
127
128 info := dns01.GetChallengeInfo(domain, keyAuth)
129
130 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
131 if err != nil {
132 return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err)
133 }
134
135 recordName, err := extractRecordName(info.EffectiveFQDN, authZone)
136 if err != nil {
137 return fmt.Errorf("anexia: %w", err)
138 }
139
140 zoneName := dns01.UnFqdn(authZone)
141
142 recordReq := internal.Record{
143 Name: recordName,
144 Type: "TXT",
145 RData: info.Value,
146 TTL: d.config.TTL,
147 }
148
149 // Ignores returned zone, because of UUID unstability.
150 // https://github.com/go-acme/lego/pull/2675#issuecomment-3418678194
151 _, err = d.client.CreateRecord(ctx, zoneName, recordReq)
152 if err != nil {
153 return fmt.Errorf("anexia: new record: %w", err)
154 }
155
156 return nil
157 }
158
159 // CleanUp removes the TXT record matching the specified parameters.
160 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
161 ctx := context.Background()
162
163 info := dns01.GetChallengeInfo(domain, keyAuth)
164
165 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
166 if err != nil {
167 return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err)
168 }
169
170 recordName, err := extractRecordName(info.EffectiveFQDN, authZone)
171 if err != nil {
172 return fmt.Errorf("anexia: %w", err)
173 }
174
175 recordID, err := d.findRecordID(ctx, dns01.UnFqdn(authZone), recordName, info.Value)
176 if err != nil {
177 return fmt.Errorf("anexia: %w", err)
178 }
179
180 err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID)
181 if err != nil {
182 return fmt.Errorf("anexia: delete TXT record: %w", err)
183 }
184
185 return nil
186 }
187
188 // findRecordID attempts to find the record ID from the zone response.
189 // If the record is not immediately available in the response, it retries by querying the zone.
190 func (d *DNSProvider) findRecordID(ctx context.Context, zoneName, recordName, rdata string) (string, error) {
191 return backoff.Retry(ctx,
192 func() (string, error) {
193 currentZone, err := d.client.GetZone(ctx, zoneName)
194 if err != nil {
195 return "", backoff.Permanent(fmt.Errorf("get zone: %w", err))
196 }
197
198 recordID := findRecordIdentifier(currentZone, recordName, rdata)
199 if recordID == "" {
200 return "", fmt.Errorf("get record identifier: %w", err)
201 }
202
203 return recordID, nil
204 },
205 backoff.WithBackOff(backoff.NewConstantBackOff(5*time.Second)),
206 backoff.WithMaxElapsedTime(300*time.Second),
207 )
208 }
209
210 func findRecordIdentifier(zone *internal.Zone, recordName, rdata string) string {
211 if len(zone.Revisions) == 0 {
212 return ""
213 }
214
215 // Check the first revision (index 0) which should be the current one
216
217 for _, record := range zone.Revisions[0].Records {
218 if record.Name != recordName || record.Type != "TXT" {
219 continue
220 }
221
222 if record.RData == rdata || record.RData == strconv.Quote(rdata) {
223 return record.Identifier
224 }
225 }
226
227 return ""
228 }
229
230 func extractRecordName(fqdn, authZone string) (string, error) {
231 if dns01.UnFqdn(fqdn) == dns01.UnFqdn(authZone) {
232 // "@" for the root domain instead of an empty string.
233 return "@", nil
234 }
235
236 return dns01.ExtractSubDomain(fqdn, authZone)
237 }
238