forked from TrueCloudLab/lego
Add DNS challenge provider for namecheap.
This commit is contained in:
parent
8c3023d198
commit
55968bcd21
6 changed files with 819 additions and 3 deletions
|
@ -3,12 +3,12 @@
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added:
|
### Added:
|
||||||
- CLI: The `--dns` switch. To include the DNS challenge for consideration. Supported are the following solvers: cloudflare, digitalocean, dnsimple, gandi, route53, rfc2136 and manual.
|
- CLI: The `--dns` switch. To include the DNS challenge for consideration. Supported are the following solvers: cloudflare, digitalocean, dnsimple, gandi, namecheap, route53, rfc2136 and manual.
|
||||||
- CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you.
|
- CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you.
|
||||||
- lib: A new type for challenge identifiers `Challenge`
|
- lib: A new type for challenge identifiers `Challenge`
|
||||||
- lib: A new interface for custom challenge providers `ChallengeProvider`
|
- lib: A new interface for custom challenge providers `ChallengeProvider`
|
||||||
- lib: SetChallengeProvider function. Pass a challenge identifier and a Provider to replace the default behaviour of a challenge.
|
- lib: SetChallengeProvider function. Pass a challenge identifier and a Provider to replace the default behaviour of a challenge.
|
||||||
- lib: The DNS-01 challenge has been implemented with modular solvers using the `ChallengeProvider` interface. Included solvers are: cloudflare, digitalocean, dnsimple, gandi, route53, rfc2136 and manual.
|
- lib: The DNS-01 challenge has been implemented with modular solvers using the `ChallengeProvider` interface. Included solvers are: cloudflare, digitalocean, dnsimple, gandi, namecheap, route53, rfc2136 and manual.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- lib: ExcludeChallenges now expects to be passed an array of `Challenge` types.
|
- lib: ExcludeChallenges now expects to be passed an array of `Challenge` types.
|
||||||
|
|
|
@ -99,6 +99,7 @@ GLOBAL OPTIONS:
|
||||||
digitalocean: DO_AUTH_TOKEN
|
digitalocean: DO_AUTH_TOKEN
|
||||||
dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY
|
dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY
|
||||||
gandi: GANDI_API_KEY
|
gandi: GANDI_API_KEY
|
||||||
|
namecheap: NAMECHEAP_API_USER, NAMECHEAP_API_KEY
|
||||||
route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
|
route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
|
||||||
rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER
|
rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER
|
||||||
manual: none
|
manual: none
|
||||||
|
|
1
cli.go
1
cli.go
|
@ -138,6 +138,7 @@ func main() {
|
||||||
"\n\tdigitalocean: DO_AUTH_TOKEN" +
|
"\n\tdigitalocean: DO_AUTH_TOKEN" +
|
||||||
"\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" +
|
"\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" +
|
||||||
"\n\tgandi: GANDI_API_KEY" +
|
"\n\tgandi: GANDI_API_KEY" +
|
||||||
|
"\n\tnamecheap: NAMECHEAP_API_USER, NAMECHEAP_API_KEY" +
|
||||||
"\n\troute53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION" +
|
"\n\troute53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION" +
|
||||||
"\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER" +
|
"\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER" +
|
||||||
"\n\tmanual: none",
|
"\n\tmanual: none",
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/xenolf/lego/providers/dns/digitalocean"
|
"github.com/xenolf/lego/providers/dns/digitalocean"
|
||||||
"github.com/xenolf/lego/providers/dns/dnsimple"
|
"github.com/xenolf/lego/providers/dns/dnsimple"
|
||||||
"github.com/xenolf/lego/providers/dns/gandi"
|
"github.com/xenolf/lego/providers/dns/gandi"
|
||||||
|
"github.com/xenolf/lego/providers/dns/namecheap"
|
||||||
"github.com/xenolf/lego/providers/dns/rfc2136"
|
"github.com/xenolf/lego/providers/dns/rfc2136"
|
||||||
"github.com/xenolf/lego/providers/dns/route53"
|
"github.com/xenolf/lego/providers/dns/route53"
|
||||||
"github.com/xenolf/lego/providers/http/webroot"
|
"github.com/xenolf/lego/providers/http/webroot"
|
||||||
|
@ -62,7 +63,7 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
client.SetChallengeProvider(acme.HTTP01, provider)
|
client.SetChallengeProvider(acme.HTTP01, provider)
|
||||||
|
|
||||||
// --webroot=foo indicates that the user specifically want to do a HTTP challenge
|
// --webroot=foo indicates that the user specifically want to do a HTTP challenge
|
||||||
// infer that the user also wants to exclude all other challenges
|
// infer that the user also wants to exclude all other challenges
|
||||||
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01})
|
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01})
|
||||||
|
@ -96,6 +97,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
|
||||||
case "gandi":
|
case "gandi":
|
||||||
apiKey := os.Getenv("GANDI_API_KEY")
|
apiKey := os.Getenv("GANDI_API_KEY")
|
||||||
provider, err = gandi.NewDNSProvider(apiKey)
|
provider, err = gandi.NewDNSProvider(apiKey)
|
||||||
|
case "namecheap":
|
||||||
|
provider, err = namecheap.NewDNSProvider("", "")
|
||||||
case "route53":
|
case "route53":
|
||||||
awsRegion := os.Getenv("AWS_REGION")
|
awsRegion := os.Getenv("AWS_REGION")
|
||||||
provider, err = route53.NewDNSProvider("", "", awsRegion)
|
provider, err = route53.NewDNSProvider("", "", awsRegion)
|
||||||
|
|
409
providers/dns/namecheap/namecheap.go
Normal file
409
providers/dns/namecheap/namecheap.go
Normal file
|
@ -0,0 +1,409 @@
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 new DNSProvider instance. apiUser is the namecheap
|
||||||
|
// API user's account name, and apiKey is the account's API access key.
|
||||||
|
func NewDNSProvider(apiUser, apiKey string) (*DNSProvider, error) {
|
||||||
|
if apiUser == "" || apiKey == "" {
|
||||||
|
apiUser = os.Getenv("NAMECHEAP_API_USER")
|
||||||
|
apiKey = os.Getenv("NAMECHEAP_API_KEY")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := http.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 := http.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, >r); 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 := http.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 := http.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)
|
||||||
|
}
|
402
providers/dns/namecheap/namecheap_test.go
Normal file
402
providers/dns/namecheap/namecheap_test.go
Normal file
|
@ -0,0 +1,402 @@
|
||||||
|
package namecheap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fakeUser = "foo"
|
||||||
|
fakeKey = "bar"
|
||||||
|
fakeClientIP = "10.0.0.1"
|
||||||
|
|
||||||
|
tlds = map[string]string{
|
||||||
|
"com.au": "com.au",
|
||||||
|
"com": "com",
|
||||||
|
"co.uk": "co.uk",
|
||||||
|
"uk": "uk",
|
||||||
|
"edu": "edu",
|
||||||
|
"co.com": "co.com",
|
||||||
|
"za.com": "za.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func assertEq(t *testing.T, variable, got, want string) {
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Expected %s to be '%s' but got '%s'", variable, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHdr(tc *testcase, t *testing.T, values *url.Values) {
|
||||||
|
ch, _ := newChallenge(tc.domain, "", tlds)
|
||||||
|
|
||||||
|
assertEq(t, "ApiUser", values.Get("ApiUser"), fakeUser)
|
||||||
|
assertEq(t, "ApiKey", values.Get("ApiKey"), fakeKey)
|
||||||
|
assertEq(t, "UserName", values.Get("UserName"), fakeUser)
|
||||||
|
assertEq(t, "ClientIp", values.Get("ClientIp"), fakeClientIP)
|
||||||
|
assertEq(t, "SLD", values.Get("SLD"), ch.sld)
|
||||||
|
assertEq(t, "TLD", values.Get("TLD"), ch.tld)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockServer(tc *testcase, t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
|
||||||
|
case "GET":
|
||||||
|
values := r.URL.Query()
|
||||||
|
cmd := values.Get("Command")
|
||||||
|
switch cmd {
|
||||||
|
case "namecheap.domains.dns.getHosts":
|
||||||
|
assertHdr(tc, t, &values)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, tc.getHostsResponse)
|
||||||
|
case "namecheap.domains.getTldList":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, responseGetTlds)
|
||||||
|
default:
|
||||||
|
t.Errorf("Unexpected GET command: %s", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "POST":
|
||||||
|
r.ParseForm()
|
||||||
|
values := r.Form
|
||||||
|
cmd := values.Get("Command")
|
||||||
|
switch cmd {
|
||||||
|
case "namecheap.domains.dns.setHosts":
|
||||||
|
assertHdr(tc, t, &values)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, tc.setHostsResponse)
|
||||||
|
default:
|
||||||
|
t.Errorf("Unexpected POST command: %s", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("Unexpected http method: %s", r.Method)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetHosts(tc *testcase, t *testing.T) {
|
||||||
|
mock := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mockServer(tc, t, w, r)
|
||||||
|
}))
|
||||||
|
defer mock.Close()
|
||||||
|
|
||||||
|
prov := &DNSProvider{
|
||||||
|
baseURL: mock.URL,
|
||||||
|
apiUser: fakeUser,
|
||||||
|
apiKey: fakeKey,
|
||||||
|
clientIP: fakeClientIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, _ := newChallenge(tc.domain, "", tlds)
|
||||||
|
hosts, err := prov.getHosts(ch)
|
||||||
|
if tc.errString != "" {
|
||||||
|
if err == nil || err.Error() != tc.errString {
|
||||||
|
t.Errorf("Namecheap getHosts case %s expected error", tc.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Namecheap getHosts case %s failed\n%v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next1:
|
||||||
|
for _, h := range hosts {
|
||||||
|
for _, th := range tc.hosts {
|
||||||
|
if h == th {
|
||||||
|
continue next1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("getHosts case %s unexpected record [%s:%s:%s]",
|
||||||
|
tc.name, h.Type, h.Name, h.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
next2:
|
||||||
|
for _, th := range tc.hosts {
|
||||||
|
for _, h := range hosts {
|
||||||
|
if h == th {
|
||||||
|
continue next2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("getHosts case %s missing record [%s:%s:%s]",
|
||||||
|
tc.name, th.Type, th.Name, th.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockDNSProvider(url string) *DNSProvider {
|
||||||
|
return &DNSProvider{
|
||||||
|
baseURL: url,
|
||||||
|
apiUser: fakeUser,
|
||||||
|
apiKey: fakeKey,
|
||||||
|
clientIP: fakeClientIP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetHosts(tc *testcase, t *testing.T) {
|
||||||
|
mock := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mockServer(tc, t, w, r)
|
||||||
|
}))
|
||||||
|
defer mock.Close()
|
||||||
|
|
||||||
|
prov := mockDNSProvider(mock.URL)
|
||||||
|
ch, _ := newChallenge(tc.domain, "", tlds)
|
||||||
|
hosts, err := prov.getHosts(ch)
|
||||||
|
if tc.errString != "" {
|
||||||
|
if err == nil || err.Error() != tc.errString {
|
||||||
|
t.Errorf("Namecheap getHosts case %s expected error", tc.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Namecheap getHosts case %s failed\n%v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prov.setHosts(ch, hosts)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Namecheap setHosts case %s failed", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPresent(tc *testcase, t *testing.T) {
|
||||||
|
mock := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mockServer(tc, t, w, r)
|
||||||
|
}))
|
||||||
|
defer mock.Close()
|
||||||
|
|
||||||
|
prov := mockDNSProvider(mock.URL)
|
||||||
|
err := prov.Present(tc.domain, "", "dummyKey")
|
||||||
|
if tc.errString != "" {
|
||||||
|
if err == nil || err.Error() != tc.errString {
|
||||||
|
t.Errorf("Namecheap Present case %s expected error", tc.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Namecheap Present case %s failed\n%v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCleanUp(tc *testcase, t *testing.T) {
|
||||||
|
mock := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mockServer(tc, t, w, r)
|
||||||
|
}))
|
||||||
|
defer mock.Close()
|
||||||
|
|
||||||
|
prov := mockDNSProvider(mock.URL)
|
||||||
|
err := prov.CleanUp(tc.domain, "", "dummyKey")
|
||||||
|
if tc.errString != "" {
|
||||||
|
if err == nil || err.Error() != tc.errString {
|
||||||
|
t.Errorf("Namecheap CleanUp case %s expected error", tc.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Namecheap CleanUp case %s failed\n%v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamecheap(t *testing.T) {
|
||||||
|
for _, tc := range testcases {
|
||||||
|
testGetHosts(&tc, t)
|
||||||
|
testSetHosts(&tc, t)
|
||||||
|
testPresent(&tc, t)
|
||||||
|
testCleanUp(&tc, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamecheapDomainSplit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
domain string
|
||||||
|
valid bool
|
||||||
|
tld string
|
||||||
|
sld string
|
||||||
|
host string
|
||||||
|
}{
|
||||||
|
{"a.b.c.test.co.uk", true, "co.uk", "test", "a.b.c"},
|
||||||
|
{"test.co.uk", true, "co.uk", "test", ""},
|
||||||
|
{"test.com", true, "com", "test", ""},
|
||||||
|
{"test.co.com", true, "co.com", "test", ""},
|
||||||
|
{"www.test.com.au", true, "com.au", "test", "www"},
|
||||||
|
{"www.za.com", true, "za.com", "www", ""},
|
||||||
|
{"", false, "", "", ""},
|
||||||
|
{"a", false, "", "", ""},
|
||||||
|
{"com", false, "", "", ""},
|
||||||
|
{"co.com", false, "", "", ""},
|
||||||
|
{"co.uk", false, "", "", ""},
|
||||||
|
{"test.au", false, "", "", ""},
|
||||||
|
{"za.com", false, "", "", ""},
|
||||||
|
{"www.za", false, "", "", ""},
|
||||||
|
{"www.test.au", false, "", "", ""},
|
||||||
|
{"www.test.unk", false, "", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
valid := true
|
||||||
|
ch, err := newChallenge(test.domain, "", tlds)
|
||||||
|
if err != nil {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if test.valid && !valid {
|
||||||
|
t.Errorf("Expected '%s' to split", test.domain)
|
||||||
|
} else if !test.valid && valid {
|
||||||
|
t.Errorf("Expected '%s' to produce error", test.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if test.valid && valid {
|
||||||
|
assertEq(t, "domain", ch.domain, test.domain)
|
||||||
|
assertEq(t, "tld", ch.tld, test.tld)
|
||||||
|
assertEq(t, "sld", ch.sld, test.sld)
|
||||||
|
assertEq(t, "host", ch.host, test.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testcase struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
hosts []host
|
||||||
|
errString string
|
||||||
|
getHostsResponse string
|
||||||
|
setHostsResponse string
|
||||||
|
}
|
||||||
|
|
||||||
|
var testcases []testcase = []testcase{
|
||||||
|
{
|
||||||
|
"Test:Success:1",
|
||||||
|
"test.example.com",
|
||||||
|
[]host{
|
||||||
|
{"A", "home", "10.0.0.1", "10", "1799"},
|
||||||
|
{"A", "www", "10.0.0.2", "10", "1200"},
|
||||||
|
{"AAAA", "a", "::0", "10", "1799"},
|
||||||
|
{"CNAME", "*", "example.com.", "10", "1799"},
|
||||||
|
{"MXE", "example.com", "10.0.0.5", "10", "1800"},
|
||||||
|
{"URL", "xyz", "https://google.com", "10", "1799"},
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
responseGetHostsSuccess1,
|
||||||
|
responseSetHostsSuccess1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Test:Success:2",
|
||||||
|
"example.com",
|
||||||
|
[]host{
|
||||||
|
{"A", "@", "10.0.0.2", "10", "1200"},
|
||||||
|
{"A", "www", "10.0.0.3", "10", "60"},
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
responseGetHostsSuccess2,
|
||||||
|
responseSetHostsSuccess2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Test:Error:BadApiKey:1",
|
||||||
|
"test.example.com",
|
||||||
|
nil,
|
||||||
|
"Namecheap error: API Key is invalid or API access has not been enabled [1011102]",
|
||||||
|
responseGetHostsErrorBadApiKey1,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseGetHostsSuccess1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors />
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
|
||||||
|
<CommandResponse Type="namecheap.domains.dns.getHosts">
|
||||||
|
<DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
|
||||||
|
<host HostId="217076" Name="www" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217069" Name="home" Type="A" Address="10.0.0.1" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217071" Name="a" Type="AAAA" Address="::0" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217075" Name="*" Type="CNAME" Address="example.com." MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217073" Name="example.com" Type="MXE" Address="10.0.0.5" MXPref="10" TTL="1800" AssociatedAppTitle="MXE" FriendlyName="MXE1" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217077" Name="xyz" Type="URL" Address="https://google.com" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
</DomainDNSGetHostsResult>
|
||||||
|
</CommandResponse>
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>3.338</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
||||||
|
|
||||||
|
var responseSetHostsSuccess1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors />
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
|
||||||
|
<CommandResponse Type="namecheap.domains.dns.setHosts">
|
||||||
|
<DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
|
||||||
|
<Warnings />
|
||||||
|
</DomainDNSSetHostsResult>
|
||||||
|
</CommandResponse>
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>2.347</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
||||||
|
|
||||||
|
var responseGetHostsSuccess2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors />
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
|
||||||
|
<CommandResponse Type="namecheap.domains.dns.getHosts">
|
||||||
|
<DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
|
||||||
|
<host HostId="217076" Name="@" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
<host HostId="217069" Name="www" Type="A" Address="10.0.0.3" MXPref="10" TTL="60" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
|
||||||
|
</DomainDNSGetHostsResult>
|
||||||
|
</CommandResponse>
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>3.338</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
||||||
|
|
||||||
|
var responseSetHostsSuccess2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors />
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
|
||||||
|
<CommandResponse Type="namecheap.domains.dns.setHosts">
|
||||||
|
<DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
|
||||||
|
<Warnings />
|
||||||
|
</DomainDNSSetHostsResult>
|
||||||
|
</CommandResponse>
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>2.347</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
||||||
|
|
||||||
|
var responseGetHostsErrorBadApiKey1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="ERROR" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors>
|
||||||
|
<Error Number="1011102">API Key is invalid or API access has not been enabled</Error>
|
||||||
|
</Errors>
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand />
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>0</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
||||||
|
|
||||||
|
var responseGetTlds = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
|
||||||
|
<Errors />
|
||||||
|
<Warnings />
|
||||||
|
<RequestedCommand>namecheap.domains.getTldList</RequestedCommand>
|
||||||
|
<CommandResponse Type="namecheap.domains.getTldList">
|
||||||
|
<Tlds>
|
||||||
|
<Tld Name="com" NonRealTime="false" MinRegisterYears="1" MaxRegisterYears="10" MinRenewYears="1" MaxRenewYears="10" RenewalMinDays="0" RenewalMaxDays="4000" ReactivateMaxDays="27" MinTransferYears="1" MaxTransferYears="1" IsApiRegisterable="true" IsApiRenewable="true" IsApiTransferable="true" IsEppRequired="true" IsDisableModContact="false" IsDisableWGAllot="false" IsIncludeInExtendedSearchOnly="false" SequenceNumber="10" Type="GTLD" SubType="" IsSupportsIDN="true" Category="A" SupportsRegistrarLock="true" AddGracePeriodDays="5" WhoisVerification="false" ProviderApiDelete="true" TldState="" SearchGroup="" Registry="">Most recognized top level domain<Categories><TldCategory Name="popular" SequenceNumber="10" /></Categories></Tld>
|
||||||
|
</Tlds>
|
||||||
|
</CommandResponse>
|
||||||
|
<Server>PHX01SBAPI01</Server>
|
||||||
|
<GMTTimeDifference>--5:00</GMTTimeDifference>
|
||||||
|
<ExecutionTime>0.004</ExecutionTime>
|
||||||
|
</ApiResponse>`
|
Loading…
Reference in a new issue