Add ECS source support to routing (#470) (#471)

* 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:
tekert
2025-10-20 06:25:47 -03:00
committed by GitHub
parent 3fc8654938
commit 61be40db8c
9 changed files with 138 additions and 14 deletions

View File

@@ -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).
![use-case-7](doc/use-case-7.svg)
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)

View File

@@ -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

View 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"

View File

@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -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)

View File

@@ -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())
}

View File

@@ -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)

View File

@@ -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())
}