From 8dd66ec6050594077af9ca57b7c6470a7d8ed9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Henry?= Date: Fri, 8 May 2020 16:55:51 +0200 Subject: [PATCH] New provider: AXFR+DDNS (#259) (#729) * NEW PROVIDER: AXFR+DDNS (#259) * AXFRDDNS: split GetZoneRecords in two functions * AXFRDDNS: improve code documentation * AXFRDDNS: line-wrap documentation * AXFRDDNS: add simple `named.conf` as example * AXFRDDNS: improve error messages * AXFRDDNS: improve doc. * AXFRDDNS: update `OWNERS` * Linting and other cosmetic changes * AXFRDDNS: fix grammar Co-authored-by: Tom Limoncelli --- OWNERS | 1 + README.md | 1 + docs/_includes/matrix.html | 49 ++++ docs/_providers/axfrddns.md | 196 +++++++++++++ docs/provider-list.md | 1 + integrationTest/providers.json | 7 + providers/_all/all.go | 1 + providers/axfrddns/axfrddnsProvider.go | 383 +++++++++++++++++++++++++ 8 files changed, 639 insertions(+) create mode 100644 docs/_providers/axfrddns.md create mode 100644 providers/axfrddns/axfrddnsProvider.go diff --git a/OWNERS b/OWNERS index 8f32ab711..81bb3bfaa 100644 --- a/OWNERS +++ b/OWNERS @@ -1,4 +1,5 @@ # providers/activedir +providers/axfrddns @hnrgrgr providers/azuredns @vatsalyagoel providers/bind @tlimoncelli # providers/cloudflare diff --git a/README.md b/README.md index 9fbd86b9f..c037e69ee 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Windows). The provider model is extensible, so more providers can be added. Currently supported DNS providers: - AWS Route 53 - Active Directory + - AXFR+DDNS - Azure DNS - BIND - ClouDNS diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index de70f678d..356c479ef 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -7,6 +7,7 @@
ACTIVEDIRECTORY_PS
+
AXFRDDNS
AZURE_DNS
BIND
CLOUDFLAREAPI
@@ -38,6 +39,9 @@ + + + @@ -146,6 +150,9 @@ + + + @@ -206,6 +213,9 @@ + + + @@ -263,6 +273,7 @@ + @@ -316,6 +327,9 @@ AUTODNSSEC + + + @@ -384,6 +398,9 @@ + + + @@ -418,6 +435,9 @@ + + + @@ -473,6 +493,9 @@ NAPTR + + + @@ -529,6 +552,9 @@ + + + @@ -575,6 +601,9 @@ SSHFP + + + @@ -622,6 +651,9 @@ TLSA + + + @@ -679,6 +711,9 @@ + + + @@ -728,6 +763,7 @@ + @@ -749,6 +785,7 @@ AZURE_ALIAS + @@ -780,6 +817,9 @@ + + + @@ -843,6 +883,9 @@ + + + @@ -918,6 +961,9 @@ + + + @@ -1014,6 +1060,9 @@ + + + diff --git a/docs/_providers/axfrddns.md b/docs/_providers/axfrddns.md new file mode 100644 index 000000000..b2cbb4ac4 --- /dev/null +++ b/docs/_providers/axfrddns.md @@ -0,0 +1,196 @@ +--- +name: AXFRDDNS +title: AXFR+DDNS Provider +layout: default +jsId: AXFRDDNS +--- +# AXFR+DDNS Provider + +This provider uses the native DNS protocols. It uses the AXFR (RFC5936, +Zone Transfer Protocol) to retrieve the existing records and DDNS +(RFC2136, Dynamic Update) to make corrections. It can use TSIG (RFC2845) or +IP-based authentication (ACLs). + +It is able to work with any standards-compliant +authoritative DNS server. It has been tested with +[BIND](https://www.isc.org/bind/), [Knot](https://www.knot-dns.cz/), +and [Yadifa](https://www.yadifa.eu/home). + +## Configuration + +### Authentication + +Authentication information is included in the `creds.json` entry for +the provider: + +* `transfer-key`: If this exists, the value is used to authenticate AXFR transfers. +* `update-key`: If this exists, the value is used to authenticate DDNS updates. + +For instance, your `creds.json` might looks like: + +{% highlight json %} +{ + "axfrddns": { + "transfer-key": "hmac-sha256:transfer-key-id:Base64EncodedSecret=", + "update-key": "hmac-sha256:update-key-id:AnotherSecret=" + } +} +{% endhighlight %} + +If either key is missing, DNSControl defaults to IP-based ACL +authentication for that function. Including both keys is the most +secure option. Omitting both keys defaults to IP-based ACLs for all +operations, which is the least secure option. + +If distinct zones require distinct keys, you will need to instantiate the +provider once for each key: + +{% highlight javascript %} +var AXFRDDNS_A = NewDnsProvider('axfrddns-a', 'AXFRDDNS'} +var AXFRDDNS_B = NewDnsProvider('axfrddns-b', 'AXFRDDNS'} +{% endhighlight %} + +And update `creds.json` accordingly: + +{% highlight json %} +{ + "axfrddns-a": { + "transfer-key": "hmac-sha256:transfer-key-id:Base64EncodedSecret=", + "update-key": "hmac-sha256:update-key-id:AnotherSecret=" + }, + "axfrddns-b": { + "transfer-key": "hmac-sha512:transfer-key-id-B:SmallSecret=", + "update-key": "hmac-sha512:update-key-id-B:YetAnotherSecret=" + } +} +{% endhighlight %} + +### Default nameservers + +The AXFR+DDNS provider can be configured with a list of default +nameservers. They will be added to all the zones handled by the +provider. + +This list can be provided either as metadata or in `creds.json`. Only +the later allows `get-zones` to work properly. + +{% highlight javascript %} +var AXFRDDNS = NewDnsProvider('axfrddns', 'AXFRDDNS', + 'default_ns': [ + 'ns1.example.tld.', + 'ns2.example.tld.', + 'ns3.example.tld.', + 'ns4.example.tld.' + ] +} +{% endhighlight %} + +{% highlight json %} +{ + nameservers = "ns1.example.tld,ns2.example.tld,ns3.example.tld,ns4.example.tld" +} +{% endhighlight %} + +### Primary master + +By default, the AXFR+DDNS provider will send the AXFR requests and the +DDNS updates to the first nameserver of the zone, usually known as the +"primary master". Typically, this is the first of the default +nameservers. Though, on some networks, the primary master is a private +node, hidden behind slaves, and it does not appear in the `NS` records +of the zone. In that case, the IP or the name of the primary server +must be provided in `creds.json`. With this option, a non-standard +port might be used. + +{% highlight json %} +{ + master = "10.20.30.40:5353" +} +{% endhighlight %} + +When no nameserver appears in the zone, and no default nameservers nor +custom master are configured, the AXFR+DDNS provider will fail with +the following error message: + +{% highlight %} +[Error] AXFRDDNS: the nameservers list cannot be empty. +Please consider adding default `nameservers` or an explicit `master` in `creds.json`. +{% endhighlight %} + + +## Server configuration examples + +### Bind9 + +Here is a sample `named.conf` example for an authauritative server on +zone `example.tld`. It uses a simple IP-based ACL for the AXFR +transfer and a conjunction of TSIG and IP-based ACL for the updates. + +{% highlight %} +options { + + listen-on { any; }; + listen-on-v6 { any; }; + + allow-query { any; }; + allow-notify { none; }; + allow-recursion { none; }; + allow-transfer { none; }; + allow-update { none; }; + allow-query-cache { none; }; + +}; + +zone "example.tld" { + type master; + file "/etc/bind/db.example.tld"; + allow-transfer { example-transfer; }; + allow-update { example-update; }; +}; + +## Allow transfer to anyone on our private network + +acl example-transfer { + 172.17.0.0/16; +}; + +## Allow update only from authenticated client on our private network + +acl example-update { + ! { + !172.17.0.0/16; + any; + }; + key update-key-id; +}; + +key update-key-id { + algorithm HMAC-SHA256; + secret "AnotherSecret="; +}; +{% endhighlight %} + +## FYI: get-zones + +When using `get-zones`, a custom master or a list of default +nameservers should be configured in `creds.json`. + +THe AXFR+DDNS provider does not display DNSSec records. But, if any +DNSSec records is found in the zone, it will replace all of them with +a single placeholder record: + +{% highlight %} +__dnssec IN TXT "Domain has DNSSec records, not displayed here." +{% endhighlight %} + +## FYI: create-domain + +The AXFR+DDNS provider is not able to create domain. + +## FYI: AUTODNSSEC + +The AXFR+DDNS provider is not able to ask the DNS server to sign the zone. But, it is able to check whether the server seems to do so or not. + +When AutoDNSSEC is set, the AXFR+DDNS provider will emit a warning when no RRSIG, DNSKEY or NSEC records are found in the zone. + +When AutoDNSSEC is not set, the AXFR+DDNS provider will emit a warning when RRSIG, DNSKEY or NSEC records are found in the zone. diff --git a/docs/provider-list.md b/docs/provider-list.md index 1a083a6da..52b4ce75e 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -71,6 +71,7 @@ provided to help community members support their code independently. Maintainers of contributed providers: +* `AXFRDDNS` @hnrgrgr * `CLOUDNS` @pragmaton * `DESEC` @D3luxee * `DIGITALOCEAN` @Deraen diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 7828d05da..97e05a6f6 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -3,6 +3,13 @@ "ADServer": "$AD_SERVER", "domain": "$AD_DOMAIN" }, + "AXFRDDNS": { + "master": "$AXFRDDNS_MASTER", + "nameservers": "ns.example.com", + "transfer-key": "$AXFRDDNS_TRANSFER_KEY", + "update-key": "$AXFRDDNS_UPDATE_KEY", + "domain": "$AXFRDDNS_DOMAIN" + }, "AZURE_DNS": { "ClientID": "$AZURE_CLIENT_ID", "ClientSecret": "$AZURE_CLIENT_SECRET", diff --git a/providers/_all/all.go b/providers/_all/all.go index c7583041c..5259d41a6 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -4,6 +4,7 @@ package all 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/axfrddns" _ "github.com/StackExchange/dnscontrol/v3/providers/azuredns" _ "github.com/StackExchange/dnscontrol/v3/providers/bind" _ "github.com/StackExchange/dnscontrol/v3/providers/cloudflare" diff --git a/providers/axfrddns/axfrddnsProvider.go b/providers/axfrddns/axfrddnsProvider.go new file mode 100644 index 000000000..368d8ef6f --- /dev/null +++ b/providers/axfrddns/axfrddnsProvider.go @@ -0,0 +1,383 @@ +package axfrddns + +/* + +axfrddns - + Fetch the zone with an AXFR request (RFC5936) to a given primary master, and + push Dynamic DNS updates (RFC2136) to the same server. + + Both the AXFR request and the updates might be authentificated with + a TSIG. + +*/ + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "math/rand" + "strings" + "time" + + "github.com/miekg/dns" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +const ( + dnsTimeout = 30 * time.Second + dnssecDummyLabel = "__dnssec" + dnssecDummyTxt = "Domain has DNSSec records, not displayed here." +) + +var features = providers.DocumentationNotes{ + providers.CanUseCAA: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanUseNAPTR: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Can(), + providers.CanUseTXTMulti: providers.Can(), + providers.CanAutoDNSSEC: providers.Can("Just warn when DNSSEC is requested but no RRSIG is found in the AXFR or warn when DNSSEC is not requested but RRSIG are found in the AXFR."), + providers.CantUseNOPURGE: providers.Cannot(), + providers.DocCreateDomains: providers.Cannot(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Can(), +} + +// AxfrDdns stores the client info for the provider. +type AxfrDdns struct { + rand *rand.Rand + master string + nameservers []*models.Nameserver + transferKey *Key + updateKey *Key +} + +func initAxfrDdns(config map[string]string, providermeta json.RawMessage) (providers.DNSServiceProvider, error) { + // config -- the key/values from creds.json + // providermeta -- the json blob from NewReq('name', 'TYPE', providermeta) + var err error + api := &AxfrDdns{ + rand: rand.New(rand.NewSource(int64(time.Now().Nanosecond()))), + } + param := &Param{} + if len(providermeta) != 0 { + err := json.Unmarshal(providermeta, param) + if err != nil { + return nil, err + } + } + var nss []string + if config["nameservers"] != "" { + nss = strings.Split(config["nameservers"], ",") + } + for _, ns := range param.DefaultNS { + nss = append(nss, ns[0:len(ns)-1]) + } + api.nameservers, err = models.ToNameservers(nss) + if err != nil { + return nil, err + } + if config["master"] != "" { + api.master = config["master"] + if !strings.Contains(api.master, ":") { + api.master = api.master + ":53" + } + } else if len(api.nameservers) != 0 { + api.master = api.nameservers[0].Name + ":53" + } else { + return nil, fmt.Errorf("nameservers list is empty: creds.json needs a default `nameservers` or an explicit `master`") + } + api.updateKey, err = readKey(config["update-key"], "update-key") + if err != nil { + return nil, err + } + api.transferKey, err = readKey(config["transfer-key"], "transfer-key") + if err != nil { + return nil, err + } + for key := range config { + switch key { + case "master", + "nameservers", + "update-key", + "transfer-key": + continue + default: + fmt.Printf("[Warning] AXFRDDNS: unknown key in `creds.json` (%s)\n", key) + } + } + return api, err +} + +func init() { + providers.RegisterDomainServiceProviderType("AXFRDDNS", initAxfrDdns, features) +} + +// Param is used to decode extra parameters sent to provider. +type Param struct { + DefaultNS []string `json:"default_ns"` +} + +// Key stores the individual parts of a TSIG key. +type Key struct { + algo string + id string + secret string +} + +func readKey(raw string, kind string) (*Key, error) { + if raw == "" { + return nil, nil + } + arr := strings.Split(raw, ":") + if len(arr) != 3 { + return nil, fmt.Errorf("invalid key format (%s) in AXFRDDNS.TSIG", kind) + } + var algo string + switch arr[0] { + case "hmac-md5", "md5": + algo = dns.HmacMD5 + case "hmac-sha1", "sha1": + algo = dns.HmacSHA1 + case "hmac-sha256", "sha256": + algo = dns.HmacSHA256 + case "hmac-sha512", "sha512": + algo = dns.HmacSHA512 + default: + return nil, fmt.Errorf("unknown algorithm (%s) in AXFRDDNS.TSIG", kind) + } + _, err := base64.StdEncoding.DecodeString(arr[2]) + if err != nil { + return nil, fmt.Errorf("cannot decode Base64 secret (%s) in AXFRDDNS.TSIG", kind) + } + return &Key{algo: algo, id: arr[1] + ".", secret: arr[2]}, nil +} + +// GetNameservers returns the nameservers for a domain. +func (c *AxfrDdns) GetNameservers(domain string) ([]*models.Nameserver, error) { + return c.nameservers, nil +} + +// FetchZoneRecords gets the records of a zone and returns them in dns.RR format. +func (c *AxfrDdns) FetchZoneRecords(domain string) ([]dns.RR, error) { + + transfer := new(dns.Transfer) + transfer.DialTimeout = dnsTimeout + transfer.ReadTimeout = dnsTimeout + + request := new(dns.Msg) + request.SetAxfr(domain + ".") + + if c.transferKey != nil { + transfer.TsigSecret = + map[string]string{c.transferKey.id: c.transferKey.secret} + request.SetTsig(c.transferKey.id, c.transferKey.algo, 300, time.Now().Unix()) + } + + envelope, err := transfer.In(request, c.master) + if err != nil { + return nil, err + } + + var rawRecords []dns.RR + for msg := range envelope { + if msg.Error != nil { + // Fragile but more "user-friendly" error-handling + err := msg.Error.Error() + if err == "dns: bad xfr rcode: 9" { + err = "NOT AUTH (9)" + } + return nil, fmt.Errorf("[Error] AXFRDDNS: nameserver refused to transfer the zone: %s", msg) + } + rawRecords = append(rawRecords, msg.RR...) + } + return rawRecords, nil + +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (c *AxfrDdns) GetZoneRecords(domain string) (models.Records, error) { + + rawRecords, err := c.FetchZoneRecords(domain) + if err != nil { + return nil, err + } + + var foundDNSSecRecords *models.RecordConfig + foundRecords := models.Records{} + for _, rr := range rawRecords { + switch rr.(type) { + case *dns.RRSIG, + *dns.DNSKEY, + *dns.CDNSKEY, + *dns.CDS, + *dns.NSEC, + *dns.NSEC3, + *dns.NSEC3PARAM: + // Ignoring DNSSec RRs, but replacing it with a single + // "TXT" placeholder + if foundDNSSecRecords == nil { + foundDNSSecRecords = new(models.RecordConfig) + foundDNSSecRecords.Type = "TXT" + foundDNSSecRecords.SetLabel(dnssecDummyLabel, domain) + err = foundDNSSecRecords.SetTargetTXT(dnssecDummyTxt) + if err != nil { + return nil, err + } + } + continue + default: + rec := models.RRtoRC(rr, domain) + foundRecords = append(foundRecords, &rec) + } + } + + if len(foundRecords) >= 1 && foundRecords[len(foundRecords)-1].Type == "SOA" { + // The SOA is sent two times: as the first and the last record + // See section 2.2 of RFC5936 + foundRecords = foundRecords[:len(foundRecords)-1] + } + + if foundDNSSecRecords != nil { + foundRecords = append(foundRecords, foundDNSSecRecords) + } + + return foundRecords, nil + +} + +// GetDomainCorrections returns a list of corrections to update a domain. +func (c *AxfrDdns) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc.Punycode() + + foundRecords, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + if len(foundRecords) >= 1 && foundRecords[0].Type == "SOA" { + // Ignoring the SOA, others providers don't manage it either. + foundRecords = foundRecords[1:] + } + + hasDnssecRecords := false + if len(foundRecords) >= 1 { + last := foundRecords[len(foundRecords)-1] + if last.Type == "TXT" && + last.Name == dnssecDummyLabel && + len(last.TxtStrings) == 1 && + last.TxtStrings[0] == dnssecDummyTxt { + hasDnssecRecords = true + foundRecords = foundRecords[0:(len(foundRecords) - 1)] + } + } + + if dc.AutoDNSSEC && !hasDnssecRecords { + fmt.Printf("Warning: AUTODNSSEC is set, but no DNSKEY or RRSIG record was found in the AXFR answer!\n") + } else if !dc.AutoDNSSEC && hasDnssecRecords { + fmt.Printf("Warning: AUTODNSSEC is not set, but DNSKEY or RRSIG records were found in the AXFR answer!\n") + } + + // Normalize + models.PostProcessRecords(foundRecords) + + differ := diff.New(dc) + _, create, del, mod := differ.IncrementalDiff(foundRecords) + + buf := &bytes.Buffer{} + // Print a list of changes. Generate an actual change that is the zone + changes := false + for _, i := range create { + changes = true + fmt.Fprintln(buf, i) + } + for _, i := range del { + changes = true + fmt.Fprintln(buf, i) + } + for _, i := range mod { + changes = true + fmt.Fprintln(buf, i) + } + msg := fmt.Sprintf("DDNS UPDATES to '%s' (primary master: '%s'). Changes:\n%s", dc.Name, c.master, buf) + + corrections := []*models.Correction{} + if changes { + + corrections = append(corrections, + &models.Correction{ + Msg: msg, + F: func() error { + + // An RFC2136-compliant server must silently ignore an + // update that inserts a non-CNAME RRset when a CNAME RR + // with the same name is present in the zone (and + // vice-versa). Therefore we prefer to first remove records + // and then insert new ones. + // + // Compliant servers must also silently ignore an update + // that removes the last NS record of a zone. Therefore we + // don't want to remove all NS records before inserting a + // new one. For the particular case of NS record, we prefer + // to insert new records before ot remove old ones. + // + // This remarks does not apply for "modified" NS records, as + // updates are processed one-by-one. + // + // This provider does not allow modifying the TTL of an NS + // record in a zone that defines only one NS. That would + // would require removing the single NS record, before + // adding the new one. But who does that anyway? + + update := new(dns.Msg) + update.SetUpdate(dc.Name + ".") + update.Id = uint16(c.rand.Intn(math.MaxUint16)) + for _, c := range create { + if c.Desired.Type == "NS" { + update.Insert([]dns.RR{c.Desired.ToRR()}) + } + } + for _, c := range del { + update.Remove([]dns.RR{c.Existing.ToRR()}) + } + for _, c := range mod { + update.Remove([]dns.RR{c.Existing.ToRR()}) + update.Insert([]dns.RR{c.Desired.ToRR()}) + } + for _, c := range create { + if c.Desired.Type != "NS" { + update.Insert([]dns.RR{c.Desired.ToRR()}) + } + } + + client := new(dns.Client) + client.Timeout = dnsTimeout + if c.updateKey != nil { + client.TsigSecret = + map[string]string{c.updateKey.id: c.updateKey.secret} + update.SetTsig(c.updateKey.id, c.updateKey.algo, 300, time.Now().Unix()) + } + + msg, _, err := client.Exchange(update, c.master) + if err != nil { + return err + } + if msg.MsgHdr.Rcode != 0 { + return fmt.Errorf("[Error] AXFRDDNS: nameserver refused to update the zone: %s (%d)", + dns.RcodeToString[msg.MsgHdr.Rcode], + msg.MsgHdr.Rcode) + } + + return nil + }, + }) + } + return corrections, nil +}