diff --git a/core/coredns.go b/core/coredns.go index d7043c804..b621f2c27 100644 --- a/core/coredns.go +++ b/core/coredns.go @@ -25,6 +25,7 @@ import ( _ "github.com/miekg/coredns/middleware/metrics" _ "github.com/miekg/coredns/middleware/pprof" _ "github.com/miekg/coredns/middleware/proxy" + _ "github.com/miekg/coredns/middleware/reverse" _ "github.com/miekg/coredns/middleware/rewrite" _ "github.com/miekg/coredns/middleware/root" _ "github.com/miekg/coredns/middleware/secondary" diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index a55461854..ff6722998 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -29,6 +29,7 @@ var directives = []string{ "secondary", "etcd", "kubernetes", + "reverse", "proxy", "httpprox", "whoami", diff --git a/middleware.cfg b/middleware.cfg index 71434ffd8..0a9527e5c 100644 --- a/middleware.cfg +++ b/middleware.cfg @@ -37,6 +37,7 @@ 160:secondary: 170:etcd: 180:kubernetes: +185:reverse: 190:proxy: 200:httpprox: 210:whoami: diff --git a/middleware/reverse/README.md b/middleware/reverse/README.md new file mode 100644 index 000000000..7437a3079 --- /dev/null +++ b/middleware/reverse/README.md @@ -0,0 +1,97 @@ +# reverse + +The *reverse* middleware allows CoreDNS to respond dynamic to an PTR request and the related A/AAAA request. + +## Syntax + +~~~ +reverse NETWORK.. { + hostname TEMPLATE + [ttl TTL] + [fallthrough] +~~~ + +* **NETWORK** one or more CIDR formatted networks to respond on. +* `hostname` inject the ip and zone to an template for the hostname. Defaults to "ip-{ip}.{zone[0]}". See below for template. +* `ttl` defaults to 60 +* `fallthrough` If zone matches and no record can be generated, pass request to the next middleware. + +### Template Syntax +The template for the hostname is used for generating the PTR for an reverse lookup and matching the forward lookup back to an ip. + +#### `{ip}` +This symbol is **required** to work. +V4 network replaces the "." with an "-". 10.1.1.1 results in "10-1-1-1" +V6 network removes the ":" and fills the zeros. "ffff::ffff" results in "ffff000000000000000000000000ffff" + +#### `{zone[i]}` +This symbol is **optional** to use and can be replaced by a fix zone string. +The zone will be matched by the configured listener on the server block key. +`i` needs to be replaced to the index of the configured listener zones, starting with 0. + +`arpa.:53 domain.com.:8053` will resolve `zone{0}` to `arpa.` and `zone{1}` to `domain.com.` + +## Examples + +~~~ +# Serve on port 53 +# match arpa. and compute.internal. to resolv reverse and forward lookup +.arpa.:53 compute.internal.:53 { + # proxy unmatched requests + proxy . 8.8.8.8 + + # answer requests for IPs in this networks + # PTR 1.0.32.10.in-addr.arpa. 3600 ip-10-0-32-1.compute.internal. + # A ip-10-0-32-1.compute.internal. 3600 10.0.32.1 + # v6 is also possible + # PTR 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.d.f.ip6.arpa. 3600 ip-fd010000000000000000000000000001.compute.internal. + # AAAA ip-fd010000000000000000000000000001.compute.internal. 3600 fd01::1 + reverse 10.32.0.0/16 fd01::/16 { + # template of the ip injection to hostname, zone resolved to compute.internal. + hostname ip-{ip}.{zone[1]} + + # set time-to-live of the RR + ttl 3600 + + # forward unanswered or unmatched requests to proxy + # without this flag, requesting A/AAAA records on compute.internal. will end here + fallthrough + } + + # cache with ttl timeout + cache +} +~~~ + + +~~~ +# Serve on port 53 +# listen only on the specific network +32.10.in-addr.arpa.arpa.:53 arpa.company.org.:53 { + + reverse 10.32.0.0/16 { + # template of the ip injection to hostname, zone resolved to arpa.company.org. + hostname "ip-{ip}.v4.{zone[1]}" + + # set time-to-live of the RR + ttl 3600 + + # fallthrough is not required, v4.arpa.company.org. will be only answered here + } + + # cidr closer to the ip wins, so we can overwrite the "default" + reverse 10.32.2.0/24 { + # its also possible to set fix domain suffix + hostname ip-{ip}.fix.arpa.company.org. + + # set time-to-live of the RR + ttl 3600 + } + + # cache with ttl timeout + cache +} +~~~ + + + diff --git a/middleware/reverse/network.go b/middleware/reverse/network.go new file mode 100644 index 000000000..7f408326b --- /dev/null +++ b/middleware/reverse/network.go @@ -0,0 +1,123 @@ +package reverse + +import ( + "net" + "regexp" + "bytes" + "strings" +) + +type network struct { + IPnet *net.IPNet + Zone string // forward lookup zone + Template string + TTL uint32 + RegexMatchIP *regexp.Regexp + Fallthrough bool +} + +const hexDigit = "0123456789abcdef" +const templateNameIP = "{ip}" +const regexMatchV4 = "((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\-){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))" +const regexMatchV6 = "([0-9a-fA-F]{32})" + +// For forward lookup +// converts the hostname back to an ip, based on the template +// returns nil if there is no ip found +func (network *network) hostnameToIP(rname string) net.IP { + var matchedIP net.IP + + // use precompiled regex by setup + match := network.RegexMatchIP.FindStringSubmatch(rname) + // regex did not matched + if (len(match) != 2) { + return nil + } + + if network.IPnet.IP.To4() != nil { + matchedIP = net.ParseIP(strings.Replace(match[1], "-", ".", 4)) + } else { + var buf bytes.Buffer + // convert back to an valid ipv6 string with colons + for i := 0; i < 8 * 4; i += 4 { + buf.WriteString(match[1][i:i + 4]) + if (i < 28) { + buf.WriteString(":") + } + } + matchedIP = net.ParseIP(buf.String()) + } + + // No valid ip or it does not belong to this network + if matchedIP == nil || !network.IPnet.Contains(matchedIP) { + return nil + } + + return matchedIP +} + +// For reverse lookup +// Converts an Ip to an dns compatible hostname and injects it into the template.domain +func (network *network) ipToHostname(ip net.IP) string { + var name string + + ipv4 := ip.To4() + if ipv4 != nil { + // replace . to - + name = uitoa(ipv4[0]) + "-" + + uitoa(ipv4[1]) + "-" + + uitoa(ipv4[2]) + "-" + + uitoa(ipv4[3]) + } else { + // assume v6 + // ensure zeros are present in string + buf := make([]byte, 0, len(ip) * 4) + for i := 0; i < len(ip); i++ { + v := ip[i] + buf = append(buf, hexDigit[v >> 4]) + buf = append(buf, hexDigit[v & 0xF]) + } + name = string(buf) + } + // inject the converted ip into the fqdn template + return strings.Replace(network.Template, templateNameIP, name, 1) +} + +// just the same from net.ip package, but with uint8 +func uitoa(val uint8) string { + if val == 0 { + // avoid string allocation + return "0" + } + var buf [20]byte // big enough for 64bit value base 10 + i := len(buf) - 1 + for val >= 10 { + q := val / 10 + buf[i] = byte('0' + val - q * 10) + i-- + val = q + } + // val < 10 + buf[i] = byte('0' + val) + return string(buf[i:]) +} + +type networks []network + +// implements the sort interface +func (slice networks) Len() int { + return len(slice) +} + +// implements the sort interface +// cidr closer to the ip wins (by netmask) +func (slice networks) Less(i, j int) bool { + isize, _ := slice[i].IPnet.Mask.Size() + jsize, _ := slice[j].IPnet.Mask.Size() + return isize > jsize +} + +// implements the sort interface +func (slice networks) Swap(i, j int) { + slice[i], slice[j] = slice[j], slice[i] +} \ No newline at end of file diff --git a/middleware/reverse/network_test.go b/middleware/reverse/network_test.go new file mode 100644 index 000000000..ee4db1206 --- /dev/null +++ b/middleware/reverse/network_test.go @@ -0,0 +1,135 @@ +package reverse + +import ( + "testing" + "net" + "reflect" + "regexp" +) + +// Test converting from hostname to IP and back again to hostname +func TestNetworkConversion(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP4, _ := regexp.Compile("^dns-" + regexMatchV4 + "\\.domain\\.internal\\.$") + regexIP6, _ := regexp.Compile("^dns-" + regexMatchV6 + "\\.domain\\.internal\\.$") + + tests := []struct { + network network + resultHost string + resultIP net.IP + }{ + { + network{ + IPnet:net4, + Template: "dns-{ip}.domain.internal.", + RegexMatchIP:regexIP4, + }, + "dns-10-1-1-23.domain.internal.", + net.ParseIP("10.1.1.23"), + }, + { + network{ + IPnet:net6, + Template: "dns-{ip}.domain.internal.", + RegexMatchIP:regexIP6, + }, + "dns-fd01000000000000000000000000a32f.domain.internal.", + net.ParseIP("fd01::a32f"), + }, + } + + for i, test := range tests { + resultIP := test.network.hostnameToIP(test.resultHost) + if !reflect.DeepEqual(test.resultIP, resultIP) { + t.Fatalf("Test %d expected %v, got %v", i, test.resultIP, resultIP) + } + + resultHost := test.network.ipToHostname(test.resultIP) + if !reflect.DeepEqual(test.resultHost, resultHost) { + t.Fatalf("Test %d expected %v, got %v", i, test.resultHost, resultHost) + } + } +} + +func TestNetworkHostnameToIP(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP4, _ := regexp.Compile("^dns-" + regexMatchV4 + "\\.domain\\.internal\\.$") + regexIP6, _ := regexp.Compile("^dns-" + regexMatchV6 + "\\.domain\\.internal\\.$") + + // Test regex does NOT match + // All this test should return nil + testsNil := []struct { + network network + hostname string + }{ + { + network{ + IPnet:net4, + RegexMatchIP:regexIP4, + }, + // domain does not match + "dns-10-1-1-23.domain.internals.", + }, + { + network{ + IPnet:net4, + RegexMatchIP:regexIP4, + }, + // IP does match / contain in subnet + "dns-200-1-1-23.domain.internals.", + }, + { + network{ + IPnet:net4, + RegexMatchIP:regexIP4, + }, + // template does not match + "dns-10-1-1-23-x.domain.internal.", + }, + { + network{ + IPnet:net4, + RegexMatchIP:regexIP4, + }, + // template does not match + "IP-dns-10-1-1-23.domain.internal.", + }, + { + network{ + IPnet:net6, + RegexMatchIP:regexIP6, + }, + // template does not match + "dnx-fd01000000000000000000000000a32f.domain.internal.", + }, + { + network{ + IPnet:net6, + RegexMatchIP:regexIP6, + }, + // no valid v6 (missing one 0, only 31 chars) + "dns-fd0100000000000000000000000a32f.domain.internal.", + }, + { + network{ + IPnet:net6, + RegexMatchIP:regexIP6, + }, + // IP does match / contain in subnet + "dns-ed01000000000000000000000000a32f.domain.internal.", + }, + } + + for i, test := range testsNil { + resultIP := test.network.hostnameToIP(test.hostname) + if resultIP != nil { + t.Fatalf("Test %d expected nil, got %v", i, resultIP) + } + } +} diff --git a/middleware/reverse/reverse.go b/middleware/reverse/reverse.go new file mode 100644 index 000000000..9cf7d751a --- /dev/null +++ b/middleware/reverse/reverse.go @@ -0,0 +1,114 @@ +package reverse + +import ( + "net" + + "github.com/miekg/coredns/request" + "github.com/miekg/coredns/middleware" + "github.com/miekg/coredns/middleware/pkg/dnsutil" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Reverse provides dynamic reverse dns and the related forward rr +type Reverse struct { + Next middleware.Handler + Networks networks +} + +// ServeDNS implements the middleware.Handler interface. +func (reverse Reverse) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + var rr dns.RR + nextHandler := true + + state := request.Request{W: w, Req: r} + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + + switch state.QType(){ + case dns.TypePTR: + address := dnsutil.ExtractAddressFromReverse(state.Name()) + + if address == "" { + // Not an reverse lookup, but can still be an pointer for an domain + break + } + + ip := net.ParseIP(address) + // loop through the configured networks + for _, n := range reverse.Networks { + if (n.IPnet.Contains(ip)) { + nextHandler = n.Fallthrough + rr = &dns.PTR{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: n.TTL}, + Ptr: n.ipToHostname(ip), + } + break + } + } + + case dns.TypeA: + for _, n := range reverse.Networks { + if dns.IsSubDomain(n.Zone, state.Name()) { + nextHandler = n.Fallthrough + + // skip if requesting an v4 address and network is not v4 + if n.IPnet.IP.To4() == nil { + continue + } + + result := n.hostnameToIP(state.Name()) + if result != nil { + rr = &dns.A{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: n.TTL}, + A: result, + } + break + } + } + } + + case dns.TypeAAAA: + for _, n := range reverse.Networks { + if dns.IsSubDomain(n.Zone, state.Name()) { + nextHandler = n.Fallthrough + + // Do not use To16 which tries to make v4 in v6 + if n.IPnet.IP.To4() != nil { + continue + } + + result := n.hostnameToIP(state.Name()) + if result != nil { + rr = &dns.AAAA{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: n.TTL}, + AAAA: result, + } + break + } + } + } + + } + + if rr == nil { + if reverse.Next == nil || !nextHandler { + // could not resolv + w.WriteMsg(m) + return dns.RcodeNameError, nil + } + return reverse.Next.ServeDNS(ctx, w, r) + } + + m.Answer = append(m.Answer, rr) + state.SizeAndDo(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// Name implements the Handler interface. +func (reverse Reverse) Name() string { + return "reverse" +} diff --git a/middleware/reverse/setup.go b/middleware/reverse/setup.go new file mode 100644 index 000000000..024f7a39e --- /dev/null +++ b/middleware/reverse/setup.go @@ -0,0 +1,146 @@ +package reverse + +import ( + "net" + "sort" + "strings" + "strconv" + "regexp" + + "github.com/miekg/coredns/core/dnsserver" + "github.com/miekg/coredns/middleware" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("reverse", caddy.Plugin{ + ServerType: "dns", + Action: setupReverse, + }) +} + +func setupReverse(c *caddy.Controller) error { + networks, err := reverseParse(c) + if err != nil { + return middleware.Error("reverse", err) + } + + dnsserver.GetConfig(c).AddMiddleware(func(next middleware.Handler) middleware.Handler { + return Reverse{Next: next, Networks:networks} + }) + + return nil +} + +func reverseParse(c *caddy.Controller) (networks, error) { + var err error + + // normalize zones, validation is almost done by dnsserver + zones := make([]string, len(c.ServerBlockKeys)) + for i, str := range c.ServerBlockKeys { + host, _, _ := net.SplitHostPort(str) + zones[i] = strings.ToLower(host) + } + + networks := networks{} + for c.Next() { + if c.Val() == "reverse" { + + var cidrs []*net.IPNet + + // parse all networks + for _, cidr := range c.RemainingArgs() { + if cidr == "{" { + break + } + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, c.Errf("%v needs to be an CIDR formatted Network\n", cidr) + } + cidrs = append(cidrs, ipnet) + } + if len(cidrs) == 0 { + return nil, c.ArgErr() + } + + // set defaults + var ( + template = "ip-" + templateNameIP + ".{zone[0]}" + ttl = 60 + fall = false + ) + for c.NextBlock() { + switch c.Val() { + case "hostname": + if !c.NextArg() { + return nil, c.ArgErr() + } + template = c.Val() + + case "ttl": + if !c.NextArg() { + return nil, c.ArgErr() + } + ttl, err = strconv.Atoi(c.Val()) + if err != nil { + return nil, err + } + + case "fallthrough": + fall = true + + default: + return nil, c.ArgErr() + } + } + + // prepare template + // replace {zone[index]} by the listen zone/domain of this config block + for i, zone := range zones { + template = strings.Replace(template, "{zone[" + string(i + 48) + "]}", zone, 1) + } + if !strings.HasSuffix(template, ".") { + template += "." + } + + // extract zone from template + templateZone := strings.SplitAfterN(template, ".", 2) + if len(templateZone) != 2 || templateZone[1] == "" { + return nil, c.Errf("Cannot find domain in template '%v'", template) + } + + // Create for each configured network in this stanza + for _, ipnet := range cidrs { + // precompile regex for hostname to ip matching + regexIP := regexMatchV4 + if ipnet.IP.To4() == nil { + regexIP = regexMatchV6 + } + regex, err := regexp.Compile( + "^" + strings.Replace( // inject ip regex into template + regexp.QuoteMeta(template), // escape dots + regexp.QuoteMeta(templateNameIP), + regexIP, + 1, ) + "$") + if err != nil { + // invalid regex + return nil, err + } + + networks = append(networks, network{ + IPnet: ipnet, + Zone: templateZone[1], + Template: template, + RegexMatchIP: regex, + TTL: uint32(ttl), + Fallthrough: fall, + }) + } + } + } + + // sort by cidr + sort.Sort(networks) + return networks, nil +} diff --git a/middleware/reverse/setup_test.go b/middleware/reverse/setup_test.go new file mode 100644 index 000000000..4ec9e8a55 --- /dev/null +++ b/middleware/reverse/setup_test.go @@ -0,0 +1,186 @@ +package reverse + +import ( + "testing" + "net" + "reflect" + "regexp" + + "github.com/mholt/caddy" +) + +func TestSetupParse(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP6, _ := regexp.Compile("^ip-" + regexMatchV6 + "\\.domain\\.com\\.$") + regexIpv4dynamic, _ := regexp.Compile("^dynamic-" + regexMatchV4 + "-intern\\.dynamic\\.domain\\.com\\.$") + regexIpv6dynamic, _ := regexp.Compile("^dynamic-" + regexMatchV6 + "-intern\\.dynamic\\.domain\\.com\\.$") + regexIpv4vpndynamic, _ := regexp.Compile("^dynamic-" + regexMatchV4 + "-vpn\\.dynamic\\.domain\\.com\\.$") + + serverBlockKeys := []string{"domain.com.:8053", "dynamic.domain.com.:8053" } + + tests := []struct { + inputFileRules string + shouldErr bool + networks networks + }{ + { + // with defaults + `reverse fd01::/64`, + false, + networks{network{ + IPnet: net6, + Template: "ip-{ip}.domain.com.", + Zone: "domain.com.", + TTL: 60, + RegexMatchIP: regexIP6, + Fallthrough: false, + }}, + }, + { + `reverse`, + true, + networks{}, + }, + { + //no cidr + `reverse 10.1.1.1`, + true, + networks{}, + }, + { + //no cidr + `reverse 10.1.1.0/16 fd00::`, + true, + networks{}, + }, + { + // invalid key + `reverse 10.1.1.0/24 { + notavailable + }`, + true, + networks{}, + }, + { + // no domain suffix + `reverse 10.1.1.0/24 { + hostname ip-{ip}. + }`, + true, + networks{}, + }, + { + // hostname requires an second arg + `reverse 10.1.1.0/24 { + hostname + }`, + true, + networks{}, + }, + { + // template breaks regex compile + `reverse 10.1.1.0/24 { + hostname ip-{[-x + }`, + true, + networks{}, + }, + { + // ttl requires an (u)int + `reverse 10.1.1.0/24 { + ttl string + }`, + true, + networks{}, + }, + { + `reverse fd01::/64 { + hostname dynamic-{ip}-intern.{zone[1]} + ttl 50 + } + reverse 10.1.1.0/24 { + hostname dynamic-{ip}-vpn.{zone[1]} + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP:regexIpv6dynamic, + Fallthrough: false, + }, network{ + IPnet: net4, + Template: "dynamic-{ip}-vpn.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 60, + RegexMatchIP: regexIpv4vpndynamic, + Fallthrough:true, + }}, + }, + { + // multiple networks in one stanza + `reverse fd01::/64 10.1.1.0/24 { + hostname dynamic-{ip}-intern.{zone[1]} + ttl 50 + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP:regexIpv6dynamic, + Fallthrough: true, + }, network{ + IPnet: net4, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP: regexIpv4dynamic, + Fallthrough: true, + }}, + }, + { + // fix domain in template + `reverse fd01::/64 { + hostname dynamic-{ip}-intern.dynamic.domain.com + ttl 300 + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 300, + RegexMatchIP:regexIpv6dynamic, + Fallthrough: true, + }}, + }, + + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + c.ServerBlockKeys = serverBlockKeys + networks, err := reverseParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } else { + for j, n := range networks { + reflect.DeepEqual(test.networks[j], n) + if !reflect.DeepEqual(test.networks[j], n) { + t.Fatalf("Test %d/%d expected %v, got %v", i, j, test.networks[j], n) + } + } + } + } +} \ No newline at end of file