package namecheap import ( "encoding/json" "fmt" "sort" "strings" "time" "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/diff" "github.com/StackExchange/dnscontrol/v4/pkg/printer" "github.com/StackExchange/dnscontrol/v4/providers" nc "github.com/billputer/go-namecheap" "golang.org/x/net/publicsuffix" ) // NamecheapDefaultNs lists the default nameservers for this provider. var NamecheapDefaultNs = []string{"dns1.registrar-servers.com", "dns2.registrar-servers.com"} // namecheapProvider is the handle for this provider. type namecheapProvider struct { APIKEY string APIUser string client *nc.Client } var features = providers.DocumentationNotes{ // The default for unlisted capabilities is 'Cannot'. // See providers/capabilities.go for the entire list of capabilities. providers.CanGetZones: providers.Can(), providers.CanConcur: providers.Cannot(), providers.CanUseAlias: providers.Can(), providers.CanUseCAA: providers.Can(), providers.CanUseLOC: providers.Cannot(), providers.CanUsePTR: providers.Cannot(), providers.CanUseSRV: providers.Cannot("The namecheap web console allows you to make SRV records, but their api does not let you read or set them"), providers.CanUseTLSA: providers.Cannot(), providers.DocCreateDomains: providers.Cannot("Requires domain registered through their service"), providers.DocDualHost: providers.Cannot("Doesn't allow control of apex NS records"), providers.DocOfficiallySupported: providers.Cannot(), } func init() { const providerName = "NAMECHEAP" const providerMaintainer = "@willpower232" providers.RegisterRegistrarType(providerName, newReg) fns := providers.DspFuncs{ Initializer: newDsp, RecordAuditor: AuditRecords, } providers.RegisterDomainServiceProviderType(providerName, fns, features) providers.RegisterCustomRecordType("URL", providerName, "") providers.RegisterCustomRecordType("URL301", providerName, "") providers.RegisterCustomRecordType("FRAME", providerName, "") providers.RegisterMaintainer(providerName, providerMaintainer) } func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { return newProvider(conf, metadata) } func newReg(conf map[string]string) (providers.Registrar, error) { return newProvider(conf, nil) } func newProvider(m map[string]string, metadata json.RawMessage) (*namecheapProvider, error) { api := &namecheapProvider{} api.APIUser, api.APIKEY = m["apiuser"], m["apikey"] if api.APIKEY == "" || api.APIUser == "" { return nil, fmt.Errorf("missing Namecheap apikey and apiuser") } api.client = nc.NewClient(api.APIUser, api.APIKEY, api.APIUser) // if BaseURL is specified in creds, use that url BaseURL, ok := m["BaseURL"] if ok { api.client.BaseURL = BaseURL } return api, nil } func splitDomain(domain string) (sld string, tld string) { tld, _ = publicsuffix.PublicSuffix(domain) d, _ := publicsuffix.EffectiveTLDPlusOne(domain) sld = strings.Split(d, ".")[0] return sld, tld } // namecheap has request limiting at unpublished limits // from support in SEP-2017: // // "The limits for the API calls will be 20/Min, 700/Hour and 8000/Day for one user. // If you can limit the requests within these it should be fine." // // this helper performs some api action, checks for rate limited response, and if so, enters a retry loop until it resolves // if you are consistently hitting this, you may have success asking their support to increase your account's limits. func doWithRetry(f func() error) { // sleep 5 seconds at a time, up to 23 times (1 minute, 15 seconds) const maxRetries = 23 const sleepTime = 5 * time.Second var currentRetry int for { err := f() if err == nil { return } if strings.Contains(err.Error(), "unexpected status code from api: 405") { currentRetry++ if currentRetry >= maxRetries { return } printer.Printf("Namecheap rate limit exceeded. Waiting %s to retry.\n", sleepTime) time.Sleep(sleepTime) } else { return } } } // GetZoneRecords gets the records of a zone and returns them in RecordConfig format. func (n *namecheapProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { sld, tld := splitDomain(domain) var records *nc.DomainDNSGetHostsResult var err error doWithRetry(func() error { records, err = n.client.DomainsDNSGetHosts(sld, tld) return err }) if err != nil { return nil, err } // namecheap has this really annoying feature where they add some parking records if you have no records. // This causes a few problems for our purposes, specifically the integration tests. // lets detect that one case and pretend it is a no-op. if len(records.Hosts) == 2 { if records.Hosts[0].Type == "CNAME" && strings.Contains(records.Hosts[0].Address, "parkingpage") && records.Hosts[1].Type == "URL" { // return an empty zone return nil, nil } } // Copying this from GetDomainCorrections. This seems redundent // with what toRecords() does. Leaving it out. // for _, r := range records.Hosts { // if r.Type == "SOA" { // continue // } // rec := &models.RecordConfig{ // Type: r.Type, // TTL: uint32(r.TTL), // MxPreference: uint16(r.MXPref), // Original: r, // } // rec.SetLabel(r.Name, dc.Name) // switch rtype := r.Type; rtype { // #rtype_variations // case "TXT": // rec.SetTargetTXT(r.Address) // case "CAA": // rec.SetTargetCAAString(r.Address) // default: // rec.SetTarget(r.Address) // } // actual = append(actual, rec) // } return toRecords(records, domain) } // // GetDomainCorrections returns the corrections for the domain. // func (n *namecheapProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { // dc.Punycode() // sld, tld := splitDomain(dc.Name) // var records *nc.DomainDNSGetHostsResult // var err error // doWithRetry(func() error { // records, err = n.client.DomainsDNSGetHosts(sld, tld) // return err // }) // if err != nil { // return nil, err // } // var actual []*models.RecordConfig // // namecheap does not allow setting @ NS with basic DNS // dc.Filter(func(r *models.RecordConfig) bool { // if r.Type == "NS" && r.GetLabel() == "@" { // if !strings.HasSuffix(r.GetTargetField(), "registrar-servers.com.") { // printer.Println("\n", r.GetTargetField(), "Namecheap does not support changing apex NS records. Skipping.") // } // return false // } // return true // }) // // namecheap has this really annoying feature where they add some parking records if you have no records. // // This causes a few problems for our purposes, specifically the integration tests. // // lets detect that one case and pretend it is a no-op. // if len(dc.Records) == 0 && len(records.Hosts) == 2 { // if records.Hosts[0].Type == "CNAME" && // strings.Contains(records.Hosts[0].Address, "parkingpage") && // records.Hosts[1].Type == "URL" { // return nil, nil // } // } // for _, r := range records.Hosts { // if r.Type == "SOA" { // continue // } // rec := &models.RecordConfig{ // Type: r.Type, // TTL: uint32(r.TTL), // MxPreference: uint16(r.MXPref), // Original: r, // } // rec.SetLabel(r.Name, dc.Name) // switch rtype := r.Type; rtype { // #rtype_variations // case "TXT": // rec.SetTargetTXT(r.Address) // case "CAA": // rec.SetTargetCAAString(r.Address) // default: // rec.SetTarget(r.Address) // } // actual = append(actual, rec) // } // // Normalize // models.PostProcessRecords(actual) // return n.GetZoneRecordsCorrections(dc, actual) // } // GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. func (n *namecheapProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, actual models.Records) ([]*models.Correction, error) { // namecheap does not allow setting @ NS with basic DNS dc.Filter(func(r *models.RecordConfig) bool { if r.Type == "NS" && r.GetLabel() == "@" { if !strings.HasSuffix(r.GetTargetField(), "registrar-servers.com.") { printer.Println("\n", r.GetTargetField(), "Namecheap does not support changing apex NS records. Skipping.") } return false } return true }) toReport, create, delete, modify, err := diff.NewCompat(dc).IncrementalDiff(actual) if err != nil { return nil, err } // Start corrections with the reports corrections := diff.GenerateMessageCorrections(toReport) // because namecheap doesn't have selective create, delete, modify, // we bundle them all up to send at once. We *do* want to see the // changes though var desc []string for _, i := range create { desc = append(desc, "\n"+i.String()) } for _, i := range delete { desc = append(desc, "\n"+i.String()) } for _, i := range modify { desc = append(desc, "\n"+i.String()) } // only create corrections if there are changes if len(desc) > 0 { msg := fmt.Sprintf("GENERATE_ZONE: %s (%d records)%s", dc.Name, len(dc.Records), desc) corrections = append(corrections, &models.Correction{ Msg: msg, F: func() error { return n.generateRecords(dc) }, }) } return corrections, nil } func toRecords(result *nc.DomainDNSGetHostsResult, origin string) ([]*models.RecordConfig, error) { var records []*models.RecordConfig for _, dnsHost := range result.Hosts { record := models.RecordConfig{ Type: dnsHost.Type, TTL: uint32(dnsHost.TTL), MxPreference: uint16(dnsHost.MXPref), Name: dnsHost.Name, } record.SetLabel(dnsHost.Name, origin) switch dnsHost.Type { case "MX": record.SetTargetMX(uint16(dnsHost.MXPref), dnsHost.Address) default: record.PopulateFromString(dnsHost.Type, dnsHost.Address, origin) } records = append(records, &record) } return records, nil } func (n *namecheapProvider) generateRecords(dc *models.DomainConfig) error { var recs []nc.DomainDNSHost id := 1 for _, r := range dc.Records { var value string switch rtype := r.Type; rtype { // #rtype_variations case "CAA": value = r.GetTargetCombined() default: value = r.GetTargetField() } rec := nc.DomainDNSHost{ ID: id, Name: r.GetLabel(), Type: r.Type, Address: value, MXPref: int(r.MxPreference), TTL: int(r.TTL), } recs = append(recs, rec) id++ } sld, tld := splitDomain(dc.Name) var err error doWithRetry(func() error { _, err = n.client.DomainDNSSetHosts(sld, tld, recs) return err }) return err } // GetNameservers returns the nameservers for a domain. func (n *namecheapProvider) GetNameservers(domainName string) ([]*models.Nameserver, error) { // return default namecheap nameservers return models.ToNameservers(NamecheapDefaultNs) } // GetRegistrarCorrections returns corrections to update nameservers. func (n *namecheapProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { var info *nc.DomainInfo var err error doWithRetry(func() error { info, err = n.client.DomainGetInfo(dc.Name) return err }) if err != nil { return nil, err } sort.Strings(info.DNSDetails.Nameservers) found := strings.Join(info.DNSDetails.Nameservers, ",") desiredNs := []string{} for _, d := range dc.Nameservers { desiredNs = append(desiredNs, d.Name) } sort.Strings(desiredNs) desired := strings.Join(desiredNs, ",") if found != desired { parts := strings.SplitN(dc.Name, ".", 2) sld, tld := parts[0], parts[1] return []*models.Correction{ { Msg: fmt.Sprintf("Change Nameservers from '%s' to '%s'", found, desired), F: func() (err error) { doWithRetry(func() error { _, err = n.client.DomainDNSSetCustom(sld, tld, desired) return err }) return }}, }, nil } return nil, nil }