cloudflare.go raw
1 // Package cloudflare implements a DNS provider for solving the DNS-01 challenge using cloudflare DNS.
2 package cloudflare
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "strconv"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/go-acme/lego/v4/challenge"
15 "github.com/go-acme/lego/v4/challenge/dns01"
16 "github.com/go-acme/lego/v4/log"
17 "github.com/go-acme/lego/v4/platform/config/env"
18 "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal"
19 )
20
21 // Environment variables names.
22 const (
23 envNamespace = "CLOUDFLARE_"
24
25 EnvEmail = envNamespace + "EMAIL"
26 EnvAPIKey = envNamespace + "API_KEY"
27
28 EnvDNSAPIToken = envNamespace + "DNS_API_TOKEN"
29 EnvZoneAPIToken = envNamespace + "ZONE_API_TOKEN"
30
31 EnvBaseURL = envNamespace + "BASE_URL"
32
33 EnvTTL = envNamespace + "TTL"
34 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
35 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
36 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
37 )
38
39 const (
40 altEnvNamespace = "CF_"
41
42 altEnvEmail = altEnvNamespace + "API_EMAIL"
43 )
44
45 const (
46 minTTL = 120
47 )
48
49 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
50
51 // Config is used to configure the creation of the DNSProvider.
52 type Config struct {
53 AuthEmail string
54 AuthKey string
55
56 AuthToken string
57 ZoneToken string
58
59 BaseURL string
60
61 TTL int
62 PropagationTimeout time.Duration
63 PollingInterval time.Duration
64 HTTPClient *http.Client
65 }
66
67 // NewDefaultConfig returns a default configuration for the DNSProvider.
68 func NewDefaultConfig() *Config {
69 return &Config{
70 TTL: env.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)),
71 PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)),
72 PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
73 HTTPClient: &http.Client{
74 Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 30*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
75 },
76 }
77 }
78
79 // DNSProvider implements the challenge.Provider interface.
80 type DNSProvider struct {
81 client *metaClient
82 config *Config
83
84 recordIDs map[string]string
85 recordIDsMu sync.Mutex
86 }
87
88 // NewDNSProvider returns a DNSProvider instance configured for Cloudflare.
89 // Credentials must be passed in as environment variables:
90 //
91 // Either provide CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY,
92 // or a CLOUDFLARE_DNS_API_TOKEN.
93 //
94 // For a more paranoid setup, provide CLOUDFLARE_DNS_API_TOKEN and CLOUDFLARE_ZONE_API_TOKEN.
95 //
96 // The email and API key should be avoided, if possible.
97 // Instead, set up an API token with both Zone:Read and DNS:Edit permission, and pass the CLOUDFLARE_DNS_API_TOKEN environment variable.
98 // You can split the Zone:Read and DNS:Edit permissions across multiple API tokens:
99 // in this case pass both CLOUDFLARE_ZONE_API_TOKEN and CLOUDFLARE_DNS_API_TOKEN accordingly.
100 func NewDNSProvider() (*DNSProvider, error) {
101 values, err := env.GetWithFallback(
102 []string{EnvEmail, altEnvEmail},
103 []string{EnvAPIKey, altEnvName(EnvAPIKey)},
104 )
105 if err != nil {
106 var errT error
107
108 values, errT = env.GetWithFallback(
109 []string{EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)},
110 []string{EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)},
111 )
112 if errT != nil {
113 //nolint:errorlint
114 return nil, fmt.Errorf("cloudflare: %v or %v", err, errT)
115 }
116 }
117
118 config := NewDefaultConfig()
119 config.AuthEmail = values[EnvEmail]
120 config.AuthKey = values[EnvAPIKey]
121 config.AuthToken = values[EnvDNSAPIToken]
122 config.ZoneToken = values[EnvZoneAPIToken]
123 config.BaseURL = env.GetOrFile(EnvBaseURL)
124
125 return NewDNSProviderConfig(config)
126 }
127
128 // NewDNSProviderConfig return a DNSProvider instance configured for Cloudflare.
129 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
130 if config == nil {
131 return nil, errors.New("cloudflare: the configuration of the DNS provider is nil")
132 }
133
134 if config.TTL < minTTL {
135 return nil, fmt.Errorf("cloudflare: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
136 }
137
138 client, err := newClient(config)
139 if err != nil {
140 return nil, fmt.Errorf("cloudflare: %w", err)
141 }
142
143 return &DNSProvider{
144 client: client,
145 config: config,
146 recordIDs: make(map[string]string),
147 }, nil
148 }
149
150 // Timeout returns the timeout and interval to use when checking for DNS propagation.
151 // Adjusting here to cope with spikes in propagation times.
152 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
153 return d.config.PropagationTimeout, d.config.PollingInterval
154 }
155
156 // Present creates a TXT record to fulfill the dns-01 challenge.
157 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
158 ctx := context.Background()
159
160 info := dns01.GetChallengeInfo(domain, keyAuth)
161
162 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
163 if err != nil {
164 return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err)
165 }
166
167 zoneID, err := d.client.ZoneIDByName(ctx, authZone)
168 if err != nil {
169 return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err)
170 }
171
172 dnsRecord := internal.Record{
173 Type: "TXT",
174 Name: dns01.UnFqdn(info.EffectiveFQDN),
175 Content: `"` + info.Value + `"`,
176 TTL: d.config.TTL,
177 }
178
179 response, err := d.client.CreateDNSRecord(ctx, zoneID, dnsRecord)
180 if err != nil {
181 return fmt.Errorf("cloudflare: failed to create TXT record: %w", err)
182 }
183
184 d.recordIDsMu.Lock()
185 d.recordIDs[token] = response.ID
186 d.recordIDsMu.Unlock()
187
188 log.Infof("cloudflare: new record for %s, ID %s", domain, response.ID)
189
190 return nil
191 }
192
193 // CleanUp removes the TXT record matching the specified parameters.
194 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
195 ctx := context.Background()
196
197 info := dns01.GetChallengeInfo(domain, keyAuth)
198
199 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
200 if err != nil {
201 return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err)
202 }
203
204 zoneID, err := d.client.ZoneIDByName(ctx, authZone)
205 if err != nil {
206 return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err)
207 }
208
209 // get the record's unique ID from when we created it
210 d.recordIDsMu.Lock()
211 recordID, ok := d.recordIDs[token]
212 d.recordIDsMu.Unlock()
213
214 if !ok {
215 return fmt.Errorf("cloudflare: unknown record ID for '%s'", info.EffectiveFQDN)
216 }
217
218 err = d.client.DeleteDNSRecord(ctx, zoneID, recordID)
219 if err != nil {
220 log.Printf("cloudflare: failed to delete TXT record: %v", err)
221 }
222
223 // Delete record ID from map
224 d.recordIDsMu.Lock()
225 delete(d.recordIDs, token)
226 d.recordIDsMu.Unlock()
227
228 return nil
229 }
230
231 func altEnvName(v string) string {
232 return strings.ReplaceAll(v, envNamespace, altEnvNamespace)
233 }
234