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/v3/challenge/dns01"
11	"github.com/go-acme/lego/v3/platform/config/env"
12	"github.com/go-acme/lego/v3/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, 300),
42		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
43		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
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: %w", 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 get 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		record := records[0]
126
127		if containsValue(record, value) {
128			return nil
129		}
130
131		request := internal.RecordRequest{
132			Name:       record.Name,
133			TTL:        record.TTL,
134			RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`"%s"`, value)}),
135		}
136
137		_, err = d.client.TxtRecords.Update(dom.ID, record.ID, request)
138		if err != nil {
139			return fmt.Errorf("constellix: failed to update TXT records: %w", err)
140		}
141		return nil
142	}
143
144	request := internal.RecordRequest{
145		Name: recordName,
146		TTL:  d.config.TTL,
147		RoundRobin: []internal.RecordValue{
148			{Value: fmt.Sprintf(`"%s"`, value)},
149		},
150	}
151
152	_, err = d.client.TxtRecords.Create(dom.ID, request)
153	if err != nil {
154		return fmt.Errorf("constellix: failed to create TXT record %s: %w", fqdn, err)
155	}
156
157	return nil
158}
159
160// CleanUp removes the TXT record matching the specified parameters.
161func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
162	fqdn, value := dns01.GetRecord(domain, keyAuth)
163
164	authZone, err := dns01.FindZoneByFqdn(fqdn)
165	if err != nil {
166		return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err)
167	}
168
169	dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone))
170	if err != nil {
171		return fmt.Errorf("constellix: failed to get domain: %w", err)
172	}
173
174	recordName := getRecordName(fqdn, authZone)
175
176	records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName)
177	if err != nil {
178		return fmt.Errorf("constellix: failed to get TXT records: %w", err)
179	}
180
181	if len(records) > 1 {
182		return errors.New("constellix: failed to get TXT records")
183	}
184
185	if len(records) == 0 {
186		return nil
187	}
188
189	record := records[0]
190
191	if !containsValue(record, value) {
192		return nil
193	}
194
195	// only 1 record value, the whole record must be deleted.
196	if len(record.Value) == 1 {
197		_, err = d.client.TxtRecords.Delete(dom.ID, record.ID)
198		if err != nil {
199			return fmt.Errorf("constellix: failed to delete TXT records: %w", err)
200		}
201		return nil
202	}
203
204	request := internal.RecordRequest{
205		Name: record.Name,
206		TTL:  record.TTL,
207	}
208
209	for _, val := range record.Value {
210		if val.Value != fmt.Sprintf(`"%s"`, value) {
211			request.RoundRobin = append(request.RoundRobin, val)
212		}
213	}
214
215	_, err = d.client.TxtRecords.Update(dom.ID, record.ID, request)
216	if err != nil {
217		return fmt.Errorf("constellix: failed to update TXT records: %w", err)
218	}
219
220	return nil
221}
222
223func containsValue(record internal.Record, value string) bool {
224	for _, val := range record.Value {
225		if val.Value == fmt.Sprintf(`"%s"`, value) {
226			return true
227		}
228	}
229
230	return false
231}
232
233func getRecordName(fqdn, authZone string) string {
234	return fqdn[0 : len(fqdn)-len(authZone)-1]
235}
236