package autodns import ( "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "regexp" "sort" "strconv" "strings" "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/diff2" "github.com/StackExchange/dnscontrol/v4/providers" "github.com/StackExchange/dnscontrol/v4/providers/bind" ) 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.Can(), providers.CanUseAlias: providers.Can(), providers.CanUseCAA: providers.Can(), providers.CanUseDS: providers.Cannot(), providers.CanUsePTR: providers.Can(), providers.CanUseSRV: providers.Can(), providers.CanUseSSHFP: providers.Cannot(), providers.CanUseTLSA: providers.Cannot(), providers.DocCreateDomains: providers.Cannot(), providers.DocDualHost: providers.Cannot(), providers.DocOfficiallySupported: providers.Cannot(), } type autoDNSProvider struct { baseURL url.URL defaultHeaders http.Header } func init() { const providerName = "AUTODNS" const providerMaintainer = "@arnoschoon" fns := providers.DspFuncs{ Initializer: func(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { return new(settings), nil }, RecordAuditor: AuditRecords, } providers.RegisterRegistrarType(providerName, func(settings map[string]string) (providers.Registrar, error) { return new(settings), nil }, features) providers.RegisterDomainServiceProviderType(providerName, fns, features) providers.RegisterMaintainer(providerName, providerMaintainer) } func new(settings map[string]string) *autoDNSProvider { api := &autoDNSProvider{} api.baseURL = url.URL{ Scheme: "https", User: url.UserPassword( settings["username"], settings["password"], ), Host: "api.autodns.com", Path: "/v1/", } api.defaultHeaders = http.Header{ "Accept": []string{"application/json; charset=UTF-8"}, "Content-Type": []string{"application/json; charset=UTF-8"}, "X-Domainrobot-Context": []string{settings["context"]}, } return api } // GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. func (api *autoDNSProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { domain := dc.Name var corrections []*models.Correction result, err := diff2.ByZone(existingRecords, dc, nil) if err != nil { return nil, 0, err } msgs, changed, actualChangeCount := result.Msgs, result.HasChanges, result.ActualChangeCount if changed { msgs = append(msgs, "Zone update for "+domain) msg := strings.Join(msgs, "\n") nameServers, zoneTTL, resourceRecords := recordsToNative(result.DesiredPlus) corrections = append(corrections, &models.Correction{ Msg: msg, F: func() error { nameServers := nameServers zoneTTL := zoneTTL resourceRecords := resourceRecords err := api.updateZone(domain, resourceRecords, nameServers, zoneTTL) if err != nil { return errors.New(err.Error()) } return nil }, }) } return corrections, actualChangeCount, nil } func recordsToNative(recs models.Records) ([]*models.Nameserver, uint32, []*ResourceRecord) { var nameServers []*models.Nameserver var zoneTTL uint32 var resourceRecords []*ResourceRecord for _, record := range recs { if record.Type == "NS" && record.Name == "@" { // NS records for the APEX should be handled differently nameServers = append(nameServers, &models.Nameserver{ Name: strings.TrimSuffix(record.GetTargetField(), "."), }) zoneTTL = record.TTL } else { resourceRecord := &ResourceRecord{ Name: record.Name, TTL: int64(record.TTL), Type: record.Type, Value: record.GetTargetField(), } if resourceRecord.Name == "@" { resourceRecord.Name = "" } if record.Type == "MX" { resourceRecord.Pref = int32(record.MxPreference) } if record.Type == "SRV" { resourceRecord.Value = fmt.Sprintf("%d %d %d %s", record.SrvPriority, record.SrvWeight, record.SrvPort, record.GetTargetField(), ) } if record.Type == "CAA" { resourceRecord.Value = fmt.Sprintf("%d %s \"%s\"", record.CaaFlag, record.CaaTag, record.GetTargetField(), ) } resourceRecords = append(resourceRecords, resourceRecord) } } return nameServers, zoneTTL, resourceRecords } // GetNameservers returns the nameservers for a domain. func (api *autoDNSProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { zone, err := api.getZone(domain) if err != nil { return nil, err } return zone.NameServers, nil } // GetZoneRecords gets the records of a zone and returns them in RecordConfig format. func (api *autoDNSProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { zone, err := api.getZone(domain) if err != nil { return nil, err } existingRecords := make([]*models.RecordConfig, len(zone.ResourceRecords)) for i, resourceRecord := range zone.ResourceRecords { var err error existingRecords[i], err = toRecordConfig(domain, resourceRecord) if err != nil { return nil, err } // If TTL is not set for an individual RR AutoDNS defaults to the zone TTL defined in SOA if existingRecords[i].TTL == 0 { existingRecords[i].TTL = zone.Soa.TTL } } // AutoDNS doesn't respond with APEX nameserver records as regular RR but rather as a zone property for _, nameServer := range zone.NameServers { nameServerRecord := &models.RecordConfig{ TTL: zone.Soa.TTL, } nameServerRecord.SetLabel("", domain) // make sure the value for this NS record is suffixed with a dot at the end _ = nameServerRecord.PopulateFromString("NS", strings.TrimSuffix(nameServer.Name, ".")+".", domain) existingRecords = append(existingRecords, nameServerRecord) } if zone.MainRecord != nil && zone.MainRecord.Value != "" { addressRecord := &models.RecordConfig{ TTL: uint32(zone.MainRecord.TTL), } // If TTL is not set for an individual RR AutoDNS defaults to the zone TTL defined in SOA if addressRecord.TTL == 0 { addressRecord.TTL = zone.Soa.TTL } addressRecord.SetLabel("", domain) _ = addressRecord.PopulateFromString("A", zone.MainRecord.Value, domain) existingRecords = append(existingRecords, addressRecord) if zone.IncludeWwwForMain { prefixedAddressRecord := &models.RecordConfig{ TTL: uint32(zone.MainRecord.TTL), } // If TTL is not set for an individual RR AutoDNS defaults to the zone TTL defined in SOA if prefixedAddressRecord.TTL == 0 { prefixedAddressRecord.TTL = zone.Soa.TTL } prefixedAddressRecord.SetLabel("www", domain) _ = prefixedAddressRecord.PopulateFromString("A", zone.MainRecord.Value, domain) existingRecords = append(existingRecords, prefixedAddressRecord) } } return existingRecords, nil } func (api *autoDNSProvider) EnsureZoneExists(domain string, metadata map[string]string) error { // try to get zone _, err := api.getZone(domain) if !errors.Is(err, os.ErrNotExist) { return err } _, err = api.createZone(domain, &Zone{ Origin: domain, NameServers: []*models.Nameserver{ {Name: "a.ns14.net"}, {Name: "b.ns14.net"}, {Name: "c.ns14.net"}, {Name: "d.ns14.net"}, }, Soa: &bind.SoaDefaults{ Expire: 1209600, Refresh: 43200, Retry: 7200, TTL: 86400, }, }) return err } func (api *autoDNSProvider) ListZones() ([]string, error) { return api.getZones() } func (api *autoDNSProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { domain, err := api.getDomain(dc.Name) if err != nil { return nil, err } existingNs := make([]string, 0, len(domain.NameServers)) for _, ns := range domain.NameServers { existingNs = append(existingNs, ns.Name) } sort.Strings(existingNs) existing := strings.Join(existingNs, ",") desiredNs := models.NameserversToStrings(dc.Nameservers) sort.Strings(desiredNs) desired := strings.Join(desiredNs, ",") if existing != desired { return []*models.Correction{ { Msg: fmt.Sprintf("Change Nameservers from '%s' to '%s'", existing, desired), F: func() error { nameservers := make([]*NameServer, 0, len(desiredNs)) for _, name := range desiredNs { nameservers = append(nameservers, &NameServer{ Name: name, }) } return api.updateDomain(dc.Name, &Domain{ NameServers: nameservers, }) }, }, }, nil } return nil, nil } func toRecordConfig(domain string, record *ResourceRecord) (*models.RecordConfig, error) { rc := &models.RecordConfig{ Type: record.Type, TTL: uint32(record.TTL), Original: record, } rc.SetLabel(record.Name, domain) // special record types are handled below, skip the `rc.PopulateFromString` method if record.Type != "MX" && record.Type != "SRV" { if err := rc.PopulateFromString(record.Type, record.Value, domain); err != nil { return nil, err } } if record.Type == "MX" { rc.MxPreference = uint16(record.Pref) if err := rc.SetTarget(record.Value); err != nil { return nil, err } } if record.Type == "SRV" { rc.SrvPriority = uint16(record.Pref) re := regexp.MustCompile(`(\d+) (\d+) (.+)$`) found := re.FindStringSubmatch(record.Value) if len(found) != 4 { return nil, fmt.Errorf("invalid SRV record value: %s", record.Value) } weight, err := strconv.Atoi(found[1]) if err != nil { return nil, err } if weight < 0 { rc.SrvWeight = 0 } else if weight > 65535 { rc.SrvWeight = 65535 } else { rc.SrvWeight = uint16(weight) } port, err := strconv.Atoi(found[2]) if err != nil { return nil, err } if port < 0 { rc.SrvPort = 0 } else if port > 65535 { rc.SrvPort = 65535 } else { rc.SrvPort = uint16(port) } if err := rc.SetTarget(found[3]); err != nil { return nil, err } } return rc, nil }