1// Package constellix implements a DNS provider for solving the DNS-01 challenge using Constellix DNS. 2package constellix 3 4import ( 5 "errors" 6 "fmt" 7 "net/http" 8 "time" 9 10 "github.com/go-acme/lego/v4/challenge/dns01" 11 "github.com/go-acme/lego/v4/platform/config/env" 12 "github.com/go-acme/lego/v4/providers/dns/constellix/internal" 13) 14 15// Environment variables names. 16const ( 17 envNamespace = "CONSTELLIX_" 18 19 EnvAPIKey = envNamespace + "API_KEY" 20 EnvSecretKey = envNamespace + "SECRET_KEY" 21 22 EnvTTL = envNamespace + "TTL" 23 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" 24 EnvPollingInterval = envNamespace + "POLLING_INTERVAL" 25 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" 26) 27 28// Config is used to configure the creation of the DNSProvider. 29type Config struct { 30 APIKey string 31 SecretKey string 32 PropagationTimeout time.Duration 33 PollingInterval time.Duration 34 TTL int 35 HTTPClient *http.Client 36} 37 38// NewDefaultConfig returns a default configuration for the DNSProvider. 39func NewDefaultConfig() *Config { 40 return &Config{ 41 TTL: env.GetOrDefaultInt(EnvTTL, 60), 42 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), 43 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), 44 HTTPClient: &http.Client{ 45 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), 46 }, 47 } 48} 49 50// DNSProvider implements the challenge.Provider interface. 51type DNSProvider struct { 52 config *Config 53 client *internal.Client 54} 55 56// NewDNSProvider returns a DNSProvider instance configured for Constellix. 57// Credentials must be passed in the environment variables: 58// CONSTELLIX_API_KEY and CONSTELLIX_SECRET_KEY. 59func NewDNSProvider() (*DNSProvider, error) { 60 values, err := env.Get(EnvAPIKey, EnvSecretKey) 61 if err != nil { 62 return nil, fmt.Errorf("constellix: %w", err) 63 } 64 65 config := NewDefaultConfig() 66 config.APIKey = values[EnvAPIKey] 67 config.SecretKey = values[EnvSecretKey] 68 69 return NewDNSProviderConfig(config) 70} 71 72// NewDNSProviderConfig return a DNSProvider instance configured for Constellix. 73func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { 74 if config == nil { 75 return nil, errors.New("constellix: the configuration of the DNS provider is nil") 76 } 77 78 if config.SecretKey == "" || config.APIKey == "" { 79 return nil, errors.New("constellix: incomplete credentials, missing secret key and/or API key") 80 } 81 82 tr, err := internal.NewTokenTransport(config.APIKey, config.SecretKey) 83 if err != nil { 84 return nil, fmt.Errorf("constellix: %w", err) 85 } 86 87 client := internal.NewClient(tr.Wrap(config.HTTPClient)) 88 89 return &DNSProvider{config: config, client: client}, nil 90} 91 92// Timeout returns the timeout and interval to use when checking for DNS propagation. 93// Adjusting here to cope with spikes in propagation times. 94func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 95 return d.config.PropagationTimeout, d.config.PollingInterval 96} 97 98// Present creates a TXT record using the specified parameters. 99func (d *DNSProvider) Present(domain, token, keyAuth string) error { 100 fqdn, value := dns01.GetRecord(domain, keyAuth) 101 102 authZone, err := dns01.FindZoneByFqdn(fqdn) 103 if err != nil { 104 return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) 105 } 106 107 dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) 108 if err != nil { 109 return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) 110 } 111 112 recordName := getRecordName(fqdn, authZone) 113 114 records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) 115 if err != nil { 116 return fmt.Errorf("constellix: failed to search TXT records: %w", err) 117 } 118 119 if len(records) > 1 { 120 return errors.New("constellix: failed to get TXT records") 121 } 122 123 // TXT record entry already existing 124 if len(records) == 1 { 125 return d.appendRecordValue(dom, records[0].ID, value) 126 } 127 128 err = d.createRecord(dom, fqdn, recordName, value) 129 if err != nil { 130 return fmt.Errorf("constellix: %w", err) 131 } 132 133 return nil 134} 135 136// CleanUp removes the TXT record matching the specified parameters. 137func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { 138 fqdn, value := dns01.GetRecord(domain, keyAuth) 139 140 authZone, err := dns01.FindZoneByFqdn(fqdn) 141 if err != nil { 142 return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) 143 } 144 145 dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) 146 if err != nil { 147 return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) 148 } 149 150 recordName := getRecordName(fqdn, authZone) 151 152 records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) 153 if err != nil { 154 return fmt.Errorf("constellix: failed to search TXT records: %w", err) 155 } 156 157 if len(records) > 1 { 158 return errors.New("constellix: failed to get TXT records") 159 } 160 161 if len(records) == 0 { 162 return nil 163 } 164 165 record, err := d.client.TxtRecords.Get(dom.ID, records[0].ID) 166 if err != nil { 167 return fmt.Errorf("constellix: failed to get TXT records: %w", err) 168 } 169 170 if !containsValue(record, value) { 171 return nil 172 } 173 174 // only 1 record value, the whole record must be deleted. 175 if len(record.Value) == 1 { 176 _, err = d.client.TxtRecords.Delete(dom.ID, record.ID) 177 if err != nil { 178 return fmt.Errorf("constellix: failed to delete TXT records: %w", err) 179 } 180 return nil 181 } 182 183 err = d.removeRecordValue(dom, record, value) 184 if err != nil { 185 return fmt.Errorf("constellix: %w", err) 186 } 187 188 return nil 189} 190 191func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value string) error { 192 request := internal.RecordRequest{ 193 Name: recordName, 194 TTL: d.config.TTL, 195 RoundRobin: []internal.RecordValue{ 196 {Value: fmt.Sprintf(`%q`, value)}, 197 }, 198 } 199 200 _, err := d.client.TxtRecords.Create(dom.ID, request) 201 if err != nil { 202 return fmt.Errorf("failed to create TXT record %s: %w", fqdn, err) 203 } 204 205 return nil 206} 207 208func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, value string) error { 209 record, err := d.client.TxtRecords.Get(dom.ID, recordID) 210 if err != nil { 211 return fmt.Errorf("failed to get TXT records: %w", err) 212 } 213 214 if containsValue(record, value) { 215 return nil 216 } 217 218 request := internal.RecordRequest{ 219 Name: record.Name, 220 TTL: record.TTL, 221 RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`%q`, value)}), 222 } 223 224 _, err = d.client.TxtRecords.Update(dom.ID, record.ID, request) 225 if err != nil { 226 return fmt.Errorf("failed to update TXT records: %w", err) 227 } 228 229 return nil 230} 231 232func (d *DNSProvider) removeRecordValue(dom internal.Domain, record *internal.Record, value string) error { 233 request := internal.RecordRequest{ 234 Name: record.Name, 235 TTL: record.TTL, 236 } 237 238 for _, val := range record.Value { 239 if val.Value != fmt.Sprintf(`%q`, value) { 240 request.RoundRobin = append(request.RoundRobin, val) 241 } 242 } 243 244 _, err := d.client.TxtRecords.Update(dom.ID, record.ID, request) 245 if err != nil { 246 return fmt.Errorf("failed to update TXT records: %w", err) 247 } 248 249 return nil 250} 251 252func containsValue(record *internal.Record, value string) bool { 253 if record == nil { 254 return false 255 } 256 257 for _, val := range record.Value { 258 if val.Value == fmt.Sprintf(`%q`, value) { 259 return true 260 } 261 } 262 263 return false 264} 265 266func getRecordName(fqdn, authZone string) string { 267 return fqdn[0 : len(fqdn)-len(authZone)-1] 268} 269