From b38e459884ddf6ef853af0e10434e16bf9dfdcfa Mon Sep 17 00:00:00 2001 From: Frank Olbricht Date: Sun, 17 May 2020 08:22:00 -0600 Subject: [PATCH] Add static-responder group (#25) * Add static-responder group * Missing files * Fix example file --- README.md | 31 +++++++ cmd/routedns/config.go | 6 ++ .../example-config/blocklist-resolver.toml | 12 ++- .../example-config/walled-garden.toml | 53 ++++++++++++ cmd/routedns/main.go | 12 +++ static.go | 80 +++++++++++++++++++ static_test.go | 37 +++++++++ 7 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 cmd/routedns/example-config/walled-garden.toml create mode 100644 static.go create mode 100644 static_test.go diff --git a/README.md b/README.md index bfbf389..6fac2b6 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,37 @@ ecs-prefix4 = 8 ecs-prefix6 = 64 ``` +## Static responders + +A `static-responder` can be used to terminate a query with a fixed answer. The answer can contain Answer, NS, and Extra records with a configurable RCode. Static responsers are useful in combination with routers to build walled-gardens or blocklists providing more control over the response. The individual records in the response are defined in zone-file format. The default TTL is 1h unless given in the record. + +A fixed responder that will return a full answer with NS and Extra records with different TTL. The name string in answer records gets updated dynamically to match the query, while NS and Extra records are return unmodified. + +```toml +[groups.static-a] +type = "static-responder" +rcode = 0 # Response code: 0 = NOERROR, 1 = FORMERR, 2 = SERVFAIL, 3 = NXDOMAIN, ... +answer = ["IN A 1.2.3.4"] +ns = [ + "domain.com. 18000 IN NS ns1.domain.com.", + "domain.com. 18000 IN NS ns2.domain.com.", +] +extra = [ + "ns1.domain.com. 1800 IN A 127.0.0.1", + "ns1.domain.com. 1800 IN AAAA ::1", + "ns2.domain.com. 1800 IN A 127.0.0.1", + "ns2.domain.com. 1800 IN AAAA ::1", +] +``` + +Simple responder that'll reply with SERVFAIL to every query routed to it. + +```toml +[groups.static-nxdomain] +type = "static-responder" +rcode = 3 +``` + ## Use-cases / Examples ### Use case 1: Use DNS-over-TLS for all queries locally diff --git a/cmd/routedns/config.go b/cmd/routedns/config.go index d90251a..7ec498c 100644 --- a/cmd/routedns/config.go +++ b/cmd/routedns/config.go @@ -70,6 +70,12 @@ type group struct { AllowlistFormat string `toml:"allowlist-format"` // only used for static allowlists in the config AllowlistSource []list `toml:"allowlist-source"` AllowlistRefresh int `toml:"allowlist-refresh"` + + // Static responder options + Answer []string + NS []string + Extra []string + RCode int } // Block/Allowlist items for blocklist-v2 diff --git a/cmd/routedns/example-config/blocklist-resolver.toml b/cmd/routedns/example-config/blocklist-resolver.toml index 029f396..15cbaa7 100644 --- a/cmd/routedns/example-config/blocklist-resolver.toml +++ b/cmd/routedns/example-config/blocklist-resolver.toml @@ -2,7 +2,10 @@ # queries that match the allowlist or blocklist. The default behavior is to # return NXDOMAIN for any item that matches the blocklist but not the allowlist. # With resolvers, it's possible to send queries to different resolvers based -# on them matching the allowlist or blocklist. Similar to a router. +# on them matching the allowlist or blocklist, similar to a router. This is +# useful when the blocklist should return something other than NXDOMAIN, the +# query can be routed to a statis-responder instead, providing more control +# over what is returned. [resolvers.cloudflare-dot] address = "1.1.1.1:853" @@ -12,10 +15,15 @@ protocol = "dot" address = "8.8.8.8:853" protocol = "dot" +# Returns servfail for every query routed here by the blocklist +[groups.static-servfail] +type = "static-responder" +rcode = 2 # Response code: 0 = NOERROR, 1 = FORMERR, 2 = SERVFAIL, 3 = NXDOMAIN, ... + [groups.cloudflare-blocklist] type = "blocklist-v2" resolvers = ["cloudflare-dot"] # Default upstream resolver for anything not matching either list -blocklist-resolver = "google-dot" # Resolver used for anything matching the blocklist (and not on the allowlist) +blocklist-resolver = "static-servfail" # Resolver used for anything matching the blocklist (and not on the allowlist) blocklist-format = "domain" blocklist = [ 'evil.com', diff --git a/cmd/routedns/example-config/walled-garden.toml b/cmd/routedns/example-config/walled-garden.toml new file mode 100644 index 0000000..034349d --- /dev/null +++ b/cmd/routedns/example-config/walled-garden.toml @@ -0,0 +1,53 @@ +# This example shows how to combine routers with static responders to build +# a walled garden. Static responders can also be used with alternative resolvers +# on blocklists to control the block response (something other than NXDOMAIN). + +# A static-responder always returns the same response records defined here. The only +# thing that is changed is the name in the answer records to match that of the query. +# It's possible to define Answer, NS, and Extra records in zone-file format as well +# as a RCode which defaults to 0 (NOERROR). +[groups.static-a] +type = "static-responder" +rcode = 0 # Response code: 0 = NOERROR, 1 = FORMERR, 2 = SERVFAIL, 3 = NXDOMAIN, ... +answer = ["IN A 1.2.3.4"] +ns = [ + "domain.com. 18000 IN NS ns1.domain.com.", + "domain.com. 18000 IN NS ns2.domain.com.", +] +extra = [ + "ns1.domain.com. 1800 IN A 127.0.0.1", + "ns1.domain.com. 1800 IN AAAA ::1", + "ns2.domain.com. 1800 IN A 127.0.0.1", + "ns2.domain.com. 1800 IN AAAA ::1", +] + +[groups.static-aaaa] +type = "static-responder" +answer = ["IN AAAA ::1"] + +[groups.static-mx] +type = "static-responder" +answer = ["IN MX 10 smtp.acme.test."] + +[groups.static-nxdomain] +type = "static-responder" +rcode = 3 + +# Use a router to send certain types of queries to static responders. +[routers.walled-garden-router] +routes = [ + { type = "A", resolver="static-a" }, + { type = "AAAA", resolver="static-aaaa" }, + { type = "MX", resolver="static-mx" }, + { resolver="static-nxdomain" }, # default route +] + +[listeners.local-udp] +address = ":53" +protocol = "udp" +resolver = "walled-garden-router" + +[listeners.local-tcp] +address = ":53" +protocol = "tcp" +resolver = "walled-garden-router" diff --git a/cmd/routedns/main.go b/cmd/routedns/main.go index b954bdb..43e7c53 100644 --- a/cmd/routedns/main.go +++ b/cmd/routedns/main.go @@ -422,6 +422,18 @@ func instantiateGroup(id string, g group, resolvers map[string]rdns.Resolver) er if err != nil { return err } + case "static-responder": + opt := rdns.StaticResolverOptions{ + Answer: g.Answer, + NS: g.NS, + Extra: g.Extra, + RCode: g.RCode, + } + resolvers[id], err = rdns.NewStaticResolver(opt) + if err != nil { + return err + } + default: return fmt.Errorf("unsupported group type '%s' for group '%s'", g.Type, id) } diff --git a/static.go b/static.go new file mode 100644 index 0000000..8a5912b --- /dev/null +++ b/static.go @@ -0,0 +1,80 @@ +package rdns + +import ( + "fmt" + + "github.com/miekg/dns" +) + +// StaticResolver is a resolver that always returns the same answer, to any question. +// Typically used in combination with a blocklist to define fixed block responses or +// with a router when building a walled garden. +type StaticResolver struct { + answer []dns.RR + ns []dns.RR + extra []dns.RR + rcode int +} + +var _ Resolver = &StaticResolver{} + +type StaticResolverOptions struct { + // Records in zone-file format + Answer []string + NS []string + Extra []string + RCode int +} + +// NewStaticResolver returns a new instance of a StaticResolver resolver. +func NewStaticResolver(opt StaticResolverOptions) (*StaticResolver, error) { + r := new(StaticResolver) + + for _, record := range opt.Answer { + rr, err := dns.NewRR(record) + if err != nil { + return nil, err + } + r.answer = append(r.answer, rr) + } + for _, record := range opt.NS { + rr, err := dns.NewRR(record) + if err != nil { + return nil, err + } + r.ns = append(r.ns, rr) + } + for _, record := range opt.Extra { + rr, err := dns.NewRR(record) + if err != nil { + return nil, err + } + r.extra = append(r.extra, rr) + } + r.rcode = opt.RCode + + return r, nil +} + +// Resolve a DNS query by returning a fixed response. +func (r *StaticResolver) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) { + answer := new(dns.Msg) + answer.SetReply(q) + + // Update the name of every answer record to match that of the query + answer.Answer = make([]dns.RR, 0, len(r.answer)) + for _, rr := range r.answer { + r := dns.Copy(rr) + r.Header().Name = qName(q) + answer.Answer = append(answer.Answer, r) + } + answer.Ns = r.ns + answer.Extra = r.extra + answer.Rcode = r.rcode + + return answer, nil +} + +func (r *StaticResolver) String() string { + return fmt.Sprintf("StaticResolver") +} diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..13b6f3f --- /dev/null +++ b/static_test.go @@ -0,0 +1,37 @@ +package rdns + +import ( + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestStaticResolver(t *testing.T) { + opt := StaticResolverOptions{ + Answer: []string{ + "IN A 1.2.3.4", + }, + NS: []string{ + "example.com. 18000 IN A 1.2.3.4", + "example.com. 18000 IN AAAA ::1", + }, + Extra: []string{ + "ns1.example.com. IN A 1.1.1.1", + }, + } + r, err := NewStaticResolver(opt) + require.NoError(t, err) + + q := new(dns.Msg) + q.SetQuestion("test.com.", dns.TypeA) + + a, err := r.Resolve(q, ClientInfo{}) + require.NoError(t, err) + require.Equal(t, len(opt.Answer), len(a.Answer)) + require.Equal(t, len(opt.NS), len(a.Ns)) + require.Equal(t, len(opt.Extra), len(a.Extra)) + require.Equal(t, "test.com.", a.Answer[0].Header().Name) + require.Equal(t, "example.com.", a.Ns[0].Header().Name) + require.Equal(t, "ns1.example.com.", a.Extra[0].Header().Name) +}