vultr.go raw
1 // Package vultr implements a DNS provider for solving the DNS-01 challenge using the Vultr DNS.
2 // See https://www.vultr.com/api/#dns
3 package vultr
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "net/http"
10 "strings"
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/go-acme/lego/v4/providers/dns/internal/clientdebug"
17 "github.com/vultr/govultr/v3"
18 "golang.org/x/oauth2"
19 )
20
21 // Environment variables names.
22 const (
23 envNamespace = "VULTR_"
24
25 EnvAPIKey = envNamespace + "API_KEY"
26
27 EnvTTL = envNamespace + "TTL"
28 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
29 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
30 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
31 )
32
33 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
34
35 // Config is used to configure the creation of the DNSProvider.
36 type Config struct {
37 APIKey string
38 PropagationTimeout time.Duration
39 PollingInterval time.Duration
40 TTL int
41 HTTPClient *http.Client
42 HTTPTimeout time.Duration // TODO(ldez): remove in v5
43 }
44
45 // NewDefaultConfig returns a default configuration for the DNSProvider.
46 func NewDefaultConfig() *Config {
47 return &Config{
48 TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
49 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
50 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
51 HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
52 }
53 }
54
55 // DNSProvider implements the challenge.Provider interface.
56 type DNSProvider struct {
57 config *Config
58 client *govultr.Client
59 }
60
61 // NewDNSProvider returns a DNSProvider instance with a configured Vultr client.
62 // Authentication uses the VULTR_API_KEY environment variable.
63 func NewDNSProvider() (*DNSProvider, error) {
64 values, err := env.Get(EnvAPIKey)
65 if err != nil {
66 return nil, fmt.Errorf("vultr: %w", err)
67 }
68
69 config := NewDefaultConfig()
70 config.APIKey = values[EnvAPIKey]
71
72 return NewDNSProviderConfig(config)
73 }
74
75 // NewDNSProviderConfig return a DNSProvider instance configured for Vultr.
76 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
77 if config == nil {
78 return nil, errors.New("vultr: the configuration of the DNS provider is nil")
79 }
80
81 if config.APIKey == "" {
82 return nil, errors.New("vultr: credentials missing")
83 }
84
85 authClient := OAuthStaticAccessToken(config.HTTPClient, config.APIKey)
86 authClient.Timeout = config.HTTPTimeout
87
88 client := govultr.NewClient(clientdebug.Wrap(authClient))
89
90 return &DNSProvider{client: client, config: config}, nil
91 }
92
93 // Present creates a TXT record to fulfill the DNS-01 challenge.
94 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
95 ctx := context.Background()
96
97 info := dns01.GetChallengeInfo(domain, keyAuth)
98
99 // TODO(ldez) replace domain by FQDN to follow CNAME.
100 zoneDomain, err := d.getHostedZone(ctx, domain)
101 if err != nil {
102 return fmt.Errorf("vultr: %w", err)
103 }
104
105 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zoneDomain)
106 if err != nil {
107 return fmt.Errorf("vultr: %w", err)
108 }
109
110 req := govultr.DomainRecordCreateReq{
111 Name: subDomain,
112 Type: "TXT",
113 Data: `"` + info.Value + `"`,
114 TTL: d.config.TTL,
115 Priority: func(v int) *int { return &v }(0),
116 }
117
118 _, resp, err := d.client.DomainRecord.Create(ctx, zoneDomain, &req)
119 if err != nil {
120 return fmt.Errorf("vultr: %w", extendError(resp, err))
121 }
122
123 return nil
124 }
125
126 // CleanUp removes the TXT record matching the specified parameters.
127 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
128 ctx := context.Background()
129
130 info := dns01.GetChallengeInfo(domain, keyAuth)
131
132 // TODO(ldez) replace domain by FQDN to follow CNAME.
133 zoneDomain, records, err := d.findTxtRecords(ctx, domain, info.EffectiveFQDN)
134 if err != nil {
135 return fmt.Errorf("vultr: %w", err)
136 }
137
138 var allErr []string
139
140 for _, rec := range records {
141 err := d.client.DomainRecord.Delete(ctx, zoneDomain, rec.ID)
142 if err != nil {
143 allErr = append(allErr, err.Error())
144 }
145 }
146
147 if len(allErr) > 0 {
148 return errors.New(strings.Join(allErr, ": "))
149 }
150
151 return nil
152 }
153
154 // Timeout returns the timeout and interval to use when checking for DNS propagation.
155 // Adjusting here to cope with spikes in propagation times.
156 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
157 return d.config.PropagationTimeout, d.config.PollingInterval
158 }
159
160 func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) {
161 listOptions := &govultr.ListOptions{PerPage: 25}
162
163 var hostedDomain govultr.Domain
164
165 for {
166 domains, meta, resp, err := d.client.Domain.List(ctx, listOptions)
167 if err != nil {
168 return "", extendError(resp, err)
169 }
170
171 for _, dom := range domains {
172 if strings.HasSuffix(domain, dom.Domain) && len(dom.Domain) > len(hostedDomain.Domain) {
173 hostedDomain = dom
174 }
175 }
176
177 if domain == hostedDomain.Domain {
178 break
179 }
180
181 if meta.Links.Next == "" {
182 break
183 }
184
185 listOptions.Cursor = meta.Links.Next
186 }
187
188 if hostedDomain.Domain == "" {
189 return "", fmt.Errorf("no matching domain found for domain %s", domain)
190 }
191
192 return hostedDomain.Domain, nil
193 }
194
195 func (d *DNSProvider) findTxtRecords(ctx context.Context, domain, fqdn string) (string, []govultr.DomainRecord, error) {
196 zoneDomain, err := d.getHostedZone(ctx, domain)
197 if err != nil {
198 return "", nil, err
199 }
200
201 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneDomain)
202 if err != nil {
203 return "", nil, err
204 }
205
206 listOptions := &govultr.ListOptions{PerPage: 25}
207
208 var records []govultr.DomainRecord
209
210 for {
211 result, meta, resp, err := d.client.DomainRecord.List(ctx, zoneDomain, listOptions)
212 if err != nil {
213 return "", records, extendError(resp, err)
214 }
215
216 for _, record := range result {
217 if record.Type == "TXT" && record.Name == subDomain {
218 records = append(records, record)
219 }
220 }
221
222 if meta.Links.Next == "" {
223 break
224 }
225
226 listOptions.Cursor = meta.Links.Next
227 }
228
229 return zoneDomain, records, nil
230 }
231
232 func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
233 if client == nil {
234 client = &http.Client{Timeout: 5 * time.Second}
235 }
236
237 client.Transport = &oauth2.Transport{
238 Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
239 Base: client.Transport,
240 }
241
242 return client
243 }
244
245 func extendError(resp *http.Response, err error) error {
246 msg := "API call failed"
247 if resp != nil {
248 msg += fmt.Sprintf(" (%d)", resp.StatusCode)
249 }
250
251 return fmt.Errorf("%s: %w", msg, err)
252 }
253