diff --git a/docs/_providers/autodns.md b/docs/_providers/autodns.md new file mode 100644 index 000000000..da7de8946 --- /dev/null +++ b/docs/_providers/autodns.md @@ -0,0 +1,35 @@ +--- +name: AutoDNS +title: AutoDNS (InternetX) +layout: default +jsId: AUTODNS +--- + +# AutoDNS Provider + +## Configuration + +In your credentials file, you must provide [username, password and a context](https://help.internetx.com/display/APIXMLEN/Authentication#Authentication-AuthenticationviaCredentials(username/password/context)). + +{% highlight json %} +{ + "autodns": { + "username": "autodns.service-account@example.com", + "password": "[***]", + "context": "33004" + } +} +{% endhighlight %} + +## Usage + +Example Javascript: + +{% highlight js %} +var REG_NONE = NewRegistrar('none', 'NONE'); +var HETZNER = NewDnsProvider("autodns", "AUTODNS"); + +D("example.tld", REG_NONE, DnsProvider(AUTODNS), + A("test","1.2.3.4") +); +{%endhighlight%} diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 0947f8418..36075e8a0 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -757,6 +757,7 @@ func makeTests(t *testing.T) []*TestGroup { testgroup("Null MX", // These providers don't support RFC 7505 not( + "AUTODNS", "AZURE_DNS", "DIGITALOCEAN", "DNSIMPLE", diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 5f4059285..d82148afc 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -3,6 +3,12 @@ "ADServer": "$AD_SERVER", "domain": "$AD_DOMAIN" }, + "AUTODNS": { + "username": "$AUTODNS_USERNAME", + "password": "$AUTODNS_PASSWORD", + "context": "$AUTODNS_CONTEXT", + "domain": "$AUTODNS_DOMAIN" + }, "AXFRDDNS": { "domain": "$AXFRDDNS_DOMAIN", "master": "$AXFRDDNS_MASTER", diff --git a/providers/_all/all.go b/providers/_all/all.go index 2b5b52a84..3cddb67ed 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -5,6 +5,7 @@ import ( // Define all known providers here. They should each register themselves with the providers package via init function. _ "github.com/StackExchange/dnscontrol/v3/providers/activedir" _ "github.com/StackExchange/dnscontrol/v3/providers/akamaiedgedns" + _ "github.com/StackExchange/dnscontrol/v3/providers/autodns" _ "github.com/StackExchange/dnscontrol/v3/providers/axfrddns" _ "github.com/StackExchange/dnscontrol/v3/providers/azuredns" _ "github.com/StackExchange/dnscontrol/v3/providers/bind" diff --git a/providers/autodns/api.go b/providers/autodns/api.go new file mode 100644 index 000000000..2993906e7 --- /dev/null +++ b/providers/autodns/api.go @@ -0,0 +1,138 @@ +package autodns + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/ioutil" + "net/http" + "sort" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +type ZoneListFilter struct { + Key string `json:"key"` + Value string `json:"value"` + Operator string `json:"operator"` + Link string `json:"link,omitempty"` + Filter []*ZoneListFilter `json:"filters,omitempty"` +} + +type ZoneListRequest struct { + Filter []*ZoneListFilter `json:"filters"` +} + +func (api *autoDnsProvider) request(method string, requestPath string, data interface{}) ([]byte, error) { + client := &http.Client{} + + requestUrl := api.baseURL + requestUrl.Path = api.baseURL.Path + requestPath + + request := &http.Request{ + URL: &requestUrl, + Header: api.defaultHeaders, + Method: method, + } + + if data != nil { + body, _ := json.Marshal(data) + buffer := bytes.NewBuffer(body) + request.Body = io.NopCloser(buffer) + } + + response, error := client.Do(request) + if error != nil { + return nil, error + } + defer response.Body.Close() + + responseText, _ := ioutil.ReadAll(response.Body) + if response.StatusCode != 200 { + return nil, errors.New("Request to " + requestUrl.Path + " failed: " + string(responseText)) + } + + return responseText, nil +} + +func (api *autoDnsProvider) findZoneSystemNameServer(domain string) (*models.Nameserver, error) { + request := &ZoneListRequest{} + + request.Filter = append(request.Filter, &ZoneListFilter{ + Key: "name", + Value: domain, + Operator: "EQUAL", + }) + + responseData, err := api.request("POST", "zone/_search", request) + if err != nil { + return nil, err + } + + var responseObject JSONResponseDataZone + _ = json.Unmarshal(responseData, &responseObject) + if len(responseObject.Data) != 1 { + return nil, errors.New("Domain " + domain + " could not be found in AutoDNS") + } + + systemNameServer := &models.Nameserver{Name: responseObject.Data[0].SystemNameServer} + + return systemNameServer, nil +} + +func (api *autoDnsProvider) getZone(domain string) (*Zone, error) { + systemNameServer, err := api.findZoneSystemNameServer(domain) + if err != nil { + return nil, err + } + + // if resolving of a systemNameServer succeeds the system contains this zone + var responseData, _ = api.request("GET", "zone/" + domain + "/" + systemNameServer.Name, nil) + var responseObject JSONResponseDataZone + // make sure that the response is valid, the zone is in AutoDNS but we're not sure the returned data meets our expectation + unmErr := json.Unmarshal(responseData, &responseObject) + if unmErr != nil { + return nil, unmErr + } + + return responseObject.Data[0], nil +} + +func (api *autoDnsProvider) updateZone(domain string, resourceRecords []*ResourceRecord, nameServers []*models.Nameserver, zoneTTL uint32) error { + systemNameServer, err := api.findZoneSystemNameServer(domain) + + if err != nil { + return err + } + + zone, _ := api.getZone(domain) + + zone.Origin = domain + zone.SystemNameServer = systemNameServer.Name + + zone.IncludeWwwForMain = false + + zone.Soa.TTL = zoneTTL + + // empty out NameServers and ResourceRecords, add what it should be + zone.NameServers = []*models.Nameserver{} + zone.ResourceRecords = []*ResourceRecord{} + + zone.ResourceRecords = append(zone.ResourceRecords, resourceRecords...) + + // naive approach, the first nameserver passed should be the systemNameServer, the will be named alphabetically + sort.Slice(nameServers, func(i, j int) bool { + return nameServers[i].Name < nameServers[j].Name + }) + + zone.NameServers = append(zone.NameServers, nameServers...) + + var _, putErr = api.request("PUT", "zone/" + domain + "/" + systemNameServer.Name, zone) + + if putErr != nil { + return putErr + } + + return nil +} diff --git a/providers/autodns/auditrecords.go b/providers/autodns/auditrecords.go new file mode 100644 index 000000000..320cb526e --- /dev/null +++ b/providers/autodns/auditrecords.go @@ -0,0 +1,9 @@ +package autodns + +import "github.com/StackExchange/dnscontrol/v3/models" + +// AuditRecords returns an error if any records are not +// supportable by this provider. +func AuditRecords(records []*models.RecordConfig) error { + return nil +} diff --git a/providers/autodns/autoDnsProvider.go b/providers/autodns/autoDnsProvider.go new file mode 100644 index 000000000..a4c8de25d --- /dev/null +++ b/providers/autodns/autoDnsProvider.go @@ -0,0 +1,288 @@ +package autodns + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/txtutil" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +var features = providers.DocumentationNotes{ + providers.DocCreateDomains: providers.Cannot(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Cannot(), + providers.CanUseDS: providers.Cannot(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseTLSA: providers.Cannot(), +} + +type autoDnsProvider struct { + baseURL url.URL + defaultHeaders http.Header +} + +func init() { + fns := providers.DspFuncs{ + Initializer: New, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("AUTODNS", fns, features) +} + +// New creates a new API handle. +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + 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, nil +} + +// GetDomainCorrections returns the corrections for a domain. +func (api *autoDnsProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + var changes []*models.RecordConfig + + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + err = dc.Punycode() + if err != nil { + return nil, err + } + domain := dc.Name + + // Get existing records + existingRecords, err := api.GetZoneRecords(domain) + if err != nil { + return nil, err + } + + // Normalize + models.PostProcessRecords(existingRecords) + txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records + + differ := diff.New(dc) + unchanged, create, del, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + for _, m := range unchanged { + changes = append(changes, m.Desired) + } + + for _, m := range del { + // Just notify, these records don't have to be deleted explicitly + fmt.Println(m) + } + + for _, m := range create { + fmt.Println(m) + changes = append(changes, m.Desired) + } + + for _, m := range modify { + fmt.Println("mod") + fmt.Println(m) + changes = append(changes, m.Desired) + } + + var corrections []*models.Correction + + if len(create) > 0 || len(del) > 0 || len(modify) > 0 { + corrections = append(corrections, + &models.Correction{ + Msg: "Zone update for " + domain, + F: func() error { + zoneTTL := uint32(0) + nameServers := []*models.Nameserver{} + resourceRecords := []*ResourceRecord{} + + for _, record := range changes { + // NS records for the APEX should be handled differently + if record.Type == "NS" && record.Name == "@" { + 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(), + ) + } + + resourceRecords = append(resourceRecords, resourceRecord) + } + } + + err := api.updateZone(domain, resourceRecords, nameServers, zoneTTL) + + if err != nil { + fmt.Println(err) + } + + return nil + }, + }) + } + + return corrections, nil +} + +// 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) (models.Records, error) { + zone, _ := api.getZone(domain) + existingRecords := make([]*models.RecordConfig, len(zone.ResourceRecords)) + for i, resourceRecord := range zone.ResourceRecords { + existingRecords[i] = toRecordConfig(domain, resourceRecord) + + // 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 toRecordConfig(domain string, record *ResourceRecord) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: record.Type, + TTL: uint32(record.TTL), + Original: record, + } + rc.SetLabel(record.Name, domain) + + _ = rc.PopulateFromString(record.Type, record.Value, domain) + + if record.Type == "MX" { + rc.MxPreference = uint16(record.Pref) + rc.SetTarget(record.Value) + } + + if record.Type == "SRV" { + rc.SrvPriority = uint16(record.Pref) + + re := regexp.MustCompile(`(\d+) (\d+) (.+)$`) + found := re.FindStringSubmatch(record.Value) + + weight, _ := strconv.Atoi(found[1]) + rc.SrvWeight = uint16(weight) + + port, _ := strconv.Atoi(found[2]) + rc.SrvPort = uint16(port) + + rc.SetTarget(found[3]) + } + + return rc +} diff --git a/providers/autodns/types.go b/providers/autodns/types.go new file mode 100644 index 000000000..ac5f5229c --- /dev/null +++ b/providers/autodns/types.go @@ -0,0 +1,68 @@ +package autodns + +import ( + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/providers/bind" +) + +type ResourceRecord struct { + + // The name of the record. + // Required: true + Name string `json:"name"` + + // Preference of the record, need for some record types e.g. MX + // Maximum: 65535 + Pref int32 `json:"pref,omitempty"` + + // The bind notation of the record. Only used by the zone stream task! + Raw string `json:"raw,omitempty"` + + // TTL of the record (Optionally if not set then Default SOA TTL is used) + TTL int64 `json:"ttl,omitempty"` + + // The type of the record, e.g. A + // Permitted values: A, AAAA, CAA, CNAME, HINFO, MX, NAPTR, NS, PTR, SRV, TXT, ALIAS + Type string `json:"type,omitempty"` + + // The value of the record. + Value string `json:"value,omitempty"` +} + +type MainAddressRecord struct { + + // TTL of the record (Optionally if not set then Default SOA TTL is used) + TTL int64 `json:"ttl,omitempty"` + + // The value of the record. + Value string `json:"address,omitempty"` +} + +type Zone struct { + + Origin string `json:"origin"` + + Soa * bind.SoaDefaults `json:"soa,omitempty"` + + // List of name servers + NameServers []*models.Nameserver `json:"nameServers,omitempty"` + + // The resource records. + // Max Items: 10000 + // Min Items: 0 + ResourceRecords []*ResourceRecord `json:"resourceRecords,omitempty"` + + // Might be set if we fetch a zone for the first time, should be migrated to ResourceRecords + MainRecord *MainAddressRecord `json:"main,omitempty"` + + IncludeWwwForMain bool `json:"wwwInclude"` + + // Primary NameServer, needs to be passed to the system to fetch further zone info + SystemNameServer string `json:"virtualNameServer,omitempty"` +} + +type JSONResponseDataZone struct { + + // The data for the response. The type of the objects are depending on the request and are also specified in the responseObject value of the response. + Data []*Zone `json:"data"` +}