azion.go raw
1 // Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS.
2 package azion
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "time"
10
11 "github.com/aziontech/azionapi-go-sdk/idns"
12 "github.com/go-acme/lego/v4/challenge/dns01"
13 "github.com/go-acme/lego/v4/platform/config/env"
14 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
15 )
16
17 // Environment variables names.
18 const (
19 envNamespace = "AZION_"
20
21 EnvPersonalToken = envNamespace + "PERSONAL_TOKEN"
22 EnvPageSize = envNamespace + "PAGE_SIZE"
23
24 EnvTTL = envNamespace + "TTL"
25 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
26 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
27 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
28 )
29
30 // Config is used to configure the creation of the DNSProvider.
31 type Config struct {
32 PersonalToken string
33 PageSize int
34
35 PollingInterval time.Duration
36 PropagationTimeout time.Duration
37 TTL int
38 HTTPClient *http.Client
39 }
40
41 // NewDefaultConfig returns a default configuration for the DNSProvider.
42 func NewDefaultConfig() *Config {
43 return &Config{
44 PageSize: env.GetOrDefaultInt(EnvPageSize, 50),
45 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
46 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
47 TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
48 HTTPClient: &http.Client{
49 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
50 },
51 }
52 }
53
54 // DNSProvider implements the challenge.Provider interface.
55 type DNSProvider struct {
56 config *Config
57 client *idns.APIClient
58 }
59
60 // NewDNSProvider returns a DNSProvider instance configured for Azion.
61 // Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN.
62 func NewDNSProvider() (*DNSProvider, error) {
63 values, err := env.Get(EnvPersonalToken)
64 if err != nil {
65 return nil, fmt.Errorf("azion: %w", err)
66 }
67
68 config := NewDefaultConfig()
69 config.PersonalToken = values[EnvPersonalToken]
70
71 return NewDNSProviderConfig(config)
72 }
73
74 // NewDNSProviderConfig return a DNSProvider instance configured for Azion.
75 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
76 if config == nil {
77 return nil, errors.New("azion: the configuration of the DNS provider is nil")
78 }
79
80 if config.PersonalToken == "" {
81 return nil, errors.New("azion: missing credentials")
82 }
83
84 clientConfig := idns.NewConfiguration()
85 clientConfig.AddDefaultHeader("Accept", "application/json; version=3")
86 clientConfig.UserAgent = "lego-dns/azion"
87
88 if config.HTTPClient != nil {
89 clientConfig.HTTPClient = config.HTTPClient
90 }
91
92 clientConfig.HTTPClient = clientdebug.Wrap(clientConfig.HTTPClient)
93
94 client := idns.NewAPIClient(clientConfig)
95
96 return &DNSProvider{
97 config: config,
98 client: client,
99 }, nil
100 }
101
102 // Timeout returns the timeout and interval to use when checking for DNS propagation.
103 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
104 return d.config.PropagationTimeout, d.config.PollingInterval
105 }
106
107 // Present creates a TXT record using the specified parameters.
108 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
109 info := dns01.GetChallengeInfo(domain, keyAuth)
110
111 ctxAuth := authContext(context.Background(), d.config.PersonalToken)
112
113 zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
114 if err != nil {
115 return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
116 }
117
118 subDomain, err := extractSubDomain(info, zone)
119 if err != nil {
120 return fmt.Errorf("azion: %w", err)
121 }
122
123 // Check if a TXT record with the same name already exists
124 existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
125 if err != nil {
126 return fmt.Errorf("azion: check existing records: %w", err)
127 }
128
129 record := idns.NewRecordPostOrPut()
130 record.SetEntry(subDomain)
131 record.SetRecordType("TXT")
132 record.SetTtl(int32(d.config.TTL))
133
134 var resp *idns.PostOrPutRecordResponse
135
136 if existingRecord != nil {
137 // Update existing record by adding the new value to the existing ones
138 record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value))
139
140 // Use PUT to update the existing record
141 resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
142 if err != nil {
143 return fmt.Errorf("azion: update existing record: %w", err)
144 }
145 } else {
146 // Create a new record
147 record.SetAnswersList([]string{info.Value})
148
149 resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute()
150 if err != nil {
151 return fmt.Errorf("azion: create new zone record: %w", err)
152 }
153 }
154
155 if resp == nil || resp.Results == nil {
156 return errors.New("azion: create zone record error")
157 }
158
159 return nil
160 }
161
162 // CleanUp removes the TXT record matching the specified parameters.
163 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
164 info := dns01.GetChallengeInfo(domain, keyAuth)
165
166 ctxAuth := authContext(context.Background(), d.config.PersonalToken)
167
168 zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
169 if err != nil {
170 return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
171 }
172
173 subDomain, err := extractSubDomain(info, zone)
174 if err != nil {
175 return fmt.Errorf("azion: %w", err)
176 }
177
178 existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
179 if err != nil {
180 return fmt.Errorf("azion: find existing record: %w", err)
181 }
182
183 if existingRecord == nil {
184 return nil
185 }
186
187 currentAnswers := existingRecord.GetAnswersList()
188
189 var updatedAnswers []string
190
191 for _, answer := range currentAnswers {
192 if answer != info.Value {
193 updatedAnswers = append(updatedAnswers, answer)
194 }
195 }
196
197 // If no answers remain, delete the entire record
198 if len(updatedAnswers) == 0 {
199 _, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute()
200 if errDelete != nil {
201 // If a record doesn't exist (404), consider cleanup successful
202 if resp != nil && resp.StatusCode == http.StatusNotFound {
203 return nil
204 }
205
206 return fmt.Errorf("azion: delete record: %w", errDelete)
207 }
208
209 return nil
210 }
211
212 // Update the record with remaining answers
213 record := idns.NewRecordPostOrPut()
214 record.SetEntry(subDomain)
215 record.SetRecordType("TXT")
216 record.SetAnswersList(updatedAnswers)
217 record.SetTtl(existingRecord.GetTtl())
218
219 _, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
220 if err != nil {
221 return fmt.Errorf("azion: update record: %w", err)
222 }
223
224 return nil
225 }
226
227 func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) {
228 resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute()
229 if err != nil {
230 return nil, fmt.Errorf("get zones: %w", err)
231 }
232
233 if resp == nil {
234 return nil, errors.New("get zones: no results")
235 }
236
237 for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
238 for _, zone := range resp.GetResults() {
239 if zone.GetDomain() == domain {
240 return &zone, nil
241 }
242 }
243 }
244
245 return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn)
246 }
247
248 // findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone.
249 // It handles pagination to search through all pages of results.
250 func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) {
251 var page int64 = 1
252
253 for {
254 resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute()
255 if err != nil {
256 return nil, fmt.Errorf("get zone records (page %d): %w", page, err)
257 }
258
259 if resp == nil {
260 return nil, errors.New("get zone records: no results")
261 }
262
263 results, ok := resp.GetResultsOk()
264 if !ok || results == nil {
265 return nil, errors.New("get zone records: empty")
266 }
267
268 // Search for existing TXT record with the same name in current page
269 for _, record := range results.GetRecords() {
270 if record.GetRecordType() == "TXT" && record.GetEntry() == recordName {
271 return &record, nil
272 }
273 }
274
275 // Check if there are more pages to search
276 if page >= int64(resp.GetTotalPages()) {
277 break
278 }
279
280 page++
281 }
282
283 // No existing record found in any page
284 return nil, nil
285 }
286
287 func authContext(ctx context.Context, key string) context.Context {
288 return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{
289 "tokenAuth": {
290 Key: key,
291 Prefix: "Token",
292 },
293 })
294 }
295
296 func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) {
297 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName())
298 if err != nil {
299 return "", err
300 }
301
302 if subDomain != "" {
303 return subDomain, nil
304 }
305
306 return "@", nil
307 }
308