namecheap.go raw
1 // Package namecheap implements a DNS provider for solving the DNS-01 challenge using namecheap DNS.
2 package namecheap
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "strconv"
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/log"
16 "github.com/go-acme/lego/v4/platform/config/env"
17 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
18 "github.com/go-acme/lego/v4/providers/dns/namecheap/internal"
19 "golang.org/x/net/publicsuffix"
20 )
21
22 // Notes about namecheap's tool API:
23 // 1. Using the API requires registration.
24 // Once registered, use your account name and API key to access the API.
25 // 2. There is no API to add or modify a single DNS record.
26 // Instead, you must read the entire list of records, make modifications,
27 // and then write the entire updated list of records. (Yuck.)
28 // 3. Namecheap's DNS updates can be slow to propagate.
29 // I've seen them take as long as an hour.
30 // 4. Namecheap requires you to whitelist the IP address from which you call its APIs.
31 // It also requires all API calls to include the whitelisted IP address as a form or query string value.
32 // This code uses a namecheap service to query the client's IP address.
33
34 // Environment variables names.
35 const (
36 envNamespace = "NAMECHEAP_"
37
38 EnvAPIUser = envNamespace + "API_USER"
39 EnvAPIKey = envNamespace + "API_KEY"
40
41 EnvSandbox = envNamespace + "SANDBOX"
42 EnvDebug = envNamespace + "DEBUG"
43
44 EnvTTL = envNamespace + "TTL"
45 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
46 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
47 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
48 )
49
50 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
51
52 // Config is used to configure the creation of the DNSProvider.
53 type Config struct {
54 Debug bool
55 BaseURL string
56 APIUser string
57 APIKey string
58 ClientIP string
59 PropagationTimeout time.Duration
60 PollingInterval time.Duration
61 TTL int
62 HTTPClient *http.Client
63 }
64
65 // NewDefaultConfig returns a default configuration for the DNSProvider.
66 func NewDefaultConfig() *Config {
67 baseURL := internal.DefaultBaseURL
68 if env.GetOrDefaultBool(EnvSandbox, false) {
69 baseURL = internal.SandboxBaseURL
70 }
71
72 return &Config{
73 BaseURL: baseURL,
74 Debug: env.GetOrDefaultBool(EnvDebug, false),
75 TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
76 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Hour),
77 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
78 HTTPClient: &http.Client{
79 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
80 Transport: defaultTransport(envNamespace),
81 },
82 }
83 }
84
85 // DNSProvider implements the challenge.Provider interface.
86 type DNSProvider struct {
87 config *Config
88 client *internal.Client
89 }
90
91 // NewDNSProvider returns a DNSProvider instance configured for namecheap.
92 // Credentials must be passed in the environment variables:
93 // NAMECHEAP_API_USER and NAMECHEAP_API_KEY.
94 func NewDNSProvider() (*DNSProvider, error) {
95 values, err := env.Get(EnvAPIUser, EnvAPIKey)
96 if err != nil {
97 return nil, fmt.Errorf("namecheap: %w", err)
98 }
99
100 config := NewDefaultConfig()
101 config.APIUser = values[EnvAPIUser]
102 config.APIKey = values[EnvAPIKey]
103
104 return NewDNSProviderConfig(config)
105 }
106
107 // NewDNSProviderConfig return a DNSProvider instance configured for Namecheap.
108 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
109 if config == nil {
110 return nil, errors.New("namecheap: the configuration of the DNS provider is nil")
111 }
112
113 if config.APIUser == "" || config.APIKey == "" {
114 return nil, errors.New("namecheap: credentials missing")
115 }
116
117 if config.ClientIP == "" {
118 clientIP, err := internal.GetClientIP(context.Background(), config.HTTPClient, config.Debug)
119 if err != nil {
120 return nil, fmt.Errorf("namecheap: %w", err)
121 }
122
123 config.ClientIP = clientIP
124 }
125
126 client := internal.NewClient(config.APIUser, config.APIKey, config.ClientIP)
127 client.BaseURL = config.BaseURL
128
129 if config.HTTPClient != nil {
130 client.HTTPClient = config.HTTPClient
131 }
132
133 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
134
135 return &DNSProvider{config: config, client: client}, nil
136 }
137
138 // Timeout returns the timeout and interval to use when checking for DNS propagation.
139 // Namecheap can sometimes take a long time to complete an update, so wait up to 60 minutes for the update to propagate.
140 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
141 return d.config.PropagationTimeout, d.config.PollingInterval
142 }
143
144 // Present installs a TXT record for the DNS challenge.
145 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
146 // TODO(ldez) replace domain by FQDN to follow CNAME.
147 pr, err := newPseudoRecord(domain, keyAuth)
148 if err != nil {
149 return fmt.Errorf("namecheap: %w", err)
150 }
151
152 ctx := context.Background()
153
154 records, err := d.client.GetHosts(ctx, pr.sld, pr.tld)
155 if err != nil {
156 return fmt.Errorf("namecheap: %w", err)
157 }
158
159 record := internal.Record{
160 Name: pr.key,
161 Type: "TXT",
162 Address: pr.keyValue,
163 MXPref: "10",
164 TTL: strconv.Itoa(d.config.TTL),
165 }
166
167 records = append(records, record)
168
169 if d.config.Debug {
170 for _, h := range records {
171 log.Printf("%-5.5s %-30.30s %-6s %-70.70s", h.Type, h.Name, h.TTL, h.Address)
172 }
173 }
174
175 err = d.client.SetHosts(ctx, pr.sld, pr.tld, records)
176 if err != nil {
177 return fmt.Errorf("namecheap: %w", err)
178 }
179
180 return nil
181 }
182
183 // CleanUp removes a TXT record used for a previous DNS challenge.
184 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
185 // TODO(ldez) replace domain by FQDN to follow CNAME.
186 pr, err := newPseudoRecord(domain, keyAuth)
187 if err != nil {
188 return fmt.Errorf("namecheap: %w", err)
189 }
190
191 ctx := context.Background()
192
193 records, err := d.client.GetHosts(ctx, pr.sld, pr.tld)
194 if err != nil {
195 return fmt.Errorf("namecheap: %w", err)
196 }
197
198 // Find the challenge TXT record and remove it if found.
199 var (
200 found bool
201 newRecords []internal.Record
202 )
203
204 for _, h := range records {
205 if h.Name == pr.key && h.Type == "TXT" {
206 found = true
207 } else {
208 newRecords = append(newRecords, h)
209 }
210 }
211
212 if !found {
213 return nil
214 }
215
216 err = d.client.SetHosts(ctx, pr.sld, pr.tld, newRecords)
217 if err != nil {
218 return fmt.Errorf("namecheap: %w", err)
219 }
220
221 return nil
222 }
223
224 // A pseudoRecord represents all the data needed to specify a dns-01 challenge to lets-encrypt.
225 type pseudoRecord struct {
226 domain string
227 key string
228 keyFqdn string
229 keyValue string
230 tld string
231 sld string
232 host string
233 }
234
235 // newPseudoRecord builds a challenge record from a domain name and a challenge authentication key.
236 func newPseudoRecord(domain, keyAuth string) (*pseudoRecord, error) {
237 domain = dns01.UnFqdn(domain)
238
239 tld, _ := publicsuffix.PublicSuffix(domain)
240 if tld == domain {
241 return nil, fmt.Errorf("invalid domain name %q", domain)
242 }
243
244 parts := strings.Split(domain, ".")
245 longest := len(parts) - strings.Count(tld, ".") - 1
246 sld := parts[longest-1]
247
248 var host string
249 if longest >= 1 {
250 host = strings.Join(parts[:longest-1], ".")
251 }
252
253 info := dns01.GetChallengeInfo(domain, keyAuth)
254
255 return &pseudoRecord{
256 domain: domain,
257 key: "_acme-challenge." + host,
258 keyFqdn: info.EffectiveFQDN,
259 keyValue: info.Value,
260 tld: tld,
261 sld: sld,
262 host: host,
263 }, nil
264 }
265