exoscale.go raw
1 // Package exoscale implements a DNS provider for solving the DNS-01 challenge using Exoscale DNS.
2 package exoscale
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "strconv"
10 "time"
11
12 egoscale "github.com/exoscale/egoscale/v3"
13 "github.com/exoscale/egoscale/v3/credentials"
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/internal/clientdebug"
18 "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
19 )
20
21 // Environment variables names.
22 const (
23 envNamespace = "EXOSCALE_"
24
25 EnvAPISecret = envNamespace + "API_SECRET"
26 EnvAPIKey = envNamespace + "API_KEY"
27 EnvEndpoint = envNamespace + "ENDPOINT"
28
29 EnvTTL = envNamespace + "TTL"
30 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
31 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
32 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
33 )
34
35 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
36
37 // Config is used to configure the creation of the DNSProvider.
38 type Config struct {
39 APIKey string
40 APISecret string
41 Endpoint string
42 HTTPTimeout time.Duration
43 PropagationTimeout time.Duration
44 PollingInterval time.Duration
45 TTL int64
46 }
47
48 // NewDefaultConfig returns a default configuration for the DNSProvider.
49 func NewDefaultConfig() *Config {
50 return &Config{
51 TTL: int64(env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL)),
52 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
53 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
54 HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
55 }
56 }
57
58 // DNSProvider implements the challenge.Provider interface.
59 type DNSProvider struct {
60 config *Config
61 client *egoscale.Client
62 }
63
64 // NewDNSProvider Credentials must be passed in the environment variables:
65 // EXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT.
66 func NewDNSProvider() (*DNSProvider, error) {
67 values, err := env.Get(EnvAPIKey, EnvAPISecret)
68 if err != nil {
69 return nil, fmt.Errorf("exoscale: %w", err)
70 }
71
72 config := NewDefaultConfig()
73 config.APIKey = values[EnvAPIKey]
74 config.APISecret = values[EnvAPISecret]
75 config.Endpoint = env.GetOrDefaultString(EnvEndpoint, string(egoscale.CHGva2))
76
77 return NewDNSProviderConfig(config)
78 }
79
80 // NewDNSProviderConfig return a DNSProvider instance configured for Exoscale.
81 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
82 if config == nil {
83 return nil, errors.New("exoscale: the configuration of the DNS provider is nil")
84 }
85
86 if config.APIKey == "" || config.APISecret == "" {
87 return nil, errors.New("exoscale: credentials missing")
88 }
89
90 client, err := egoscale.NewClient(
91 credentials.NewStaticCredentials(config.APIKey, config.APISecret),
92 egoscale.ClientOptWithEndpoint(egoscale.Endpoint(config.Endpoint)),
93 egoscale.ClientOptWithHTTPClient(clientdebug.Wrap(&http.Client{Timeout: config.HTTPTimeout})),
94 egoscale.ClientOptWithUserAgent(useragent.Get()),
95 )
96 if err != nil {
97 return nil, fmt.Errorf("exoscale: initializing client: %w", err)
98 }
99
100 return &DNSProvider{
101 client: client,
102 config: config,
103 }, nil
104 }
105
106 // Present creates a TXT record to fulfill the dns-01 challenge.
107 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
108 ctx := context.Background()
109
110 info := dns01.GetChallengeInfo(domain, keyAuth)
111
112 zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
113 if err != nil {
114 return fmt.Errorf("exoscale: %w", err)
115 }
116
117 zone, err := d.findExistingZone(ctx, zoneName)
118 if err != nil {
119 return fmt.Errorf("exoscale: %w", err)
120 }
121
122 if zone == nil {
123 return fmt.Errorf("exoscale: zone %q not found", zoneName)
124 }
125
126 recordRequest := egoscale.CreateDNSDomainRecordRequest{
127 Name: recordName,
128 Ttl: d.config.TTL,
129 Content: info.Value,
130 Type: egoscale.CreateDNSDomainRecordRequestTypeTXT,
131 }
132
133 op, err := d.client.CreateDNSDomainRecord(ctx, zone.ID, recordRequest)
134 if err != nil {
135 return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
136 }
137
138 _, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess)
139 if err != nil {
140 return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
141 }
142
143 return nil
144 }
145
146 // CleanUp removes the record matching the specified parameters.
147 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
148 ctx := context.Background()
149
150 info := dns01.GetChallengeInfo(domain, keyAuth)
151
152 zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
153 if err != nil {
154 return fmt.Errorf("exoscale: %w", err)
155 }
156
157 zone, err := d.findExistingZone(ctx, zoneName)
158 if err != nil {
159 return fmt.Errorf("exoscale: %w", err)
160 }
161
162 if zone == nil {
163 return fmt.Errorf("exoscale: zone %q not found", zoneName)
164 }
165
166 recordID, err := d.findExistingRecordID(ctx, zone.ID, recordName, info.Value)
167 if err != nil {
168 return err
169 }
170
171 if recordID == "" {
172 return nil
173 }
174
175 op, err := d.client.DeleteDNSDomainRecord(ctx, zone.ID, recordID)
176 if err != nil {
177 return fmt.Errorf("exoscale: error while deleting DNS record: %w", err)
178 }
179
180 _, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess)
181 if err != nil {
182 return fmt.Errorf("exoscale: error while creating DNS record: %w", err)
183 }
184
185 return nil
186 }
187
188 // Timeout returns the timeout and interval to use when checking for DNS propagation.
189 // Adjusting here to cope with spikes in propagation times.
190 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
191 return d.config.PropagationTimeout, d.config.PollingInterval
192 }
193
194 // findExistingZone Query Exoscale to find an existing zone for this name.
195 // Returns nil result if no zone could be found.
196 func (d *DNSProvider) findExistingZone(ctx context.Context, zoneName string) (*egoscale.DNSDomain, error) {
197 zones, err := d.client.ListDNSDomains(ctx)
198 if err != nil {
199 return nil, fmt.Errorf("error while retrieving DNS zones: %w", err)
200 }
201
202 for _, zone := range zones.DNSDomains {
203 if zone.UnicodeName == zoneName {
204 return &zone, nil
205 }
206 }
207
208 return nil, nil
209 }
210
211 // findExistingRecordID Query Exoscale to find an existing record for this name.
212 // Returns empty result if no record could be found.
213 func (d *DNSProvider) findExistingRecordID(ctx context.Context, zoneID egoscale.UUID, recordName, value string) (egoscale.UUID, error) {
214 records, err := d.client.ListDNSDomainRecords(ctx, zoneID)
215 if err != nil {
216 return "", fmt.Errorf("error while retrieving DNS records: %w", err)
217 }
218
219 for _, record := range records.DNSDomainRecords {
220 if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT &&
221 (record.Content == value || record.Content == strconv.Quote(value)) {
222 return record.ID, nil
223 }
224 }
225
226 return "", nil
227 }
228
229 // findZoneAndRecordName Extract DNS zone and DNS entry name.
230 func (d *DNSProvider) findZoneAndRecordName(fqdn string) (string, string, error) {
231 zone, err := dns01.FindZoneByFqdn(fqdn)
232 if err != nil {
233 return "", "", fmt.Errorf("could not find zone: %w", err)
234 }
235
236 zone = dns01.UnFqdn(zone)
237
238 subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
239 if err != nil {
240 return "", "", err
241 }
242
243 return zone, subDomain, nil
244 }
245