summaryrefslogtreecommitdiffstats
path: root/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap.go
blob: d7eb409358196a2f8a72ad42e7fbd7137a5b9a83 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// Package namecheap implements a DNS provider for solving the DNS-01
// challenge using namecheap DNS.
package namecheap

import (
	"encoding/xml"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/xenolf/lego/acme"
)

// Notes about namecheap's tool API:
// 1. Using the API requires registration. Once registered, use your account
//    name and API key to access the API.
// 2. There is no API to add or modify a single DNS record. Instead you must
//    read the entire list of records, make modifications, and then write the
//    entire updated list of records.  (Yuck.)
// 3. Namecheap's DNS updates can be slow to propagate. I've seen them take
//    as long as an hour.
// 4. Namecheap requires you to whitelist the IP address from which you call
//    its APIs. It also requires all API calls to include the whitelisted IP
//    address as a form or query string value. This code uses a namecheap
//    service to query the client's IP address.

var (
	debug          = false
	defaultBaseURL = "https://api.namecheap.com/xml.response"
	getIPURL       = "https://dynamicdns.park-your-domain.com/getip"
	httpClient     = http.Client{Timeout: 60 * time.Second}
)

// DNSProvider is an implementation of the ChallengeProviderTimeout interface
// that uses Namecheap's tool API to manage TXT records for a domain.
type DNSProvider struct {
	baseURL  string
	apiUser  string
	apiKey   string
	clientIP string
}

// NewDNSProvider returns a DNSProvider instance configured for namecheap.
// Credentials must be passed in the environment variables: NAMECHEAP_API_USER
// and NAMECHEAP_API_KEY.
func NewDNSProvider() (*DNSProvider, error) {
	apiUser := os.Getenv("NAMECHEAP_API_USER")
	apiKey := os.Getenv("NAMECHEAP_API_KEY")
	return NewDNSProviderCredentials(apiUser, apiKey)
}

// NewDNSProviderCredentials uses the supplied credentials to return a
// DNSProvider instance configured for namecheap.
func NewDNSProviderCredentials(apiUser, apiKey string) (*DNSProvider, error) {
	if apiUser == "" || apiKey == "" {
		return nil, fmt.Errorf("Namecheap credentials missing")
	}

	clientIP, err := getClientIP()
	if err != nil {
		return nil, err
	}

	return &DNSProvider{
		baseURL:  defaultBaseURL,
		apiUser:  apiUser,
		apiKey:   apiKey,
		clientIP: clientIP,
	}, nil
}

// Timeout returns the timeout and interval to use when checking for DNS
// propagation. Namecheap can sometimes take a long time to complete an
// update, so wait up to 60 minutes for the update to propagate.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
	return 60 * time.Minute, 15 * time.Second
}

// host describes a DNS record returned by the Namecheap DNS gethosts API.
// Namecheap uses the term "host" to refer to all DNS records that include
// a host field (A, AAAA, CNAME, NS, TXT, URL).
type host struct {
	Type    string `xml:",attr"`
	Name    string `xml:",attr"`
	Address string `xml:",attr"`
	MXPref  string `xml:",attr"`
	TTL     string `xml:",attr"`
}

// apierror describes an error record in a namecheap API response.
type apierror struct {
	Number      int    `xml:",attr"`
	Description string `xml:",innerxml"`
}

// getClientIP returns the client's public IP address. It uses namecheap's
// IP discovery service to perform the lookup.
func getClientIP() (addr string, err error) {
	resp, err := httpClient.Get(getIPURL)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	clientIP, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	if debug {
		fmt.Println("Client IP:", string(clientIP))
	}
	return string(clientIP), nil
}

// A challenge repesents all the data needed to specify a dns-01 challenge
// to lets-encrypt.
type challenge struct {
	domain   string
	key      string
	keyFqdn  string
	keyValue string
	tld      string
	sld      string
	host     string
}

// newChallenge builds a challenge record from a domain name, a challenge
// authentication key, and a map of available TLDs.
func newChallenge(domain, keyAuth string, tlds map[string]string) (*challenge, error) {
	domain = acme.UnFqdn(domain)
	parts := strings.Split(domain, ".")

	// Find the longest matching TLD.
	longest := -1
	for i := len(parts); i > 0; i-- {
		t := strings.Join(parts[i-1:], ".")
		if _, found := tlds[t]; found {
			longest = i - 1
		}
	}
	if longest < 1 {
		return nil, fmt.Errorf("Invalid domain name '%s'", domain)
	}

	tld := strings.Join(parts[longest:], ".")
	sld := parts[longest-1]

	var host string
	if longest >= 1 {
		host = strings.Join(parts[:longest-1], ".")
	}

	key, keyValue, _ := acme.DNS01Record(domain, keyAuth)

	return &challenge{
		domain:   domain,
		key:      "_acme-challenge." + host,
		keyFqdn:  key,
		keyValue: keyValue,
		tld:      tld,
		sld:      sld,
		host:     host,
	}, nil
}

// setGlobalParams adds the namecheap global parameters to the provided url
// Values record.
func (d *DNSProvider) setGlobalParams(v *url.Values, cmd string) {
	v.Set("ApiUser", d.apiUser)
	v.Set("ApiKey", d.apiKey)
	v.Set("UserName", d.apiUser)
	v.Set("ClientIp", d.clientIP)
	v.Set("Command", cmd)
}

// getTLDs requests the list of available TLDs from namecheap.
func (d *DNSProvider) getTLDs() (tlds map[string]string, err error) {
	values := make(url.Values)
	d.setGlobalParams(&values, "namecheap.domains.getTldList")

	reqURL, _ := url.Parse(d.baseURL)
	reqURL.RawQuery = values.Encode()

	resp, err := httpClient.Get(reqURL.String())
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("getHosts HTTP error %d", resp.StatusCode)
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	type GetTldsResponse struct {
		XMLName xml.Name   `xml:"ApiResponse"`
		Errors  []apierror `xml:"Errors>Error"`
		Result  []struct {
			Name string `xml:",attr"`
		} `xml:"CommandResponse>Tlds>Tld"`
	}

	var gtr GetTldsResponse
	if err := xml.Unmarshal(body, &gtr); err != nil {
		return nil, err
	}
	if len(gtr.Errors) > 0 {
		return nil, fmt.Errorf("Namecheap error: %s [%d]",
			gtr.Errors[0].Description, gtr.Errors[0].Number)
	}

	tlds = make(map[string]string)
	for _, t := range gtr.Result {
		tlds[t.Name] = t.Name
	}
	return tlds, nil
}

// getHosts reads the full list of DNS host records using the Namecheap API.
func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) {
	values := make(url.Values)
	d.setGlobalParams(&values, "namecheap.domains.dns.getHosts")
	values.Set("SLD", ch.sld)
	values.Set("TLD", ch.tld)

	reqURL, _ := url.Parse(d.baseURL)
	reqURL.RawQuery = values.Encode()

	resp, err := httpClient.Get(reqURL.String())
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("getHosts HTTP error %d", resp.StatusCode)
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	type GetHostsResponse struct {
		XMLName xml.Name   `xml:"ApiResponse"`
		Status  string     `xml:"Status,attr"`
		Errors  []apierror `xml:"Errors>Error"`
		Hosts   []host     `xml:"CommandResponse>DomainDNSGetHostsResult>host"`
	}

	var ghr GetHostsResponse
	if err = xml.Unmarshal(body, &ghr); err != nil {
		return nil, err
	}
	if len(ghr.Errors) > 0 {
		return nil, fmt.Errorf("Namecheap error: %s [%d]",
			ghr.Errors[0].Description, ghr.Errors[0].Number)
	}

	return ghr.Hosts, nil
}

// setHosts writes the full list of DNS host records using the Namecheap API.
func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error {
	values := make(url.Values)
	d.setGlobalParams(&values, "namecheap.domains.dns.setHosts")
	values.Set("SLD", ch.sld)
	values.Set("TLD", ch.tld)

	for i, h := range hosts {
		ind := fmt.Sprintf("%d", i+1)
		values.Add("HostName"+ind, h.Name)
		values.Add("RecordType"+ind, h.Type)
		values.Add("Address"+ind, h.Address)
		values.Add("MXPref"+ind, h.MXPref)
		values.Add("TTL"+ind, h.TTL)
	}

	resp, err := httpClient.PostForm(d.baseURL, values)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("setHosts HTTP error %d", resp.StatusCode)
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	type SetHostsResponse struct {
		XMLName xml.Name   `xml:"ApiResponse"`
		Status  string     `xml:"Status,attr"`
		Errors  []apierror `xml:"Errors>Error"`
		Result  struct {
			IsSuccess string `xml:",attr"`
		} `xml:"CommandResponse>DomainDNSSetHostsResult"`
	}

	var shr SetHostsResponse
	if err := xml.Unmarshal(body, &shr); err != nil {
		return err
	}
	if len(shr.Errors) > 0 {
		return fmt.Errorf("Namecheap error: %s [%d]",
			shr.Errors[0].Description, shr.Errors[0].Number)
	}
	if shr.Result.IsSuccess != "true" {
		return fmt.Errorf("Namecheap setHosts failed.")
	}

	return nil
}

// addChallengeRecord adds a DNS challenge TXT record to a list of namecheap
// host records.
func (d *DNSProvider) addChallengeRecord(ch *challenge, hosts *[]host) {
	host := host{
		Name:    ch.key,
		Type:    "TXT",
		Address: ch.keyValue,
		MXPref:  "10",
		TTL:     "120",
	}

	// If there's already a TXT record with the same name, replace it.
	for i, h := range *hosts {
		if h.Name == ch.key && h.Type == "TXT" {
			(*hosts)[i] = host
			return
		}
	}

	// No record was replaced, so add a new one.
	*hosts = append(*hosts, host)
}

// removeChallengeRecord removes a DNS challenge TXT record from a list of
// namecheap host records. Return true if a record was removed.
func (d *DNSProvider) removeChallengeRecord(ch *challenge, hosts *[]host) bool {
	// Find the challenge TXT record and remove it if found.
	for i, h := range *hosts {
		if h.Name == ch.key && h.Type == "TXT" {
			*hosts = append((*hosts)[:i], (*hosts)[i+1:]...)
			return true
		}
	}

	return false
}

// Present installs a TXT record for the DNS challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
	tlds, err := d.getTLDs()
	if err != nil {
		return err
	}

	ch, err := newChallenge(domain, keyAuth, tlds)
	if err != nil {
		return err
	}

	hosts, err := d.getHosts(ch)
	if err != nil {
		return err
	}

	d.addChallengeRecord(ch, &hosts)

	if debug {
		for _, h := range hosts {
			fmt.Printf(
				"%-5.5s %-30.30s %-6s %-70.70s\n",
				h.Type, h.Name, h.TTL, h.Address)
		}
	}

	return d.setHosts(ch, hosts)
}

// CleanUp removes a TXT record used for a previous DNS challenge.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
	tlds, err := d.getTLDs()
	if err != nil {
		return err
	}

	ch, err := newChallenge(domain, keyAuth, tlds)
	if err != nil {
		return err
	}

	hosts, err := d.getHosts(ch)
	if err != nil {
		return err
	}

	if removed := d.removeChallengeRecord(ch, &hosts); !removed {
		return nil
	}

	return d.setHosts(ch, hosts)
}