mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-09-07 21:54:59 +08:00
NEW PROVIDER: AutoDNS (#1336)
* Implement AutoDNS provider to manage existing zones * Moved AuditRecords() in to separate file to ease automatic updating * S1011 - Use a single append to concatenate two slices * Set list of available record types as returned by the system * Fixed style, clarify code and add some extra comments * Documented simple configuration and usage example of AutoDNS * Convert MX and SRV record properly from string to actual structs and back * Add support for integration tests of AutoDNS * Return error message from update request instead of invoking panic() * Skip AUTODNS in test for RFC 7505 (null MX) * Update providers/autodns/autoDnsProvider.go Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
parent
02d76affc0
commit
caedb9a7a6
8 changed files with 546 additions and 0 deletions
35
docs/_providers/autodns.md
Normal file
35
docs/_providers/autodns.md
Normal file
|
@ -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%}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
138
providers/autodns/api.go
Normal file
138
providers/autodns/api.go
Normal file
|
@ -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
|
||||
}
|
9
providers/autodns/auditrecords.go
Normal file
9
providers/autodns/auditrecords.go
Normal file
|
@ -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
|
||||
}
|
288
providers/autodns/autoDnsProvider.go
Normal file
288
providers/autodns/autoDnsProvider.go
Normal file
|
@ -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
|
||||
}
|
68
providers/autodns/types.go
Normal file
68
providers/autodns/types.go
Normal file
|
@ -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"`
|
||||
}
|
Loading…
Add table
Reference in a new issue