From 8014a4d305dd3995144059c33a2234b98de44664 Mon Sep 17 00:00:00 2001 From: Frank Olbricht Date: Sat, 19 Jun 2021 13:53:08 -0600 Subject: [PATCH] Support routing by time and weekday (#167) * Support routing by time and weekday * Add note about impossible routes --- README.md | 4 +- cmd/routedns/config.go | 16 +-- cmd/routedns/example-config/router-time.toml | 20 ++++ cmd/routedns/main.go | 2 +- doc/configuration.md | 17 ++- example_test.go | 4 +- go.mod | 2 +- route.go | 107 ++++++++++++++++++- route_test.go | 2 +- router_test.go | 16 +-- 10 files changed, 165 insertions(+), 25 deletions(-) create mode 100644 cmd/routedns/example-config/router-time.toml diff --git a/README.md b/README.md index e004e40..c2103c7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Features: - Support for plain DNS, UDP and TCP for incoming and outgoing requests - Connection reuse and pipelining queries for efficiency - Multiple failover and load-balancing algorithms, caching, in-line query/response modification and translation (full list [here](doc/configuration.md)) -- Routing of queries based on query type, query name, or client IP +- Routing of queries based on query type, class, query name, time, or client IP - EDNS0 query and response padding ([RFC7830](https://tools.ietf.org/html/rfc7830), [RFC8467](https://tools.ietf.org/html/rfc8467)) - EDNS0 Client Subnet (ECS) manipulation ([RFC7871](https://tools.ietf.org/html/rfc7871)) - Support for bootstrap addresses to avoid the initial service name lookup @@ -24,7 +24,7 @@ Features: ## Installation -Install [Go](https://golang.org/dl) version 1.13+ then run the following to build the binary. It'll be placed in $HOME/go/bin by default: +Install [Go](https://golang.org/dl) version 1.15+ then run the following to build the binary. It'll be placed in $HOME/go/bin by default: ```text GO111MODULE=on go get -v github.com/folbricht/routedns/cmd/routedns diff --git a/cmd/routedns/config.go b/cmd/routedns/config.go index 773b0b9..040807a 100644 --- a/cmd/routedns/config.go +++ b/cmd/routedns/config.go @@ -123,13 +123,15 @@ type router struct { } type route struct { - Type string // Deprecated, use "Types" instead - Types []string - Class string - Name string - Source string - Invert bool // Invert the result of the match - Resolver string + Type string // Deprecated, use "Types" instead + Types []string + Class string + Name string + Source string + Weekdays []string // 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' + After, Before string // Hour:Minute in 24h format, for example "14:30" + Invert bool // Invert the result of the match + Resolver string } // LoadConfig reads a config file and returns the decoded structure. diff --git a/cmd/routedns/example-config/router-time.toml b/cmd/routedns/example-config/router-time.toml new file mode 100644 index 0000000..b231c6d --- /dev/null +++ b/cmd/routedns/example-config/router-time.toml @@ -0,0 +1,20 @@ +# Routing traffic based on time of day and a weekday. + +[listeners.local-udp] +address = "127.0.0.1:53" +protocol = "udp" +resolver = "router1" + +[routers.router1] +routes = [ + { name = '(^|\.)twitter\.com\.$', weekdays = ["sat", "sun"], after = "09:00", before = "17:00", resolver="static-nxdomain" }, # No Twitter on weekends from 9am-5pm! + { resolver="cloudflare-dot" }, # default route +] + +[groups.static-nxdomain] +type = "static-responder" +rcode = 3 + +[resolvers.cloudflare-dot] +address = "1.1.1.1:853" +protocol = "dot" diff --git a/cmd/routedns/main.go b/cmd/routedns/main.go index 7feec00..8b377d9 100644 --- a/cmd/routedns/main.go +++ b/cmd/routedns/main.go @@ -603,7 +603,7 @@ func instantiateRouter(id string, r router, resolvers map[string]rdns.Resolver) 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) + r, err := rdns.NewRoute(route.Name, route.Class, types, route.Weekdays, route.Before, route.After, route.Source, resolver) if err != nil { return fmt.Errorf("failure parsing routes for router '%s' : %s", id, err.Error()) } diff --git a/doc/configuration.md b/doc/configuration.md index 5398f17..eff9447 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -991,7 +991,7 @@ Example config files: [response-collapse.toml](../cmd/routedns/example-config/re ### Router -Routers are used to direct queries to specific upstream resolvers, modifier, or to other routers based on the query type, name, or client information. Each router contains at least one route. Routes are are evaluated in the order they are defined and the first match will be used. Routes that match on the query name are regular expressions. Typically the last route should not have a class, type or name, making it the default route. +Routers are used to direct queries to specific upstream resolvers, modifiers, or to other routers based on the query type, name, time of day, or client information. Each router contains at least one route. Routes are are evaluated in the order they are defined and the first match will be used. Routes that match on the query name are regular expressions. Typically the last route should not have a class, type or name, making it the default route. #### Configuration @@ -1008,6 +1008,9 @@ A route has the following fields: - `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. +- `weekdays` - List of weekdays this route should match on. Possible values: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. Uses local time, not UTC. +- `after` - Time of day in the format HH:mm after which the rule matches. Uses 24h format. For example `09:00`. Note that together with the `before` parameter it is possible to accidentally write routes that can never trigger. For example `after=12:00 before=11:00` can never match as both conditions have to be met for the route to be used. +- `before` - Time of day in the format HH:mm before which the rule matches. Uses 24h format. For example `17:30`. - `invert` - Invert the result of the matching if set to `true`. Optional. - `resolver` - The identifier of a resolver, group, or another router. Required. @@ -1057,7 +1060,17 @@ 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) +Use a different upstream resolver on weekends between 9am and 5pm. + +```toml +[routers.router1] +routes = [ + { weekdays = ["sat", "sun"], after = "09:00", before = "17:00", resolver="google-dot" }, + { resolver="cloudflare-dot" }, +] +``` + +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), [router-time.toml](../cmd/routedns/example-config/router-time.toml) ### Rate Limiter diff --git a/example_test.go b/example_test.go index 4102507..95276ff 100644 --- a/example_test.go +++ b/example_test.go @@ -44,8 +44,8 @@ func Example_router() { // Build a router that will send all "*.cloudflare.com" to the cloudflare // resolver while everything else goes to the google resolver (default) - route1, _ := rdns.NewRoute(`\.cloudflare\.com\.$`, "", nil, "", cloudflare) - route2, _ := rdns.NewRoute("", "", nil, "", google) + route1, _ := rdns.NewRoute(`\.cloudflare\.com\.$`, "", nil, nil, "", "", "", cloudflare) + route2, _ := rdns.NewRoute("", "", nil, nil, "", "", "", google) r := rdns.NewRouter("my-router") r.Add(route1, route2) diff --git a/go.mod b/go.mod index 2dd37be..d2ffd57 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/folbricht/routedns -go 1.12 +go 1.15 require ( github.com/BurntSushi/toml v0.3.1 diff --git a/route.go b/route.go index ef8fb92..4e99336 100644 --- a/route.go +++ b/route.go @@ -5,7 +5,9 @@ import ( "fmt" "net" "regexp" + "strconv" "strings" + "time" "github.com/miekg/dns" ) @@ -15,12 +17,15 @@ type route struct { class uint16 name *regexp.Regexp source *net.IPNet + weekdays []time.Weekday + before *TimeOfDay + after *TimeOfDay 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) { +func NewRoute(name, class string, types, weekdays []string, before, after, source string, resolver Resolver) (*route, error) { if resolver == nil { return nil, errors.New("no resolver defined for route") } @@ -28,6 +33,18 @@ func NewRoute(name, class string, types []string, source string, resolver Resolv if err != nil { return nil, err } + w, err := stringsToWeekdays(weekdays) + if err != nil { + return nil, err + } + b, err := parseTimeOfDay(before) + if err != nil { + return nil, err + } + a, err := parseTimeOfDay(after) + if err != nil { + return nil, err + } c, err := stringToClass(class) if err != nil { return nil, err @@ -47,6 +64,9 @@ func NewRoute(name, class string, types []string, source string, resolver Resolv types: t, class: c, name: re, + weekdays: w, + before: b, + after: a, source: sNet, resolver: resolver, }, nil @@ -66,6 +86,30 @@ func (r *route) match(q *dns.Msg, ci ClientInfo) bool { if r.source != nil && !r.source.Contains(ci.SourceIP) { return r.inverted } + if len(r.weekdays) > 0 || r.before != nil || r.after != nil { + now := time.Now().Local() + hour := now.Hour() + minute := now.Minute() + if len(r.weekdays) > 0 { + weekday := now.Weekday() + var weekdayMatch bool + for _, wd := range r.weekdays { + if weekday == wd { + weekdayMatch = true + break + } + } + if !weekdayMatch { + return r.inverted + } + } + if r.before != nil && !r.before.isAfter(hour, minute) { + return r.inverted + } + if r.after != nil && !r.after.isBefore(hour, minute) { + return r.inverted + } + } return !r.inverted } @@ -134,3 +178,64 @@ func stringToClass(s string) (uint16, error) { return 0, fmt.Errorf("unknown class '%s'", s) } } + +func stringsToWeekdays(weekdays []string) ([]time.Weekday, error) { + var result []time.Weekday + for _, day := range weekdays { + var weekday time.Weekday + switch day { + case "mon": + weekday = time.Monday + case "tue": + weekday = time.Tuesday + case "wed": + weekday = time.Wednesday + case "thu": + weekday = time.Thursday + case "fri": + weekday = time.Friday + case "sat": + weekday = time.Saturday + case "sun": + weekday = time.Sunday + default: + return nil, fmt.Errorf("unrecognized weekday %q, must be 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'", day) + } + result = append(result, weekday) + } + return result, nil +} + +type TimeOfDay struct { + hour, minute int +} + +func parseTimeOfDay(t string) (*TimeOfDay, error) { + if t == "" { + return nil, nil + } + f := strings.SplitN(t, ":", 2) + hour, err := strconv.Atoi(f[0]) + if err != nil { + return nil, err + } + var min int + if len(f) > 1 { + min, err = strconv.Atoi(f[1]) + if err != nil { + return nil, err + } + } + return &TimeOfDay{ + hour: hour, + minute: min, + }, nil +} + +func (t *TimeOfDay) isBefore(hour, minute int) bool { + return t.hour < hour || (t.hour == hour && t.minute <= minute) +} + +func (t *TimeOfDay) isAfter(hour, minute int) bool { + return t.hour > hour || (t.hour == hour && t.minute > minute) +} diff --git a/route_test.go b/route_test.go index 32997f5..2b45291 100644 --- a/route_test.go +++ b/route_test.go @@ -79,7 +79,7 @@ func TestRoute(t *testing.T) { }, } for _, test := range tests { - r, err := NewRoute(test.rName, test.rClass, test.rType, "", &TestResolver{}) + r, err := NewRoute(test.rName, test.rClass, test.rType, nil, "", "", "", &TestResolver{}) require.NoError(t, err) r.Invert(test.rInvert) diff --git a/router_test.go b/router_test.go index 71e944d..409f540 100644 --- a/router_test.go +++ b/router_test.go @@ -14,8 +14,8 @@ func TestRouterType(t *testing.T) { q := new(dns.Msg) var ci ClientInfo - route1, _ := NewRoute("", "", []string{"MX"}, "", r1) - route2, _ := NewRoute("", "", nil, "", r2) + route1, _ := NewRoute("", "", []string{"MX"}, nil, "", "", "", r1) + route2, _ := NewRoute("", "", nil, nil, "", "", "", r2) router := NewRouter("my-router") router.Add(route1, route2) @@ -41,8 +41,8 @@ func TestRouterClass(t *testing.T) { q := new(dns.Msg) var ci ClientInfo - route1, _ := NewRoute("", "ANY", nil, "", r1) - route2, _ := NewRoute("", "", nil, "", r2) + route1, _ := NewRoute("", "ANY", nil, nil, "", "", "", r1) + route2, _ := NewRoute("", "", nil, nil, "", "", "", r2) router := NewRouter("my-router") router.Add(route1, route2) @@ -69,8 +69,8 @@ func TestRouterName(t *testing.T) { q := new(dns.Msg) var ci ClientInfo - route1, _ := NewRoute(`\.acme\.test\.$`, "", nil, "", r1) - route2, _ := NewRoute("", "", nil, "", r2) + route1, _ := NewRoute(`\.acme\.test\.$`, "", nil, nil, "", "", "", r1) + route2, _ := NewRoute("", "", nil, nil, "", "", "", r2) router := NewRouter("my-router") router.Add(route1, route2) @@ -96,8 +96,8 @@ 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) + route1, _ := NewRoute("", "", nil, nil, "", "", "192.168.1.100/32", r1) + route2, _ := NewRoute("", "", nil, nil, "", "", "", r2) router := NewRouter("my-router") router.Add(route1, route2)