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