Supporting multiple types in routes and inverting of routes (#97)

This commit is contained in:
Frank Olbricht
2020-11-01 07:03:58 -07:00
committed by GitHub
parent bf84956117
commit 58ead0bf21
9 changed files with 324 additions and 109 deletions

View File

@@ -113,10 +113,12 @@ type router struct {
}
type route struct {
Type string
Type string // Deprecated, use "Types" instead
Types []string
Class string
Name string
Source string
Invert bool // Invert the result of the match
Resolver string
}

View File

@@ -0,0 +1,33 @@
# Simple example showing how to to use a router containing multi-type routes as well as
# inverted routes. The first route only allows A, AAAA, MX record lookups past it. Then
# everything under .google.com will be sent to Google DoT while everything else goes to
# Cloudflare.
[listeners.local-udp]
address = "127.0.0.1:53"
protocol = "udp"
resolver = "router1"
[listeners.local-tcp]
address = "127.0.0.1:53"
protocol = "tcp"
resolver = "router1"
[routers.router1]
routes = [
{ invert = true, types = ["A", "AAAA", "MX"], resolver="static-nxdomain" }, # disallow anything that is not A, AAAA, or MX
{ name = '(^|\.)google\.com\.$', resolver="google-dot" },
{ resolver="cloudflare-dot" }, # default route
]
[groups.static-nxdomain]
type = "static-responder"
rcode = 3
[resolvers.cloudflare-dot]
address = "1.1.1.1:853"
protocol = "dot"
[resolvers.google-dot]
address = "8.8.8.8:853"
protocol = "dot"

View File

@@ -557,9 +557,16 @@ func instantiateRouter(id string, r router, resolvers map[string]rdns.Resolver)
if !ok {
return fmt.Errorf("router '%s' references non-existant resolver or group '%s'", id, route.Resolver)
}
if err := router.Add(route.Name, route.Class, route.Type, route.Source, resolver); err != nil {
types := route.Types
if route.Type != "" { // Support the deprecated "Type" by just adding it to "Types" if defined
types = append(types, route.Type)
}
r, err := rdns.NewRoute(route.Name, route.Class, types, route.Source, resolver)
if err != nil {
return fmt.Errorf("failure parsing routes for router '%s' : %s", id, err.Error())
}
r.Invert(route.Invert)
router.Add(r)
}
resolvers[id] = router
return nil

View File

@@ -938,9 +938,11 @@ Options:
A route has the following fields:
- `type` - If defined, only matches queries of this type, `A`, `AAAA`, `MX`, etc. Optional.
- `types` - List of types. If defined, only matches queries whose type is in this list. Optional.
- `class` - If defined, only matches queries of this class (`IN`, `CH`, `HS`, `NONE`, `ANY`). Optional.
- `name` - A regular expression that is applied to the query name. Note that dots in domain names need to be escaped. Optional.
- `source` - Network in CIDR notation. Used to route based on client IP. Optional.
- `invert` - Invert the result of the matching if set to `true`. Optional.
- `resolver` - The identifier of a resolver, group, or another router. Required.
Examples:
@@ -955,6 +957,16 @@ routes = [
]
```
Send all queries for A, AAAA, and MX records under `google.com` to a non-default resolver. Note the plural in `types` which expects a list.
```toml
[routers.router1]
routes = [
{ name = '(^|\.)google\.com\.$', types = ["A", "AAAA", "MX"], resolver="google-udp" },
{ resolver="cloudflare-dot" }, # default route
]
```
Route queries from a specific IP to a different resolver.
```toml
@@ -965,7 +977,21 @@ routes = [
]
```
Example config files: [split-dns.toml](../cmd/routedns/example-config/split-dns.toml), [block-split-cache.toml](../cmd/routedns/example-config/block-split-cache.toml), [family-browsing.toml](../cmd/routedns/example-config/family-browsing.toml),[walled-garden.toml](../cmd/routedns/example-config/walled-garden.toml)
Disallow all queries for records that are not of type A, AAAA, or MX by responding with NXDOMAIN.
```toml
[routers.router1]
routes = [
{ invert = true, types = ["A", "AAAA", "MX"], resolver="static-nxdomain" },
{ resolver="cloudflare-dot" },
]
[groups.static-nxdomain]
type = "static-responder"
rcode = 3
```
Example config files: [split-dns.toml](../cmd/routedns/example-config/split-dns.toml), [block-split-cache.toml](../cmd/routedns/example-config/block-split-cache.toml), [family-browsing.toml](../cmd/routedns/example-config/family-browsing.toml), [walled-garden.toml](../cmd/routedns/example-config/walled-garden.toml), [router.toml](../cmd/routedns/example-config/router.toml)
### Rate Limiter

View File

@@ -43,10 +43,11 @@ func Example_router() {
cloudflare, _ := rdns.NewDNSClient("cf-dns", "1.1.1.1:53", "udp", rdns.DNSClientOptions{})
// Build a router that will send all "*.cloudflare.com" to the cloudflare
// resolvber while everything else goes to the google resolver (default)
// resolver while everything else goes to the google resolver (default)
route1, _ := rdns.NewRoute(`\.cloudflare\.com\.$`, "", nil, "", cloudflare)
route2, _ := rdns.NewRoute("", "", nil, "", google)
r := rdns.NewRouter("my-router")
_ = r.Add(`\.cloudflare\.com\.$`, "", "", "", cloudflare)
_ = r.Add("", "", "", "", google)
r.Add(route1, route2)
// Build a query
q := new(dns.Msg)

136
route.go Normal file
View File

@@ -0,0 +1,136 @@
package rdns
import (
"errors"
"fmt"
"net"
"regexp"
"strings"
"github.com/miekg/dns"
)
type route struct {
types []uint16
class uint16
name *regexp.Regexp
source *net.IPNet
inverted bool // invert the matching behavior
resolver Resolver
}
// NewRoute initializes a route from string parameters.
func NewRoute(name, class string, types []string, source string, resolver Resolver) (*route, error) {
if resolver == nil {
return nil, errors.New("no resolver defined for route")
}
t, err := stringToType(types)
if err != nil {
return nil, err
}
c, err := stringToClass(class)
if err != nil {
return nil, err
}
re, err := regexp.Compile(name)
if err != nil {
return nil, err
}
var sNet *net.IPNet
if source != "" {
_, sNet, err = net.ParseCIDR(source)
if err != nil {
return nil, err
}
}
return &route{
types: t,
class: c,
name: re,
source: sNet,
resolver: resolver,
}, nil
}
func (r *route) match(q *dns.Msg, ci ClientInfo) bool {
question := q.Question[0]
if !r.matchType(question.Qtype) {
return r.inverted
}
if r.class != 0 && r.class != question.Qclass {
return r.inverted
}
if !r.name.MatchString(question.Name) {
return r.inverted
}
if r.source != nil && !r.source.Contains(ci.SourceIP) {
return r.inverted
}
return !r.inverted
}
func (r *route) Invert(value bool) {
r.inverted = value
}
func (r *route) String() string {
if r.isDefault() {
return fmt.Sprintf("default->%s", r.resolver)
}
return fmt.Sprintf("%s->%s", r.name, r.resolver)
}
func (r *route) isDefault() bool {
return r.class == 0 && len(r.types) == 0 && r.name.String() == ""
}
func (r *route) matchType(typ uint16) bool {
if len(r.types) == 0 {
return true
}
for _, t := range r.types {
if t == typ {
return true
}
}
return false
}
// Convert DNS type strings into the numerical type, for example "A" -> 1.
func stringToType(s []string) ([]uint16, error) {
if len(s) == 0 {
return nil, nil
}
var types []uint16
loop:
for _, typ := range s {
for k, v := range dns.TypeToString {
if v == strings.ToUpper(typ) {
types = append(types, k)
continue loop
}
}
return nil, fmt.Errorf("unknown type '%s'", s)
}
return types, nil
}
// Convert a DNS class string into is numerical form, for example "INET" -> 1.
func stringToClass(s string) (uint16, error) {
switch strings.ToUpper(s) {
case "":
return 0, nil
case "IN", "INET":
return 1, nil
case "CH":
return 3, nil
case "HS":
return 4, nil
case "NONE":
return 254, nil
case "ANY":
return 255, nil
default:
return 0, fmt.Errorf("unknown class '%s'", s)
}
}

93
route_test.go Normal file
View File

@@ -0,0 +1,93 @@
package rdns
import (
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
func TestRoute(t *testing.T) {
tests := []struct {
rName string
rType []string
rClass string
rInvert bool
qName string
qType uint16
qClass uint16
match bool
}{
{
rName: "\\.google\\.com$",
qType: dns.TypeA,
qName: "bla.google.com",
match: true,
},
{
rName: "\\.google\\.com$",
qType: dns.TypeA,
qName: "google.com",
match: false,
},
{
rType: []string{"MX"},
rName: "google\\.com$",
qType: dns.TypeA,
qName: "google.com",
match: false,
},
{
rType: []string{"MX", "A"},
rName: "google\\.com$",
qType: dns.TypeA,
qName: "google.com",
match: true,
},
{
rType: []string{"MX"},
rName: "google\\.com$",
qType: dns.TypeMX,
qName: "google.com",
match: true,
},
{
rType: []string{"MX"},
rName: "google\\.com$",
rInvert: true,
qType: dns.TypeMX,
qName: "google.com",
match: false,
},
{
rClass: "INET",
rType: []string{"A"},
rName: "google\\.com$",
qClass: dns.ClassANY,
qType: dns.TypeA,
qName: "google.com",
match: false,
},
{
rClass: "INET",
rType: []string{"A"},
rName: "google\\.com$",
qClass: dns.ClassINET,
qType: dns.TypeA,
qName: "google.com",
match: true,
},
}
for _, test := range tests {
r, err := NewRoute(test.rName, test.rClass, test.rType, "", &TestResolver{})
require.NoError(t, err)
r.Invert(test.rInvert)
q := new(dns.Msg)
q.Question = make([]dns.Question, 1)
q.Question[0] = dns.Question{Name: test.qName, Qtype: test.qType, Qclass: test.qClass}
match := r.match(q, ClientInfo{})
require.Equal(t, test.match, match)
}
}

View File

@@ -4,8 +4,6 @@ import (
"errors"
"expvar"
"fmt"
"net"
"regexp"
"github.com/miekg/dns"
)
@@ -55,16 +53,7 @@ func (r *Router) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) {
question := q.Question[0]
log := logger(r.id, q, ci)
for _, route := range r.routes {
if route.typ != 0 && route.typ != question.Qtype {
continue
}
if route.class != 0 && route.class != question.Qclass {
continue
}
if !route.name.MatchString(question.Name) {
continue
}
if route.source != nil && !route.source.Contains(ci.SourceIP) {
if !route.match(q, ci) {
continue
}
log.WithField("resolver", route.resolver.String()).Debug("routing query to resolver")
@@ -84,91 +73,11 @@ func (r *Router) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) {
// routes won't have any impact. Name is a regular expression that is
// applied to the name in the first question section of the DNS message.
// Source is an IP or network in CIDR format.
func (r *Router) Add(name, class, typ, source string, resolver Resolver) error {
t, err := stringToType(typ)
if err != nil {
return err
}
c, err := stringToClass(class)
if err != nil {
return err
}
re, err := regexp.Compile(name)
if err != nil {
return err
}
var sNet *net.IPNet
if source != "" {
_, sNet, err = net.ParseCIDR(source)
if err != nil {
return err
}
}
newRoute := &route{
typ: t,
class: c,
name: re,
source: sNet,
resolver: resolver,
}
r.routes = append(r.routes, newRoute)
func (r *Router) Add(routes ...*route) {
r.routes = append(r.routes, routes...)
r.metrics.available.Add(1)
return nil
}
func (r *Router) String() string {
return r.id
}
// Convert DNS type strings into the numberical type, for example "A" -> 1.
func stringToType(s string) (uint16, error) {
if s == "" {
return 0, nil
}
for k, v := range dns.TypeToString {
if v == s {
return k, nil
}
}
return 0, fmt.Errorf("unknown type '%s'", s)
}
// Convert a DNS class string into is numerical form, for example "INET" -> 1.
func stringToClass(s string) (uint16, error) {
switch s {
case "":
return 0, nil
case "IN":
return 1, nil
case "CH":
return 3, nil
case "HS":
return 4, nil
case "NONE":
return 254, nil
case "ANY":
return 255, nil
default:
return 0, fmt.Errorf("unknown class '%s'", s)
}
}
type route struct {
typ uint16
class uint16
name *regexp.Regexp
source *net.IPNet
resolver Resolver
}
func (r route) String() string {
if r.isDefault() {
return fmt.Sprintf("default->%s", r.resolver)
}
return fmt.Sprintf("%s:%s->%s", r.name, dns.Type(r.typ), r.resolver)
}
func (r route) isDefault() bool {
return r.typ == 0 && r.name.String() == ""
}

View File

@@ -14,9 +14,11 @@ func TestRouterType(t *testing.T) {
q := new(dns.Msg)
var ci ClientInfo
route1, _ := NewRoute("", "", []string{"MX"}, "", r1)
route2, _ := NewRoute("", "", nil, "", r2)
router := NewRouter("my-router")
_ = router.Add("", "", "MX", "", r1)
_ = router.Add("", "", "", "", r2)
router.Add(route1, route2)
// Not MX record, should go to r2
q.SetQuestion("acme.test.", dns.TypeA)
@@ -39,9 +41,11 @@ func TestRouterClass(t *testing.T) {
q := new(dns.Msg)
var ci ClientInfo
route1, _ := NewRoute("", "ANY", nil, "", r1)
route2, _ := NewRoute("", "", nil, "", r2)
router := NewRouter("my-router")
_ = router.Add("", "ANY", "", "", r1)
_ = router.Add("", "", "", "", r2)
router.Add(route1, route2)
// ClassINET question, should go to r2
q.SetQuestion("acme.test.", dns.TypeA)
@@ -50,7 +54,7 @@ func TestRouterClass(t *testing.T) {
require.Equal(t, 0, r1.HitCount())
require.Equal(t, 1, r2.HitCount())
// ClassAny shuold go to r1
// ClassAny should go to r1
q.Question = make([]dns.Question, 1)
q.Question[0] = dns.Question{"miek.nl.", dns.TypeMX, dns.ClassANY}
_, err = router.Resolve(q, ci)
@@ -65,9 +69,11 @@ func TestRouterName(t *testing.T) {
q := new(dns.Msg)
var ci ClientInfo
route1, _ := NewRoute(`\.acme\.test\.$`, "", nil, "", r1)
route2, _ := NewRoute("", "", nil, "", r2)
router := NewRouter("my-router")
_ = router.Add(`\.acme\.test\.$`, "", "", "", r1)
_ = router.Add("", "", "", "", r2)
router.Add(route1, route2)
// No match, should go to r2
q.SetQuestion("bla.test.", dns.TypeA)
@@ -90,9 +96,11 @@ func TestRouterSource(t *testing.T) {
q := new(dns.Msg)
q.SetQuestion("acme.test.", dns.TypeA)
route1, _ := NewRoute("", "", nil, "192.168.1.100/32", r1)
route2, _ := NewRoute("", "", nil, "", r2)
router := NewRouter("my-router")
_ = router.Add("", "", "", "192.168.1.100/32", r1)
_ = router.Add("", "", "", "", r2)
router.Add(route1, route2)
// No match, should go to r2
_, err := router.Resolve(q, ClientInfo{SourceIP: net.ParseIP("192.168.1.50")})