1// Package dyn implements a DNS provider for solving the DNS-01 challenge using Dyn Managed DNS.
2package dyn
3
4import (
5	"errors"
6	"fmt"
7	"net/http"
8	"strconv"
9	"time"
10
11	"github.com/go-acme/lego/v4/challenge/dns01"
12	"github.com/go-acme/lego/v4/platform/config/env"
13)
14
15// Environment variables names.
16const (
17	envNamespace = "DYN_"
18
19	EnvCustomerName = envNamespace + "CUSTOMER_NAME"
20	EnvUserName     = envNamespace + "USER_NAME"
21	EnvPassword     = envNamespace + "PASSWORD"
22
23	EnvTTL                = envNamespace + "TTL"
24	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
25	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
26	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
27)
28
29// Config is used to configure the creation of the DNSProvider.
30type Config struct {
31	CustomerName       string
32	UserName           string
33	Password           string
34	HTTPClient         *http.Client
35	PropagationTimeout time.Duration
36	PollingInterval    time.Duration
37	TTL                int
38}
39
40// NewDefaultConfig returns a default configuration for the DNSProvider.
41func NewDefaultConfig() *Config {
42	return &Config{
43		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
44		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
45		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
46		HTTPClient: &http.Client{
47			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
48		},
49	}
50}
51
52// DNSProvider implements the challenge.Provider interface.
53type DNSProvider struct {
54	config *Config
55	token  string
56}
57
58// NewDNSProvider returns a DNSProvider instance configured for Dyn DNS.
59// Credentials must be passed in the environment variables:
60// DYN_CUSTOMER_NAME, DYN_USER_NAME and DYN_PASSWORD.
61func NewDNSProvider() (*DNSProvider, error) {
62	values, err := env.Get(EnvCustomerName, EnvUserName, EnvPassword)
63	if err != nil {
64		return nil, fmt.Errorf("dyn: %w", err)
65	}
66
67	config := NewDefaultConfig()
68	config.CustomerName = values[EnvCustomerName]
69	config.UserName = values[EnvUserName]
70	config.Password = values[EnvPassword]
71
72	return NewDNSProviderConfig(config)
73}
74
75// NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS.
76func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
77	if config == nil {
78		return nil, errors.New("dyn: the configuration of the DNS provider is nil")
79	}
80
81	if config.CustomerName == "" || config.UserName == "" || config.Password == "" {
82		return nil, errors.New("dyn: credentials missing")
83	}
84
85	return &DNSProvider{config: config}, nil
86}
87
88// Present creates a TXT record using the specified parameters.
89func (d *DNSProvider) Present(domain, token, keyAuth string) error {
90	fqdn, value := dns01.GetRecord(domain, keyAuth)
91
92	authZone, err := dns01.FindZoneByFqdn(fqdn)
93	if err != nil {
94		return fmt.Errorf("dyn: %w", err)
95	}
96
97	err = d.login()
98	if err != nil {
99		return fmt.Errorf("dyn: %w", err)
100	}
101
102	data := map[string]interface{}{
103		"rdata": map[string]string{
104			"txtdata": value,
105		},
106		"ttl": strconv.Itoa(d.config.TTL),
107	}
108
109	resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn)
110	_, err = d.sendRequest(http.MethodPost, resource, data)
111	if err != nil {
112		return fmt.Errorf("dyn: %w", err)
113	}
114
115	err = d.publish(authZone, "Added TXT record for ACME dns-01 challenge using lego client")
116	if err != nil {
117		return fmt.Errorf("dyn: %w", err)
118	}
119
120	return d.logout()
121}
122
123// CleanUp removes the TXT record matching the specified parameters.
124func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
125	fqdn, _ := dns01.GetRecord(domain, keyAuth)
126
127	authZone, err := dns01.FindZoneByFqdn(fqdn)
128	if err != nil {
129		return fmt.Errorf("dyn: %w", err)
130	}
131
132	err = d.login()
133	if err != nil {
134		return fmt.Errorf("dyn: %w", err)
135	}
136
137	resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn)
138	url := fmt.Sprintf("%s/%s", defaultBaseURL, resource)
139
140	req, err := http.NewRequest(http.MethodDelete, url, nil)
141	if err != nil {
142		return fmt.Errorf("dyn: %w", err)
143	}
144
145	req.Header.Set("Content-Type", "application/json")
146	req.Header.Set("Auth-Token", d.token)
147
148	resp, err := d.config.HTTPClient.Do(req)
149	if err != nil {
150		return fmt.Errorf("dyn: %w", err)
151	}
152	resp.Body.Close()
153
154	if resp.StatusCode != http.StatusOK {
155		return fmt.Errorf("dyn: API request failed to delete TXT record HTTP status code %d", resp.StatusCode)
156	}
157
158	err = d.publish(authZone, "Removed TXT record for ACME dns-01 challenge using lego client")
159	if err != nil {
160		return fmt.Errorf("dyn: %w", err)
161	}
162
163	return d.logout()
164}
165
166// Timeout returns the timeout and interval to use when checking for DNS propagation.
167// Adjusting here to cope with spikes in propagation times.
168func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
169	return d.config.PropagationTimeout, d.config.PollingInterval
170}
171