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