mirror of
https://github.com/folbricht/routedns.git
synced 2025-12-21 09:29:56 -06:00
* Add ECS source support to routing (#470) * Add comment to clarify handling of EDNS0_SUBNET option in match function * Add support for EDNS Client Subnet (ECS) routing and per-client filtering - Updated README.md to include ECS routing capabilities and SVG image. - Added example configuration for use case 7
This commit is contained in:
12
README.md
12
README.md
@@ -17,7 +17,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, class, query name, time, or client IP
|
||||
- Routing of queries based on query type, class, query name, time, client IP, or EDNS Client Subnet (ECS)
|
||||
- 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
|
||||
@@ -145,6 +145,16 @@ These blocklists are loaded and refreshed daily by RouteDNS daily over HTTP. Ref
|
||||
|
||||
The configuration can be found [here](cmd/routedns/example-config/use-case-6.toml)
|
||||
|
||||
### Use case 7: Per-client filtering with an EDNS-aware frontend
|
||||
|
||||
This use case addresses applying different DNS policies to individual clients when using a frontend resolver that forwards the original client IP using an EDNS Client Subnet (ECS) option (e.g., `add-subnet=32` for dnsmasq). When a frontend resolver forwards a query, the source IP of the DNS packet becomes the IP of the resolver itself, hiding the original client's IP. As a result, RouteDNS's standard `source` routing rule cannot be used to differentiate between the individual clients behind the resolver.
|
||||
|
||||
The `ecs-source` routing option allows RouteDNS to inspect the ECS data in the query and route based on the original client's IP address. In this example, we apply a strict ad-blocking policy to a child's device (`192.168.1.10`) while giving an adult's device (`192.168.1.20`) unfiltered resolution, even though both queries are forwarded by the same DNS frontend resolver (e.g. dnsmasq).
|
||||
|
||||

|
||||
|
||||
The configuration can be found [here](cmd/routedns/example-config/use-case-7.toml)
|
||||
|
||||
## Links
|
||||
|
||||
- DNS-over-TLS RFC - [https://tools.ietf.org/html/rfc7858](https://tools.ietf.org/html/rfc7858)
|
||||
|
||||
@@ -208,6 +208,7 @@ type route struct {
|
||||
Class string
|
||||
Name string
|
||||
Source string
|
||||
ECSSource string `toml:"ecs-source"`
|
||||
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
|
||||
|
||||
34
cmd/routedns/example-config/use-case-7.toml
Normal file
34
cmd/routedns/example-config/use-case-7.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
# RouteDNS config demonstrating routing based on EDNS Client Subnet (ECS).
|
||||
# This allows for per-client policies even when RouteDNS is behind another
|
||||
# resolver that forwards the original client IP in an ECS record.
|
||||
|
||||
# Listener for the local network, receiving queries from the frontend resolver.
|
||||
[listeners.local-udp]
|
||||
address = ":53"
|
||||
protocol = "udp"
|
||||
resolver = "ecs-router"
|
||||
|
||||
# A router that inspects the ECS data in incoming queries.
|
||||
[routers.ecs-router]
|
||||
routes = [
|
||||
# Route 1: Queries with an ECS source of 192.168.1.10/32 are sent to the adblocker.
|
||||
{ ecs-source = "192.168.1.10/32", resolver = "adblock-resolver" },
|
||||
|
||||
# Route 2: Queries with an ECS source of 192.168.1.20/32 are sent to the unfiltered resolver.
|
||||
{ ecs-source = "192.168.1.20/32", resolver = "unfiltered-resolver" },
|
||||
|
||||
# Route 3 (Default): All other queries (including those without ECS) go to the unfiltered resolver.
|
||||
{ resolver = "unfiltered-resolver" },
|
||||
]
|
||||
|
||||
# A simple blocklist resolver for the "strict" policy.
|
||||
# It blocks queries for "doubleclick.net" and forwards others.
|
||||
[groups.adblock-resolver]
|
||||
type = "blocklist-v2"
|
||||
resolvers = ["unfiltered-resolver"]
|
||||
blocklist = ["doubleclick.net"]
|
||||
|
||||
# An unfiltered, secure upstream resolver (Cloudflare DoT).
|
||||
[resolvers.unfiltered-resolver]
|
||||
address = "1.1.1.1:853"
|
||||
protocol = "dot"
|
||||
@@ -900,7 +900,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.Weekdays, route.Before, route.After, route.Source, route.DoHPath, route.Listener, route.TLSServerName, resolver)
|
||||
r, err := rdns.NewRoute(route.Name, route.Class, types, route.Weekdays, route.Before, route.After, route.Source, route.ECSSource, route.DoHPath, route.Listener, route.TLSServerName, resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure parsing routes for router '%s' : %s", id, err.Error())
|
||||
}
|
||||
|
||||
4
doc/use-case-7.svg
Normal file
4
doc/use-case-7.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -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, nil, "", "", "", "", "", "", cloudflare)
|
||||
route2, _ := rdns.NewRoute("", "", nil, 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)
|
||||
|
||||
|
||||
31
route.go
31
route.go
@@ -17,6 +17,7 @@ type route struct {
|
||||
class uint16
|
||||
name *regexp.Regexp
|
||||
source *net.IPNet
|
||||
ecsSource *net.IPNet
|
||||
weekdays []time.Weekday
|
||||
before *TimeOfDay
|
||||
after *TimeOfDay
|
||||
@@ -28,7 +29,7 @@ type route struct {
|
||||
}
|
||||
|
||||
// NewRoute initializes a route from string parameters.
|
||||
func NewRoute(name, class string, types, weekdays []string, before, after, source, dohPath, listenerID, tlsServerName string, resolver Resolver) (*route, error) {
|
||||
func NewRoute(name, class string, types, weekdays []string, before, after, source, ecsSource, dohPath, listenerID, tlsServerName string, resolver Resolver) (*route, error) {
|
||||
if resolver == nil {
|
||||
return nil, errors.New("no resolver defined for route")
|
||||
}
|
||||
@@ -75,6 +76,13 @@ func NewRoute(name, class string, types, weekdays []string, before, after, sourc
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var ecsNet *net.IPNet
|
||||
if ecsSource != "" {
|
||||
_, ecsNet, err = net.ParseCIDR(ecsSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &route{
|
||||
types: t,
|
||||
class: c,
|
||||
@@ -83,6 +91,7 @@ func NewRoute(name, class string, types, weekdays []string, before, after, sourc
|
||||
before: b,
|
||||
after: a,
|
||||
source: sNet,
|
||||
ecsSource: ecsNet,
|
||||
dohPath: dohRe,
|
||||
listenerID: listenerRe,
|
||||
tlsServerName: tlsRe,
|
||||
@@ -104,6 +113,23 @@ func (r *route) match(q *dns.Msg, ci ClientInfo) bool {
|
||||
if r.source != nil && !r.source.Contains(ci.SourceIP) {
|
||||
return r.inverted
|
||||
}
|
||||
if r.ecsSource != nil {
|
||||
var ecsIP net.IP
|
||||
opt := q.IsEdns0()
|
||||
if opt != nil {
|
||||
// According to RFC 7871, there should be only one EDNS0_SUBNET option per query.
|
||||
// This code checks only the first found EDNS0_SUBNET option.
|
||||
for _, s := range opt.Option {
|
||||
if e, ok := s.(*dns.EDNS0_SUBNET); ok {
|
||||
ecsIP = e.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ecsIP == nil || !r.ecsSource.Contains(ecsIP) {
|
||||
return r.inverted
|
||||
}
|
||||
}
|
||||
if !r.dohPath.MatchString(ci.DoHPath) {
|
||||
return r.inverted
|
||||
}
|
||||
@@ -166,6 +192,9 @@ func (r *route) String() string {
|
||||
if r.source != nil {
|
||||
fragments = append(fragments, "source="+r.source.String())
|
||||
}
|
||||
if r.ecsSource != nil {
|
||||
fragments = append(fragments, "ecs-source="+r.ecsSource.String())
|
||||
}
|
||||
if r.dohPath.String() != "" {
|
||||
fragments = append(fragments, "doh-path="+r.dohPath.String())
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestRoute(t *testing.T) {
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
r, err := NewRoute(test.rName, test.rClass, test.rType, nil, "", "", "", "", "", "", &TestResolver{})
|
||||
r, err := NewRoute(test.rName, test.rClass, test.rType, nil, "", "", "", "", "", "", "", &TestResolver{})
|
||||
require.NoError(t, err)
|
||||
r.Invert(test.rInvert)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestRouterType(t *testing.T) {
|
||||
q := new(dns.Msg)
|
||||
var ci ClientInfo
|
||||
|
||||
route1, _ := NewRoute("", "", []string{"MX"}, nil, "", "", "", "", "", "", r1)
|
||||
route2, _ := NewRoute("", "", nil, 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, nil, "", "", "", "", "", "", r1)
|
||||
route2, _ := NewRoute("", "", nil, 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, nil, "", "", "", "", "", "", r1)
|
||||
route2, _ := NewRoute("", "", nil, 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, nil, "", "", "192.168.1.100/32", "", "", "", r1)
|
||||
route2, _ := NewRoute("", "", nil, 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)
|
||||
@@ -114,3 +114,49 @@ func TestRouterSource(t *testing.T) {
|
||||
require.Equal(t, 1, r1.HitCount())
|
||||
require.Equal(t, 1, r2.HitCount())
|
||||
}
|
||||
|
||||
func TestRouterECSSource(t *testing.T) {
|
||||
r1 := new(TestResolver)
|
||||
r2 := new(TestResolver)
|
||||
q := new(dns.Msg)
|
||||
q.SetQuestion("acme.test.", dns.TypeA)
|
||||
|
||||
route1, _ := NewRoute("", "", nil, nil, "", "", "", "10.0.0.0/24", "", "", "", r1)
|
||||
route2, _ := NewRoute("", "", nil, nil, "", "", "", "", "", "", "", r2)
|
||||
|
||||
router := NewRouter("my-router")
|
||||
router.Add(route1, route2)
|
||||
|
||||
// Add EDNS0 option with client subnet that matches
|
||||
o := new(dns.OPT)
|
||||
o.Hdr.Name = "."
|
||||
o.Hdr.Rrtype = dns.TypeOPT
|
||||
e := new(dns.EDNS0_SUBNET)
|
||||
e.Code = dns.EDNS0SUBNET
|
||||
e.Family = 1 // 1 for IPv4
|
||||
e.SourceNetmask = 32
|
||||
e.SourceScope = 0
|
||||
e.Address = net.ParseIP("10.0.0.1")
|
||||
o.Option = append(o.Option, e)
|
||||
q.Extra = append(q.Extra, o)
|
||||
|
||||
// Match, should go to r1
|
||||
_, err := router.Resolve(q, ClientInfo{SourceIP: net.ParseIP("192.168.1.100")})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, r1.HitCount())
|
||||
require.Equal(t, 0, r2.HitCount())
|
||||
|
||||
// No match, should go to r2
|
||||
e.Address = net.ParseIP("10.1.0.1")
|
||||
_, err = router.Resolve(q, ClientInfo{SourceIP: net.ParseIP("192.168.1.100")})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, r1.HitCount())
|
||||
require.Equal(t, 1, r2.HitCount())
|
||||
|
||||
// No ECS, should go to r2
|
||||
q.Extra = nil
|
||||
_, err = router.Resolve(q, ClientInfo{SourceIP: net.ParseIP("192.168.1.100")})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, r1.HitCount())
|
||||
require.Equal(t, 2, r2.HitCount())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user