From 582e5c2bb15af80bed9ac9a72f654708c10318d2 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Fri, 7 Jul 2017 13:59:29 -0400 Subject: [PATCH] Make PTR more magical (#148) * Initial code and tests --- docs/_functions/domain/PTR.md | 40 +++++++++++++ docs/_functions/global/REV.md | 10 ++++ integrationTest/integration_test.go | 9 +++ integrationTest/zones/example.com.zone | 4 +- pkg/normalize/validate.go | 5 ++ pkg/transform/arpa.go | 2 +- pkg/transform/ptr.go | 69 +++++++++++++++++++++++ pkg/transform/ptr_test.go | 77 ++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/_functions/domain/PTR.md create mode 100644 pkg/transform/ptr.go create mode 100644 pkg/transform/ptr_test.go diff --git a/docs/_functions/domain/PTR.md b/docs/_functions/domain/PTR.md new file mode 100644 index 000000000..0b4c55f02 --- /dev/null +++ b/docs/_functions/domain/PTR.md @@ -0,0 +1,40 @@ +--- +name: PTR +parameters: + - name + - target + - modifiers... +--- + +PTR adds a PTR record to the domain. + +The name should be the relative label for the domain, or may be a FQDN that ends with `.`. + +* If the name is a valid IP address, DNSControl will *magically* replace it with a string that is appropriate for the domain. That is, if the domain ends with `in-addr.arpa` it will generate the IPv4-style reverse name; if the domain ends with `ipv6.arpa` it will generate the IPv6-style reverse name. DNSControl will truncate it as appropriate for the netmask. +* If the name ends with `in-addr.arpa.` or `ipv6.arpa.` (not the `.` at the end), DNSControl will truncate it as appropriate for the domain. If the FQDN does not fit within the domain, this will raise an error. + +Target should be a string representing the FQDN of a host. Like all FQDNs in DNSControl, it must end with a `.`. + +{% include startExample.html %} +{% highlight js %} +D(REV('1.2.3.0/24'), REGISTRAR, DnsProvider(BIND), + PTR('1', 'foo.example.com.'), + PTR('2', 'bar.example.com.'), + PTR('3', 'baz.example.com.'), + // If the first parameter is a valid IP address, DNSControl will generate the correct name: + PTR('1.2.3.10', 'ten.example.com.'), // '10' +); + +D(REV('2001:db8:302::/48'), REGISTRAR, DnsProvider(BIND), + PTR('1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', 'foo.example.com.'), // 2001:db8:302::1 + // If the first parameter is a valid IP address, DNSControl will generate the correct name: + PTR('2001:db8:302::2', 'two.example.com.'), // '2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0' + PTR('2001:db8:302::3', 'three.example.com.'), // '3.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0' +); + +{%endhighlight%} +{% include endExample.html %} + +In the future we plan on adding a flag to `A()` which will insert +the correct PTR() record if the approprate `.arpa` domain has been +defined. diff --git a/docs/_functions/global/REV.md b/docs/_functions/global/REV.md index ca83ac3ef..f4ad28c95 100644 --- a/docs/_functions/global/REV.md +++ b/docs/_functions/global/REV.md @@ -22,11 +22,21 @@ D(REV('1.2.3.0/24'), REGISTRAR, DnsProvider(BIND), PTR("1", 'foo.example.com.'), PTR("2", 'bar.example.com.'), PTR("3", 'baz.example.com.'), + // These take advantage of DNSControl's ability to generate the right name: + PTR("1.2.3.10", 'ten.example.com.'), ); D(REV('2001:db8:302::/48'), REGISTRAR, DnsProvider(BIND), PTR("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", 'foo.example.com.'), // 2001:db8:302::1 + // These take advantage of DNSControl's ability to generate the right name: + PTR("2001:db8:302::2", 'two.example.com.'), // 2.0.0. etc. etc. + PTR("2001:db8:302::3", 'three.example.com.'), // ); + {%endhighlight%} {% include endExample.html %} + +In the future we plan on adding a flag to `A()` which will insert +the correct PTR() record if the approprate `D(REV()` domain (i.e. `.arpa` domain) has been +defined. diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index ad6ba9f14..4e635d3cf 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -219,6 +219,10 @@ func mx(name string, prio uint16, target string) *rec { return r } +func ptr(name, target string) *rec { + return makeRec(name, target, "PTR") +} + func makeRec(name, target, typ string) *rec { return &rec{ Name: name, @@ -288,6 +292,11 @@ var tests = []*TestCase{ tc("Change to other name", mx("@", 5, "foo2.com."), mx("mail", 15, "foo3.com.")), tc("Change Priority", mx("@", 7, "foo2.com."), mx("mail", 15, "foo3.com.")), + //PTR + tc("Empty"), + tc("Create PTR record", ptr("4", "foo.com.")), + tc("Modify PTR record", ptr("4", "bar.com.")), + //ALIAS tc("EMPTY"), tc("ALIAS at root", alias("@", "foo.com.")).IfHasCapability(providers.CanUseAlias), diff --git a/integrationTest/zones/example.com.zone b/integrationTest/zones/example.com.zone index 0837d01d5..808c147dc 100644 --- a/integrationTest/zones/example.com.zone +++ b/integrationTest/zones/example.com.zone @@ -1,2 +1,4 @@ $TTL 300 -@ IN SOA DEFAULT_NOT_SET. DEFAULT_NOT_SET. 2017041717 3600 600 604800 1440 +@ IN SOA DEFAULT_NOT_SET. DEFAULT_NOT_SET. 2017070632 3600 600 604800 1440 + IN NS ns1.otherdomain.tld. + IN NS ns2.otherdomain.tld. diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go index 48b3ac93f..a47f2890f 100644 --- a/pkg/normalize/validate.go +++ b/pkg/normalize/validate.go @@ -273,6 +273,11 @@ func NormalizeAndValidateConfig(config *models.DNSConfig) (errs []error) { rec.Target = dnsutil.AddOrigin(rec.Target, domain.Name+".") } else if rec.Type == "A" || rec.Type == "AAAA" { rec.Target = net.ParseIP(rec.Target).String() + } else if rec.Type == "PTR" { + var err error + if rec.Name, err = transform.PtrNameMagic(rec.Name, domain.Name); err != nil { + errs = append(errs, err) + } } // Populate FQDN: rec.NameFQDN = dnsutil.AddOrigin(rec.Name, domain.Name) diff --git a/pkg/transform/arpa.go b/pkg/transform/arpa.go index ecc01a817..ca35377eb 100644 --- a/pkg/transform/arpa.go +++ b/pkg/transform/arpa.go @@ -32,7 +32,7 @@ func ReverseDomainName(cidr string) (string, error) { } toTrim = (total - bits) / 4 } else { - return "", fmt.Errorf("Invalid mask bit size: %d", total) + return "", fmt.Errorf("Address is not IPv4 or IPv6: %v", cidr) } parts := strings.SplitN(base, ".", toTrim+1) diff --git a/pkg/transform/ptr.go b/pkg/transform/ptr.go new file mode 100644 index 000000000..620d17855 --- /dev/null +++ b/pkg/transform/ptr.go @@ -0,0 +1,69 @@ +package transform + +import ( + "net" + "strings" + + "github.com/pkg/errors" +) + +func PtrNameMagic(name, domain string) (string, error) { + // Implement the PTR name magic. If the name is a properly formed + // IPv4 or IPv6 address, we replace it with the right string (i.e + // reverse it and truncate it). + + // If the name is already in-addr.arpa or ipv6.arpa, + // make sure the domain matches. + if strings.HasSuffix(name, ".in-addr.arpa.") || strings.HasSuffix(name, ".ip6.arpa.") { + if strings.HasSuffix(name, "."+domain+".") { + return strings.TrimSuffix(name, "."+domain+"."), nil + } else { + return name, errors.Errorf("PTR record %v in wrong domain (%v)", name, domain) + } + } + + // If the domain is .arpa, we do magic. + if strings.HasSuffix(domain, ".in-addr.arpa") { + return ipv4magic(name, domain) + } else if strings.HasSuffix(domain, ".ip6.arpa") { + return ipv6magic(name, domain) + } else { + return name, nil + } +} + +func ipv4magic(name, domain string) (string, error) { + // Not a valid IPv4 address. Leave it alone. + ip := net.ParseIP(name) + if ip == nil || ip.To4() == nil || !strings.Contains(name, ".") { + return name, nil + } + + // Reverse it. + rev, err := ReverseDomainName(ip.String() + "/32") + if err != nil { + return name, err + } + if !strings.HasSuffix(rev, "."+domain) { + err = errors.Errorf("ERROR: PTR record %v in wrong IPv4 domain (%v)", name, domain) + } + return strings.TrimSuffix(rev, "."+domain), err +} + +func ipv6magic(name, domain string) (string, error) { + // Not a valid IPv6 address. Leave it alone. + ip := net.ParseIP(name) + if ip == nil || len(ip) != 16 || !strings.Contains(name, ":") { + return name, nil + } + + // Reverse it. + rev, err := ReverseDomainName(ip.String() + "/128") + if err != nil { + return name, err + } + if !strings.HasSuffix(rev, "."+domain) { + err = errors.Errorf("ERROR: PTR record %v in wrong IPv6 domain (%v)", name, domain) + } + return strings.TrimSuffix(rev, "."+domain), err +} diff --git a/pkg/transform/ptr_test.go b/pkg/transform/ptr_test.go new file mode 100644 index 000000000..e255e1e2f --- /dev/null +++ b/pkg/transform/ptr_test.go @@ -0,0 +1,77 @@ +package transform + +import ( + "fmt" + "testing" +) + +// ptrmagic(name, domain string, al int) (string, error) + +func TestPtrMagic(t *testing.T) { + tests := []struct { + name string + domain string + output string + fail bool + }{ + // Magic IPv4: + {"1.2.3.4", "3.2.1.in-addr.arpa", "4", false}, + {"1.2.3.4", "2.1.in-addr.arpa", "4.3", false}, + {"1.2.3.4", "1.in-addr.arpa", "4.3.2", false}, + + // No magic IPv4: + {"1", "2.3.4.in-addr.arpa", "1", false}, + {"1.2", "3.4.in-addr.arpa", "1.2", false}, + {"1.2.3", "4.in-addr.arpa", "1.2.3", false}, + {"1.2.3.4", "in-addr.arpa", "1.2.3.4", false}, // Not supported, but it works. + + // Magic IPv6: + {"1", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1", false}, + {"1.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0", false}, + {"1.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0", false}, + {"1.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0", false}, + {"1.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0", false}, + {"1.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0.0.0.0", false}, + {"1.0.0.0.0.0.0.0.0.0.0.0.0.0", "0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0.0.0.0.0", false}, + + // If it doesn't end in .arpa, the magic is disabled: + {"1.2.3.4", "example.com", "1.2.3.4", false}, + {"1", "example.com", "1", false}, + {"1.0.0.0", "example.com", "1.0.0.0", false}, + {"1.0.0.0.0.0.0.0", "example.com", "1.0.0.0.0.0.0.0", false}, + + // User manually reversed addresses: + {"1.1.1.1.in-addr.arpa.", "1.1.in-addr.arpa", "1.1", false}, + {"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + "0.2.ip6.arpa", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0", false}, + + // Error cases: + {"1.1.1.1.in-addr.arpa.", "2.2.in-addr.arpa", "", true}, + {"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", "9.9.ip6.arpa", "", true}, + {"3.3.3.3", "4.4.in-addr.arpa", "", true}, + {"2001:db8::1", "9.9.ip6.arpa", "", true}, + + // These should be errors but we don't check for them at this time: + //{"blurg", "3.4.in-addr.arpa", "blurg", true}, + //{"1", "3.4.in-addr.arpa", "1", true}, + } + for _, tst := range tests { + t.Run(fmt.Sprintf("%s %s", tst.name, tst.domain), func(t *testing.T) { + o, errs := PtrNameMagic(tst.name, tst.domain) + if errs != nil && !tst.fail { + t.Errorf("Got error but expected none (%v)", errs) + } else if errs == nil && tst.fail { + t.Errorf("Expected error but got none (%v)", o) + } else if errs == nil && o != tst.output { + t.Errorf("Got (%v) expected (%v)", o, tst.output) + } + }) + } +}