dnsimple.go raw
1 // Package dnsimple implements a DNS provider for solving the DNS-01 challenge using dnsimple DNS.
2 package dnsimple
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "strconv"
9 "time"
10
11 "github.com/dnsimple/dnsimple-go/v4/dnsimple"
12 "github.com/go-acme/lego/v4/challenge"
13 "github.com/go-acme/lego/v4/challenge/dns01"
14 "github.com/go-acme/lego/v4/platform/config/env"
15 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
16 "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
17 "golang.org/x/oauth2"
18 )
19
20 // Environment variables names.
21 const (
22 envNamespace = "DNSIMPLE_"
23
24 EnvOAuthToken = envNamespace + "OAUTH_TOKEN"
25 EnvBaseURL = envNamespace + "BASE_URL"
26 EnvDebug = envNamespace + "DEBUG"
27
28 EnvTTL = envNamespace + "TTL"
29 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
30 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
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 Debug bool
38 AccessToken string
39 BaseURL string
40 PropagationTimeout time.Duration
41 PollingInterval time.Duration
42 TTL int
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 Debug: env.GetOrDefaultBool(EnvDebug, false),
50 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
51 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
52 }
53 }
54
55 // DNSProvider implements the challenge.Provider interface.
56 type DNSProvider struct {
57 config *Config
58 client *dnsimple.Client
59 }
60
61 // NewDNSProvider returns a DNSProvider instance configured for dnsimple.
62 // Credentials must be passed in the environment variable: DNSIMPLE_OAUTH_TOKEN.
63 //
64 // See: https://developer.dnsimple.com/v2/#authentication
65 func NewDNSProvider() (*DNSProvider, error) {
66 config := NewDefaultConfig()
67 config.AccessToken = env.GetOrFile(EnvOAuthToken)
68 config.BaseURL = env.GetOrFile(EnvBaseURL)
69
70 return NewDNSProviderConfig(config)
71 }
72
73 // NewDNSProviderConfig return a DNSProvider instance configured for DNSimple.
74 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
75 if config == nil {
76 return nil, errors.New("dnsimple: the configuration of the DNS provider is nil")
77 }
78
79 if config.AccessToken == "" {
80 return nil, errors.New("dnsimple: OAuth token is missing")
81 }
82
83 client := dnsimple.NewClient(
84 clientdebug.Wrap(
85 oauth2.NewClient(
86 context.Background(),
87 oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken}),
88 ),
89 ),
90 )
91 client.SetUserAgent(useragent.Get())
92
93 if config.BaseURL != "" {
94 client.BaseURL = config.BaseURL
95 }
96
97 client.Debug = config.Debug
98
99 return &DNSProvider{client: client, config: config}, nil
100 }
101
102 // Present creates a TXT record to fulfill the dns-01 challenge.
103 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
104 ctx := context.Background()
105
106 info := dns01.GetChallengeInfo(domain, keyAuth)
107
108 zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN)
109 if err != nil {
110 return fmt.Errorf("dnsimple: %w", err)
111 }
112
113 accountID, err := d.getAccountID(ctx)
114 if err != nil {
115 return fmt.Errorf("dnsimple: %w", err)
116 }
117
118 recordAttributes, err := newTxtRecord(zoneName, info.EffectiveFQDN, info.Value, d.config.TTL)
119 if err != nil {
120 return fmt.Errorf("dnsimple: %w", err)
121 }
122
123 _, err = d.client.Zones.CreateRecord(ctx, accountID, zoneName, recordAttributes)
124 if err != nil {
125 return fmt.Errorf("dnsimple: API call failed: %w", err)
126 }
127
128 return nil
129 }
130
131 // CleanUp removes the TXT record matching the specified parameters.
132 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
133 ctx := context.Background()
134
135 info := dns01.GetChallengeInfo(domain, keyAuth)
136
137 records, err := d.findTxtRecords(ctx, info.EffectiveFQDN)
138 if err != nil {
139 return fmt.Errorf("dnsimple: %w", err)
140 }
141
142 accountID, err := d.getAccountID(ctx)
143 if err != nil {
144 return fmt.Errorf("dnsimple: %w", err)
145 }
146
147 var lastErr error
148
149 for _, rec := range records {
150 _, err := d.client.Zones.DeleteRecord(ctx, accountID, rec.ZoneID, rec.ID)
151 if err != nil {
152 lastErr = fmt.Errorf("dnsimple: %w", err)
153 }
154 }
155
156 return lastErr
157 }
158
159 // Timeout returns the timeout and interval to use when checking for DNS propagation.
160 // Adjusting here to cope with spikes in propagation times.
161 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
162 return d.config.PropagationTimeout, d.config.PollingInterval
163 }
164
165 func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) {
166 authZone, err := dns01.FindZoneByFqdn(domain)
167 if err != nil {
168 return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
169 }
170
171 accountID, err := d.getAccountID(ctx)
172 if err != nil {
173 return "", err
174 }
175
176 hostedZone, err := d.client.Zones.GetZone(ctx, accountID, dns01.UnFqdn(authZone))
177 if err != nil {
178 return "", fmt.Errorf("get zone: %w", err)
179 }
180
181 if hostedZone == nil || hostedZone.Data == nil || hostedZone.Data.ID == 0 {
182 return "", fmt.Errorf("zone %s not found in DNSimple for domain %s", authZone, domain)
183 }
184
185 return hostedZone.Data.Name, nil
186 }
187
188 func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]dnsimple.ZoneRecord, error) {
189 zoneName, err := d.getHostedZone(ctx, fqdn)
190 if err != nil {
191 return nil, err
192 }
193
194 accountID, err := d.getAccountID(ctx)
195 if err != nil {
196 return nil, err
197 }
198
199 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
200 if err != nil {
201 return nil, err
202 }
203
204 result, err := d.client.Zones.ListRecords(ctx, accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}})
205 if err != nil {
206 return nil, fmt.Errorf("API call has failed: %w", err)
207 }
208
209 return result.Data, nil
210 }
211
212 func newTxtRecord(zoneName, fqdn, value string, ttl int) (dnsimple.ZoneRecordAttributes, error) {
213 subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
214 if err != nil {
215 return dnsimple.ZoneRecordAttributes{}, err
216 }
217
218 return dnsimple.ZoneRecordAttributes{
219 Type: "TXT",
220 Name: &subDomain,
221 Content: value,
222 TTL: ttl,
223 }, nil
224 }
225
226 func (d *DNSProvider) getAccountID(ctx context.Context) (string, error) {
227 whoamiResponse, err := d.client.Identity.Whoami(ctx)
228 if err != nil {
229 return "", err
230 }
231
232 if whoamiResponse.Data.Account == nil {
233 return "", errors.New("user tokens are not supported, please use an account token")
234 }
235
236 return strconv.FormatInt(whoamiResponse.Data.Account.ID, 10), nil
237 }
238