designate.go raw
1 // Package designate implements a DNS provider for solving the DNS-01 challenge using the Designate DNSaaS for Openstack.
2 package designate
3
4 import (
5 "errors"
6 "fmt"
7 "log"
8 "os"
9 "slices"
10 "sync"
11 "time"
12
13 "github.com/go-acme/lego/v4/challenge"
14 "github.com/go-acme/lego/v4/challenge/dns01"
15 "github.com/go-acme/lego/v4/platform/config/env"
16 "github.com/gophercloud/gophercloud"
17 "github.com/gophercloud/gophercloud/openstack"
18 "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets"
19 "github.com/gophercloud/gophercloud/openstack/dns/v2/zones"
20 "github.com/gophercloud/utils/openstack/clientconfig"
21 )
22
23 // Environment variables names.
24 const (
25 envNamespace = "DESIGNATE_"
26
27 EnvTTL = envNamespace + "TTL"
28 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
29 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
30
31 EnvZoneName = envNamespace + "ZONE_NAME"
32
33 envNamespaceClient = "OS_"
34
35 EnvAuthURL = envNamespaceClient + "AUTH_URL"
36 EnvUsername = envNamespaceClient + "USERNAME"
37 EnvPassword = envNamespaceClient + "PASSWORD"
38 EnvUserID = envNamespaceClient + "USER_ID"
39 EnvAppCredID = envNamespaceClient + "APPLICATION_CREDENTIAL_ID"
40 EnvAppCredName = envNamespaceClient + "APPLICATION_CREDENTIAL_NAME"
41 EnvAppCredSecret = envNamespaceClient + "APPLICATION_CREDENTIAL_SECRET"
42 EnvTenantName = envNamespaceClient + "TENANT_NAME"
43 EnvRegionName = envNamespaceClient + "REGION_NAME"
44 EnvProjectID = envNamespaceClient + "PROJECT_ID"
45 EnvCloud = envNamespaceClient + "CLOUD"
46 )
47
48 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
49
50 // Config is used to configure the creation of the DNSProvider.
51 type Config struct {
52 ZoneName string
53 PropagationTimeout time.Duration
54 PollingInterval time.Duration
55 TTL int
56 opts gophercloud.AuthOptions
57 }
58
59 // NewDefaultConfig returns a default configuration for the DNSProvider.
60 func NewDefaultConfig() *Config {
61 return &Config{
62 ZoneName: env.GetOrFile(EnvZoneName),
63 TTL: env.GetOrDefaultInt(EnvTTL, 10),
64 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute),
65 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
66 }
67 }
68
69 // DNSProvider implements the challenge.Provider interface.
70 type DNSProvider struct {
71 config *Config
72 client *gophercloud.ServiceClient
73
74 dnsEntriesMu sync.Mutex
75 }
76
77 // NewDNSProvider returns a DNSProvider instance configured for Designate.
78 // Credentials must be passed in the environment variables:
79 // OS_AUTH_URL, OS_USERNAME, OS_PASSWORD, OS_REGION_NAME.
80 // Or you can specify OS_CLOUD to read the credentials from the according cloud entry.
81 func NewDNSProvider() (*DNSProvider, error) {
82 config := NewDefaultConfig()
83
84 val, err := env.Get(EnvCloud)
85 if err == nil {
86 opts, erro := clientconfig.AuthOptions(&clientconfig.ClientOpts{
87 Cloud: val[EnvCloud],
88 })
89 if erro != nil {
90 return nil, fmt.Errorf("designate: %w", erro)
91 }
92
93 config.opts = *opts
94 } else {
95 opts, err := openstack.AuthOptionsFromEnv()
96 if err != nil {
97 return nil, fmt.Errorf("designate: %w", err)
98 }
99
100 config.opts = opts
101 }
102
103 return NewDNSProviderConfig(config)
104 }
105
106 // NewDNSProviderConfig return a DNSProvider instance configured for Designate.
107 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
108 if config == nil {
109 return nil, errors.New("designate: the configuration of the DNS provider is nil")
110 }
111
112 provider, err := openstack.AuthenticatedClient(config.opts)
113 if err != nil {
114 return nil, fmt.Errorf("designate: failed to authenticate: %w", err)
115 }
116
117 dnsClient, err := openstack.NewDNSV2(provider, gophercloud.EndpointOpts{
118 Region: os.Getenv("OS_REGION_NAME"),
119 })
120 if err != nil {
121 return nil, fmt.Errorf("designate: failed to get DNS provider: %w", err)
122 }
123
124 return &DNSProvider{client: dnsClient, config: config}, nil
125 }
126
127 // Timeout returns the timeout and interval to use when checking for DNS propagation.
128 // Adjusting here to cope with spikes in propagation times.
129 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
130 return d.config.PropagationTimeout, d.config.PollingInterval
131 }
132
133 // Present creates a TXT record to fulfill the dns-01 challenge.
134 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
135 info := dns01.GetChallengeInfo(domain, keyAuth)
136
137 zone, err := d.getZoneName(info.EffectiveFQDN)
138 if err != nil {
139 return fmt.Errorf("designate: %w", err)
140 }
141
142 zoneID, err := d.getZoneID(zone)
143 if err != nil {
144 return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err)
145 }
146
147 // use mutex to prevent race condition between creating the record and verifying it
148 d.dnsEntriesMu.Lock()
149 defer d.dnsEntriesMu.Unlock()
150
151 existingRecord, err := d.getRecord(zoneID, info.EffectiveFQDN)
152 if err != nil {
153 return fmt.Errorf("designate: %w", err)
154 }
155
156 if existingRecord != nil {
157 if slices.Contains(existingRecord.Records, info.Value) {
158 log.Printf("designate: the record already exists: %s", info.Value)
159 return nil
160 }
161
162 return d.updateRecord(existingRecord, info.Value)
163 }
164
165 err = d.createRecord(zoneID, info.EffectiveFQDN, info.Value)
166 if err != nil {
167 return fmt.Errorf("designate: %w", err)
168 }
169
170 return nil
171 }
172
173 // CleanUp removes the TXT record matching the specified parameters.
174 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
175 info := dns01.GetChallengeInfo(domain, keyAuth)
176
177 zone, err := d.getZoneName(info.EffectiveFQDN)
178 if err != nil {
179 return fmt.Errorf("designate: %w", err)
180 }
181
182 zoneID, err := d.getZoneID(zone)
183 if err != nil {
184 return fmt.Errorf("designate: couldn't get zone ID in CleanUp: %w", err)
185 }
186
187 // use mutex to prevent race condition between getting the record and deleting it
188 d.dnsEntriesMu.Lock()
189 defer d.dnsEntriesMu.Unlock()
190
191 record, err := d.getRecord(zoneID, info.EffectiveFQDN)
192 if err != nil {
193 return fmt.Errorf("designate: couldn't get Record ID in CleanUp: %w", err)
194 }
195
196 if record == nil {
197 // Record is already deleted
198 return nil
199 }
200
201 err = recordsets.Delete(d.client, zoneID, record.ID).ExtractErr()
202 if err != nil {
203 return fmt.Errorf("designate: error for %s in CleanUp: %w", info.EffectiveFQDN, err)
204 }
205
206 return nil
207 }
208
209 func (d *DNSProvider) createRecord(zoneID, fqdn, value string) error {
210 createOpts := recordsets.CreateOpts{
211 Name: fqdn,
212 Type: "TXT",
213 TTL: d.config.TTL,
214 Description: "ACME verification record",
215 Records: []string{value},
216 }
217
218 actual, err := recordsets.Create(d.client, zoneID, createOpts).Extract()
219 if err != nil {
220 return fmt.Errorf("error for %s in Present while creating record: %w", fqdn, err)
221 }
222
223 if actual.Name != fqdn || actual.TTL != d.config.TTL {
224 return errors.New("the created record doesn't match what we wanted to create")
225 }
226
227 return nil
228 }
229
230 func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) error {
231 if slices.Contains(record.Records, value) {
232 log.Printf("skip: the record already exists: %s", value)
233 return nil
234 }
235
236 values := append([]string{value}, record.Records...)
237
238 updateOpts := recordsets.UpdateOpts{
239 Description: &record.Description,
240 TTL: &record.TTL,
241 Records: values,
242 }
243
244 result := recordsets.Update(d.client, record.ZoneID, record.ID, updateOpts)
245
246 return result.Err
247 }
248
249 func (d *DNSProvider) getZoneID(wanted string) (string, error) {
250 listOpts := zones.ListOpts{
251 Name: wanted,
252 }
253
254 allPages, err := zones.List(d.client, listOpts).AllPages()
255 if err != nil {
256 return "", err
257 }
258
259 allZones, err := zones.ExtractZones(allPages)
260 if err != nil {
261 return "", err
262 }
263
264 for _, zone := range allZones {
265 if zone.Name == wanted {
266 return zone.ID, nil
267 }
268 }
269
270 return "", fmt.Errorf("zone id not found for %s", wanted)
271 }
272
273 func (d *DNSProvider) getRecord(zoneID, wanted string) (*recordsets.RecordSet, error) {
274 listOpts := recordsets.ListOpts{
275 Name: wanted,
276 Type: "TXT",
277 }
278
279 allPages, err := recordsets.ListByZone(d.client, zoneID, listOpts).AllPages()
280 if err != nil {
281 return nil, err
282 }
283
284 allRecords, err := recordsets.ExtractRecordSets(allPages)
285 if err != nil {
286 return nil, err
287 }
288
289 for _, record := range allRecords {
290 if record.Name == wanted && record.Type == "TXT" {
291 return &record, nil
292 }
293 }
294
295 return nil, nil
296 }
297
298 func (d *DNSProvider) getZoneName(fqdn string) (string, error) {
299 if d.config.ZoneName != "" {
300 return d.config.ZoneName, nil
301 }
302
303 authZone, err := dns01.FindZoneByFqdn(fqdn)
304 if err != nil {
305 return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err)
306 }
307
308 if authZone == "" {
309 return "", errors.New("empty zone name")
310 }
311
312 return authZone, nil
313 }
314