Skip to content

Commit

Permalink
Merge pull request #29 from duedil-ltd/feature/accept-reject-queries
Browse files Browse the repository at this point in the history
Query filters
  • Loading branch information
tarnfeld committed Aug 19, 2014
2 parents 9003a4e + 330f3cd commit c4c1df5
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 29 deletions.
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@ An authoritative DNS nameserver that queries an [etcd](http://github.com/coreos/
- Global default on all records
- Individual TTL values for individual records
- Runtime and application metrics are captured regularly for monitoring (stdout or grahite)
- Incoming query filters

#### Production Readyness

Can I use this in production? TLDR; Yes, with caution.

We've been running discodns in some production environments with little issue, though that is not to say it's bug free! If you find any issues, please submit a [bug report](https://github.com/duedil-ltd/discodns/issues/new) or pull request.
We've been running discodns in production for several months now, with no issue, though that is not to say it's bug free! If you find any issues, please submit a [bug report](https://github.com/duedil-ltd/discodns/issues/new) or pull request.

### Why did we build discodns?

Expand Down Expand Up @@ -254,6 +253,20 @@ The discodns server will monitor a wide range of runtime and application metrics
You can also use the `-graphite` arguments for shipping metrics to your own Graphite server instead.
## Query Filters
In some situations, it can be useful to restrict the activities of a discodns nameserver to avoid querying etcd for certain domains or record types. For example, your network may not have support for IPv6 and therefore will never be storing any internal `AAAA` records, so it's a waste of effort querying etcd as they're never going to return with values.
This can be achieved with the `--accept` and `--reject` options to discodns. With these options, queries will be tested against the acceptance criteria before hitting etcd, or the internal resolver. This is a very cheap operation, and can drastically improve performance in some cases.
For example, if I **only** want to allow PTR lookups in the `in-addr.arpa.` domain space (for reverse domain queries) I can use the `--accept="in-addr.arpa:PTR"` argument. The nameserver is now going to reject any queries that aren't reverse lookups.
```
--accept="discodns.net:" # Accept any queries within the discodns.net domain
--accept="discodns.net:SRV,PTR" # Accept only PTR and SRV queries within the discodns domain
--reject="discodns.net:AAAA" # Reject any queries within the discodns.net domain that are for IPv6 lookups
```
## Contributions
All contributions are welcome and encouraged! Please feel free to open a pull request no matter how large or small.
Expand Down
70 changes: 70 additions & 0 deletions filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"github.com/miekg/dns"
"strings"
)

type QueryFilter struct {
domain string
qTypes []string
}

type QueryFilterer struct {
acceptFilters []QueryFilter
rejectFilters []QueryFilter
}

// Matches returns true if the given DNS query matches the filter
func (f *QueryFilter) Matches(req *dns.Msg) bool {
queryDomain := req.Question[0].Name
queryQType := dns.TypeToString[req.Question[0].Qtype]
if len(queryDomain) > 0 && !strings.HasSuffix(queryDomain, f.domain) {
debugMsg("Domain match failed (" + queryDomain + ", " + f.domain + ")")
return false
}

matches := false
if len(f.qTypes) > 0 {
for _, qType := range f.qTypes {
if qType == queryQType {
matches = true
}
}
} else {
matches = true
}

return matches
}

// ShouldAcceptQuery returns true if the given DNS query matches the given
// accept/reject filters, and should be accepted.
func (f *QueryFilterer) ShouldAcceptQuery(req *dns.Msg) bool {
accepted := true

for _, filter := range f.rejectFilters {
filterDescription := "Filter " + filter.domain + ":" + strings.Join(filter.qTypes, ",")
if filter.Matches(req) {
debugMsg(filterDescription + " rejected")
accepted = false
break
}
debugMsg(filterDescription + " not rejected")
}

if accepted && len(f.acceptFilters) > 0 {
accepted = false
for _, filter := range f.acceptFilters {
filterDescription := "Filter " + filter.domain + ":" + strings.Join(filter.qTypes, ",")
if filter.Matches(req) {
debugMsg(filterDescription + " accepted")
accepted = true
break
}
debugMsg(filterDescription + " not accepted")
}
}

return accepted
}
102 changes: 102 additions & 0 deletions filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"github.com/miekg/dns"
"testing"
)

func TestFilters(t *testing.T) {
// Enable debug logging
log_debug = true
}

func TestNoFilters(t *testing.T) {
filterer := QueryFilterer{}
msg := generateDNSMessage("discodns.net", dns.TypeA)

if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}
}

func TestSimpleAccept(t *testing.T) {
filterer := QueryFilterer{acceptFilters: parseFilters([]string{"net:A"})}

msg := generateDNSMessage("discodns.net", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}

msg = generateDNSMessage("discodns.net", dns.TypeAAAA)
if filterer.ShouldAcceptQuery(msg) != false {
t.Error("Expected the query to be rejected")
t.Fatal()
}

msg = generateDNSMessage("discodns.com", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != false {
t.Error("Expected the query to be rejected")
t.Fatal()
}
}

func TestSimpleReject(t *testing.T) {
filterer := QueryFilterer{rejectFilters: parseFilters([]string{"net:A"})}

msg := generateDNSMessage("discodns.com", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}

msg = generateDNSMessage("discodns.net", dns.TypeAAAA)
if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}

msg = generateDNSMessage("discodns.net", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != false {
t.Error("Expected the query to be rejected")
t.Fatal()
}
}

func TestMultipleAccept(t *testing.T) {
filterer := QueryFilterer{acceptFilters: parseFilters([]string{"net:A", "com:AAAA"})}

msg := generateDNSMessage("discodns.net", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}

msg = generateDNSMessage("discodns.net", dns.TypeAAAA)
if filterer.ShouldAcceptQuery(msg) != false {
t.Error("Expected the query to be rejected")
t.Fatal()
}

msg = generateDNSMessage("discodns.com", dns.TypeAAAA)
if filterer.ShouldAcceptQuery(msg) != true {
t.Error("Expected the query to be accepted")
t.Fatal()
}

msg = generateDNSMessage("discodns.com", dns.TypeA)
if filterer.ShouldAcceptQuery(msg) != false {
t.Error("Expected the query to be rejected")
t.Fatal()
}
}


// generateDNSMessage returns a simple DNS query with a single question,
// comprised of the domain and rrType given.
func generateDNSMessage(domain string, rrType uint16) *dns.Msg {
domain = dns.Fqdn(domain)
msg := dns.Msg{Question: []dns.Question{dns.Question{Name: domain, Qtype: rrType}}}
return &msg
}
32 changes: 31 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/coreos/go-etcd/etcd"
"github.com/jessevdk/go-flags"
"github.com/rcrowley/go-metrics"
"github.com/miekg/dns"
"log"
"os"
"os/signal"
Expand All @@ -27,6 +28,8 @@ var (
GraphiteServer string `long:"graphite" description:"Graphite server to send metrics to"`
GraphiteDuration int `long:"graphite-duration" description:"Duration to periodically send metrics to the graphite server" default:"10"`
DefaultTtl uint32 `short:"t" long:"default-ttl" description:"Default TTL to return on records without an explicit TTL" default:"300"`
Accept []string `long:"accept" description:"Limit DNS queries to a set of domain:[type,...] pairs"`
Reject []string `long:"reject" description:"Limit DNS queries to a set of domain:[type,...] pairs"`
}
)

Expand Down Expand Up @@ -83,7 +86,9 @@ func main() {
etcd: etcd,
rTimeout: time.Duration(5) * time.Second,
wTimeout: time.Duration(5) * time.Second,
defaultTtl: Options.DefaultTtl}
defaultTtl: Options.DefaultTtl,
queryFilterer: &QueryFilterer{acceptFilters: parseFilters(Options.Accept),
rejectFilters: parseFilters(Options.Reject)}}

server.Run()

Expand Down Expand Up @@ -111,6 +116,31 @@ func debugMsg(v ...interface{}) {
}
}

// parseFilters will convert a string into a Query Filter structure. The accepted
// format for input is [domain]:[type,type,...]. For example...
//
// - "domain:A,AAAA" # Match all A and AAAA queries within `domain`
// - ":TXT" # Matches only TXT queries for any domain
// - "domain:" # Matches any query within `domain`
func parseFilters(filters []string) []QueryFilter {
parsedFilters := make([]QueryFilter, 0)
for _, filter := range filters {
components := strings.Split(filter, ":")
if len(components) != 2 {
logger.Printf("Expected only one colon ([domain]:[type,type...])")
continue
}

domain := dns.Fqdn(components[0])
types := strings.Split(components[1], ",")

debugMsg("Adding filter with domain '" + domain + "' and types '" + strings.Join(types, ",") + "'")
parsedFilters = append(parsedFilters, QueryFilter{domain, types})
}

return parsedFilters
}

func init() {
runtime.GOMAXPROCS(runtime.NumCPU())
}
89 changes: 64 additions & 25 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,55 @@ import (
)

type Server struct {
addr string
port int
etcd *etcd.Client
rTimeout time.Duration
wTimeout time.Duration
defaultTtl uint32
addr string
port int
etcd *etcd.Client
rTimeout time.Duration
wTimeout time.Duration
defaultTtl uint32
queryFilterer *QueryFilterer
}

type Handler struct {
resolver *Resolver
resolver *Resolver
queryFilterer *QueryFilterer

// Metrics
request_counter metrics.Counter
response_timer metrics.Timer
requestCounter metrics.Counter
acceptCounter metrics.Counter
rejectCounter metrics.Counter
responseTimer metrics.Timer
}

func (h *Handler) Handle(response dns.ResponseWriter, req *dns.Msg) {
h.request_counter.Inc(1)
h.response_timer.Time(func() {
h.requestCounter.Inc(1)
h.responseTimer.Time(func() {
debugMsg("Handling incoming query for domain " + req.Question[0].Name)

// Lookup the dns record for the request
// This method will add any answers to the message
msg := h.resolver.Lookup(req)
var msg *dns.Msg
if h.queryFilterer.ShouldAcceptQuery(req) != true {
debugMsg("Query not accepted")

h.rejectCounter.Inc(1)

msg = new(dns.Msg)
msg.SetReply(req)
msg.SetRcode(req, dns.RcodeNameError)
msg.Authoritative = true
msg.RecursionAvailable = false

// Add a useful TXT record
header := dns.RR_Header{Name: req.Question[0].Name,
Class: dns.ClassINET,
Rrtype: dns.TypeTXT}
msg.Ns = []dns.RR{&dns.TXT{header, []string{"Rejected query based on matched filters"}}}
} else {
h.acceptCounter.Inc(1)
msg = h.resolver.Lookup(req)
}

if msg != nil {
err := response.WriteMsg(msg)
if err != nil {
Expand All @@ -50,25 +75,39 @@ func (s *Server) Addr() string {

func (s *Server) Run() {

tcp_response_timer := metrics.NewTimer()
metrics.Register("request.handler.tcp.response_time", tcp_response_timer)
tcp_request_counter := metrics.NewCounter()
metrics.Register("request.handler.tcp.requests", tcp_request_counter)

udp_response_timer := metrics.NewTimer()
metrics.Register("request.handler.udp.response_time", udp_response_timer)
udp_request_counter := metrics.NewCounter()
metrics.Register("request.handler.udp.requests", udp_request_counter)
tcpResponseTimer := metrics.NewTimer()
metrics.Register("request.handler.tcp.response_time", tcpResponseTimer)
tcpRequestCounter := metrics.NewCounter()
metrics.Register("request.handler.tcp.requests", tcpRequestCounter)
tcpAcceptCounter := metrics.NewCounter()
metrics.Register("request.handler.tcp.filter_accepts", tcpAcceptCounter)
tcpRejectCounter := metrics.NewCounter()
metrics.Register("request.handler.tcp.filter_rejects", tcpRejectCounter)

udpResponseTimer := metrics.NewTimer()
metrics.Register("request.handler.udp.response_time", udpResponseTimer)
udpRequestCounter := metrics.NewCounter()
metrics.Register("request.handler.udp.requests", udpRequestCounter)
udpAcceptCounter := metrics.NewCounter()
metrics.Register("request.handler.udp.filter_accepts", udpAcceptCounter)
udpRejectCounter := metrics.NewCounter()
metrics.Register("request.handler.udp.filter_rejects", udpRejectCounter)

resolver := Resolver{etcd: s.etcd, defaultTtl: s.defaultTtl}
tcpDNShandler := &Handler{
resolver: &resolver,
request_counter: tcp_request_counter,
response_timer: tcp_response_timer}
requestCounter: tcpRequestCounter,
acceptCounter: tcpAcceptCounter,
rejectCounter: tcpRejectCounter,
responseTimer: tcpResponseTimer,
queryFilterer: s.queryFilterer}
udpDNShandler := &Handler{
resolver: &resolver,
request_counter: udp_request_counter,
response_timer: udp_response_timer}
requestCounter: udpRequestCounter,
acceptCounter: udpAcceptCounter,
rejectCounter: udpRejectCounter,
responseTimer: udpResponseTimer,
queryFilterer: s.queryFilterer}

udpHandler := dns.NewServeMux()
tcpHandler := dns.NewServeMux()
Expand Down

0 comments on commit c4c1df5

Please sign in to comment.