diff --git a/README.md b/README.md index 9313eff..3880b63 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ A DNS client in Go that supports the [Google DNS-over-HTTPS](https://developers.google.com/speed/public-dns/docs/dns-over-https). -It effectively encrypts all your DNS traffic. +It effectively encrypts all your DNS traffic. It also supports +[OpenResolve](https://www.openresolve.com/) by OpenDNS. -The ultimate goal for the project is to provide a secure, caching DNS proxy that communicates with -recursive DNS resolvers over encrypted channels only. For now, it resolves DNS queries over -HTTPS/1.1, in a few independent threads. The plans for future include HTTP/2.0 and QUIC support, -better caching, and other resolvers (e.g. [OpenResolve](https://www.openresolve.com/) by OpenDNS). +The ultimate goal for the project is to provide a secure, caching DNS proxy that +communicates with recursive DNS resolvers over encrypted channels only. For now, +it resolves DNS queries over HTTPS/1.1 in independent threads. The plans for +future include better caching and support for HTTP/2.0 and QUIC. ## Quick start @@ -43,34 +44,44 @@ $ sudo ./dingo-linux-amd64 -port=53 -gdns:server=[2a00:1450:401b:800::200e] To see all options, run `dingo -h`: ``` -Usage of dingo: +Usage of dingo-linux-amd64: -bind string - IP address to bind to (default "0.0.0.0") + IP address to bind to (default "127.0.0.1") -dbg int debugging level (default 2) + -gdns:auto + Google DNS: try to lookup the closest IPv4 server -gdns:edns string Google DNS: EDNS client subnet (set 0.0.0.0/0 to disable) + -gdns:host string + Google DNS: HTTP 'Host' header (real FQDN, encrypted in TLS) (default "dns.google.com") -gdns:nopad Google DNS: disable random padding -gdns:server string - Google DNS: web server address (default "216.58.209.174") + Google DNS: server address (default "216.58.195.78") -gdns:sni string Google DNS: SNI string to send (should match server certificate) (default "www.google.com") -gdns:workers int Google DNS: number of independent workers (default 10) + -odns:host string + OpenDNS: HTTP 'Host' header (real FQDN, encrypted in TLS) (default "api.openresolve.com") + -odns:server string + OpenDNS: web server address (default "67.215.70.81") + -odns:sni string + OpenDNS: TLS SNI string to send (unencrypted, must validate as server cert) (default "www.openresolve.com") + -odns:workers int + OpenDNS: number of independent workers -port int listen on port number (default 32000) -``` -Note that by default dingo binds to all interfaces, which makes it open to the -world (unless you run a firewall). Consider binding it to `127.0.0.1` instead. +``` Finally, you will need to make dingo start in background each time you boot your machine. In Linux, you might want to use the [GNU Screen](https://en.wikipedia.org/wiki/GNU_Screen), which can start processes in background. For example, you might want to add the following line to your `/etc/rc.local`: ``` -screen -dmS dingo /path/to/bin/dingo -port=53 -bind=127.0.0.1 -gdns:server=[2a00:1450:401b:800::200e] +screen -dmS dingo /path/to/bin/dingo -port=53 -gdns:server=[2a00:1450:401b:800::200e] ``` ## Author diff --git a/build.sh b/build.sh index 419abfd..0285a05 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,9 @@ #!/bin/bash [ -z "$1" ] && { echo "Usage: build.sh VERSION" >&1; exit 1; } + VERSION="$1" +DEST="$HOME/tmp/dingo-$VERSION" ############################################### @@ -11,14 +13,15 @@ function build() echo "Building dingo v. $VERSION for $TARGET" GOOS="${TARGET%-*}" GOARCH="${TARGET##*-}" go build \ - -o release/dingo-$VERSION/dingo-$TARGET \ - ./dingo.go ./gdns.go + -o $DEST/dingo-$TARGET \ + ./*.go } ############################################### -rm -fr ./release/dingo-$VERSION -mkdir -p ./release/dingo-$VERSION +echo "Building in $DEST" +rm -fr $DEST +mkdir -p $DEST for target in \ darwin-386 darwin-amd64 \ diff --git a/dingo.go b/dingo.go index 7b1f7a0..931cb97 100644 --- a/dingo.go +++ b/dingo.go @@ -23,7 +23,7 @@ import "math/rand" /* command-line arguments */ var ( - bindip = flag.String("bind", "0.0.0.0", "IP address to bind to") + bindip = flag.String("bind", "127.0.0.1", "IP address to bind to") port = flag.Int("port", 32000, "listen on port number") dbglvl = flag.Int("dbg", 2, "debugging level") ) @@ -33,7 +33,7 @@ var ( /* logging stuff */ func dbg(lvl int, fmt string, v ...interface{}) { if (*dbglvl >= lvl) { dbglog.Printf(fmt, v...) } } func die(msg error) { dbglog.Fatalln("fatal error:", msg.Error()) } -var dbglog = log.New(os.Stderr, "", log.LstdFlags | log.Lshortfile | log.LUTC) +var dbglog *log.Logger /* structures */ type GRR struct { @@ -64,29 +64,46 @@ var qchan = make(chan Query, 100) /* global reply cache */ var rcache *cache.Cache +/* module interface */ +var Modules = make(map[string]Module) +type Module interface { + Init() + Start() +} +func register(name string, mod Module) *Module { + Modules[name] = mod + return &mod +} + /**********************************************************************/ /* UDP request handler */ func handle(buf []byte, addr *net.UDPAddr, uc *net.UDPConn) { - dbg(3, "new request from %s (%d bytes)", addr, len(buf)) - /* try unpacking */ msg := new(dns.Msg) - if err := msg.Unpack(buf); err != nil { dbg(3, "Unpack failed: %s", err); return } - dbg(7, "unpacked: %s", msg) + if err := msg.Unpack(buf); err != nil { + dbg(3, "unpack failed: %s", err) + return + } else { + dbg(7, "unpacked message: %s", msg) + } - /* for each question */ + /* any questions? */ if (len(msg.Question) < 1) { dbg(3, "no questions"); return } + qname := msg.Question[0].Name + qtype := msg.Question[0].Qtype + dbg(2, "resolving %s/%s", qname, dns.TypeToString[qtype]) + /* check cache */ var r Reply - cid := fmt.Sprintf("%s/%d", msg.Question[0].Name, msg.Question[0].Qtype) + cid := fmt.Sprintf("%s/%d", qname, qtype) if x, found := rcache.Get(cid); found { // FIXME: update TTLs r = x.(Reply) } else { /* pass to resolvers and block until the response comes */ - r = resolve(msg.Question[0].Name, int(msg.Question[0].Qtype)) + r = resolve(qname, int(qtype)) dbg(8, "got reply: %+v", r) /* put to cache for 10 seconds (FIXME: use minimum TTL) */ @@ -139,11 +156,12 @@ func resolve(name string, qtype int) Reply { /* main */ func main() { + rand.Seed(time.Now().UnixNano()) + dbglog = log.New(os.Stderr, "", log.LstdFlags | log.LUTC) + /* prepare */ + for _,mod := range Modules { mod.Init() } flag.Parse() -// dbglog = log.New(os.Stderr, "", log.LstdFlags | log.Lshortfile | log.LUTC) - dbglog = log.New(os.Stderr, "", log.LstdFlags | log.LUTC) - rand.Seed(time.Now().UnixNano()) rcache = cache.New(24*time.Hour, 60*time.Second) /* listen */ @@ -152,11 +170,10 @@ func main() { if err != nil { die(err) } /* start workers */ - gdns_start() -// odns_start() + for _, mod := range Modules { mod.Start() } /* accept new connections forever */ - dbg(1, "dingo ver. 0.11 started on UDP port %d", laddr.Port) + dbg(1, "dingo ver. 0.12 listening on %s UDP port %d", *bindip, laddr.Port) var buf []byte for { buf = make([]byte, 1500) diff --git a/gdns.go b/gdns.go index 5b7a18d..3a5a8ab 100644 --- a/gdns.go +++ b/gdns.go @@ -9,85 +9,88 @@ package main import "fmt" -import "net/http" import "net/url" import "time" -import "io/ioutil" import "encoding/json" -import "crypto/tls" import "math/rand" import "strings" import "flag" -//import "github.com/devsisters/goquic" + +type Gdns struct { + workers *int + server *string + auto *bool + sni *string + host *string + edns *string + nopad *bool +} /* command-line arguments */ -var ( - gdns_workers = flag.Int("gdns:workers", 10, +func (r *Gdns) Init() { + r.workers = flag.Int("gdns:workers", 10, "Google DNS: number of independent workers") - gdns_server = flag.String("gdns:server", "216.58.209.174", - "Google DNS: web server address") - gdns_sni = flag.String("gdns:sni", "www.google.com", + r.server = flag.String("gdns:server", "216.58.195.78", + "Google DNS: server address") + r.auto = flag.Bool("gdns:auto", false, + "Google DNS: try to lookup the closest IPv4 server") + r.sni = flag.String("gdns:sni", "www.google.com", "Google DNS: SNI string to send (should match server certificate)") - gdns_edns = flag.String("gdns:edns", "", + r.host = flag.String("gdns:host", "dns.google.com", + "Google DNS: HTTP 'Host' header (real FQDN, encrypted in TLS)") + r.edns = flag.String("gdns:edns", "", "Google DNS: EDNS client subnet (set 0.0.0.0/0 to disable)") - gdns_nopad = flag.Bool("gdns:nopad", false, + r.nopad = flag.Bool("gdns:nopad", false, "Google DNS: disable random padding") -) - -/**********************************************************************/ - -func gdns_start() { - for i := 0; i < *gdns_workers; i++ { go gdns_resolver(*gdns_server) } } -func gdns_resolver(server string) { - /* setup the HTTP client */ - var httpTr = http.DefaultTransport.(*http.Transport) -// var httpTr = goquic.NewRoundTripper(true) +/**********************************************************************/ - var tlsCfg = &tls.Config{ ServerName: *gdns_sni } - httpTr.TLSClientConfig = tlsCfg; -// req,_ := http.NewRequest("GET", "https://www.google.com/", nil) -// httpTr.RoundTrip(req) +func (R *Gdns) Start() { + if *R.workers <= 0 { return } - var httpClient = &http.Client{ Timeout: time.Second*10, Transport: httpTr } + if *R.auto { + dbg(1, "resolving dns.google.com...") + r4 := R.resolve(NewHttps(*R.sni), *R.server, "dns.google.com", 1) + if r4.Status == 0 && len(r4.Answer) > 0 { + R.server = &r4.Answer[0].Data + } + } - for q := range qchan { - /* make the new response object */ - r := Reply{ Status: -1 } + dbg(1, "starting %d Google Public DNS client(s) querying server %s", + *R.workers, *R.server) + for i := 0; i < *R.workers; i++ { go R.worker(*R.server) } +} - /* prepare the query */ - v := url.Values{} - v.Set("name", q.Name) - v.Set("type", fmt.Sprintf("%d", q.Type)) - if len(*gdns_edns) > 0 { - v.Set("edns_client_subnet", *gdns_edns) - } - if !*gdns_nopad { - v.Set("random_padding", strings.Repeat(string(65+rand.Intn(26)), rand.Intn(500))) - } +func (R *Gdns) worker(server string) { + var https = NewHttps(*R.sni) + for q := range qchan { *q.rchan <- *R.resolve(https, server, q.Name, q.Type) } +} - /* prepare request, send proper HTTP 'Host:' header */ - addr := fmt.Sprintf("https://%s/resolve?%s", server, v.Encode()) - hreq,_ := http.NewRequest("GET", addr, nil) - hreq.Host = "dns.google.com" +func (R *Gdns) resolve(https *Https, server string, qname string, qtype int) *Reply { + r := Reply{ Status: -1 } + v := url.Values{} - /* send the query */ - resp,err := httpClient.Do(hreq) - if (err == nil) { - dbg(2, "[%s/%d] %s %s", q.Name, q.Type, resp.Status, resp.Proto) + /* prepare */ + v.Set("name", qname) + v.Set("type", fmt.Sprintf("%d", qtype)) + if len(*R.edns) > 0 { + v.Set("edns_client_subnet", *R.edns) + } + if !*R.nopad { + v.Set("random_padding", strings.Repeat(string(65+rand.Intn(26)), rand.Intn(500))) + } - /* read */ - buf,_ := ioutil.ReadAll(resp.Body) - resp.Body.Close() - dbg(7, " reply: %s", buf) + /* query */ + buf, err := https.Get(server, *R.host, "/resolve?" + v.Encode()) + if err != nil { return &r } - /* parse JSON? */ - if (resp.StatusCode == 200) { json.Unmarshal(buf, &r) } - r.Now = time.Now() - } else { dbg(1, "[%s/%d] error: %s", q.Name, q.Type, err.Error()) } + /* parse */ + r.Now = time.Now() + json.Unmarshal(buf, &r) - /* write the reply */ - *q.rchan <- r - } + return &r } + +/* register module */ +var _ = register("gdns", new(Gdns)) diff --git a/https.go b/https.go new file mode 100644 index 0000000..033d9dd --- /dev/null +++ b/https.go @@ -0,0 +1,68 @@ +/** + * dingo: a DNS caching proxy written in Go + * This file implements common code for HTTPS+JSON requests + * + * Copyright (C) 2016 Pawel Foremski + * Licensed under GNU GPL v3 + */ + +package main + +import "time" +import "net/http" +import "io/ioutil" +import "crypto/tls" +import "errors" + +type Https struct { + client http.Client + transport http.Transport + tlscfg tls.Config +} + +func NewHttps(sni string) *Https { + H := Https{} + + /* basic setup */ + H.client.Timeout = time.Second * 10 + H.client.Transport = &H.transport + H.transport.TLSClientConfig = &H.tlscfg + H.tlscfg.ServerName = sni + + return &H +} + +func (R *Https) Get(ip string, host string, uri string) ([]byte, error) { + url := "https://" + ip + uri + hreq, err := http.NewRequest("GET", url, nil) + if err != nil { + dbg(1, "http.NewRequest(): %s", err) + return nil, err + } + hreq.Host = host + + /* send the query */ + resp, err := R.client.Do(hreq) + if err != nil { + dbg(1, "http.Do(): %s", err) + return nil, err + } + dbg(3, "http.Do(%s): %s %s", url, resp.Status, resp.Proto) + + /* read */ + buf, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + dbg(1, "ioutil.ReadAll(%s): %s", url, err) + return nil, err + } + dbg(7, " reply: %s", buf) + + /* HTTP 200 OK? */ + if resp.StatusCode != 200 { + dbg(1, "resp.StatusCode != 200: %s", url) + return nil, errors.New("response code != 200") + } + + return buf, nil +} diff --git a/odns.go b/odns.go new file mode 100644 index 0000000..c06c10d --- /dev/null +++ b/odns.go @@ -0,0 +1,191 @@ +/** + * dingo: a DNS caching proxy written in Go + * This file implements an OpenDNS www.openresolve.com client + * + * Copyright (C) 2016 Pawel Foremski + * Licensed under GNU GPL v3 + */ + +package main + +import "fmt" +import "encoding/json" +import "time" +import "flag" +import "github.com/miekg/dns" + +type OdnsReply struct { + ReturnCode string + ID int + AA bool + AD bool + RA bool + RD bool + TC bool + QuestionSection map[string]interface{} + AnswerSection []map[string]interface{} + AdditionalSection []map[string]interface{} + AuthoritySection []map[string]interface{} +} + +/***********************************************************/ + +type Odns struct { + workers *int + server *string + sni *string + host *string + + string2rcode map[string]int + string2rtype map[string]uint16 +} + +func (R *Odns) Init() { + R.workers = flag.Int("odns:workers", 0, + "OpenDNS: number of independent workers") + R.server = flag.String("odns:server", "67.215.70.81", + "OpenDNS: web server address") + R.sni = flag.String("odns:sni", "www.openresolve.com", + "OpenDNS: TLS SNI string to send (unencrypted, must validate as server cert)") + R.host = flag.String("odns:host", "api.openresolve.com", + "OpenDNS: HTTP 'Host' header (real FQDN, encrypted in TLS)") + + R.string2rcode = make(map[string]int) + for rcode,str := range dns.RcodeToString { + R.string2rcode[str] = rcode + } + + R.string2rtype = make(map[string]uint16) + for rtype,str := range dns.TypeToString { + R.string2rtype[str] = rtype + } +} + +/* start OpenDNS workers */ +func (R *Odns) Start() { + if *R.workers <= 0 { return } + + dbg(1, "starting %d OpenDNS client(s) querying server %s", + *R.workers, *R.server) + for i := 0; i < *R.workers; i++ { go R.worker(*R.server) } +} + +/* handler of new requests */ +func (R *Odns) worker(server string) { + var https = NewHttps(*R.sni) + for q := range qchan { + *q.rchan <- *R.resolve(https, server, q.Name, q.Type) + } +} + +/* resolve single request */ +func (R *Odns) resolve(https *Https, server string, qname string, qtype int) *Reply { + r := Reply{ Status: -1 } + + /* prepare */ + uri := fmt.Sprintf("/%s/%s", dns.Type(qtype).String(), qname) + + /* query */ + buf, err := https.Get(server, *R.host, uri) + if err != nil { return &r } + r.Now = time.Now() + + /* parse */ + var f OdnsReply + json.Unmarshal(buf, &f) + + /* rewrite */ + r.Status = R.string2rcode[f.ReturnCode] + r.TC = f.TC + r.RD = f.RD + r.RA = f.RA + r.AD = f.AD + r.CD = false + + for _,v := range f.AnswerSection { + rr := R.odns2grr(v) + if rr != nil { r.Answer = append(r.Answer, *rr) } + } + + for _,v := range f.AdditionalSection { + rr := R.odns2grr(v) + if rr != nil { r.Additional = append(r.Additional, *rr) } + } + + for _,v := range f.AuthoritySection { + rr := R.odns2grr(v) + if rr != nil { r.Authority = append(r.Authority, *rr) } + } + + return &r +} + +func (R *Odns) odns2grr(v map[string]interface{}) (*GRR) { + /* catch panics */ + defer func() { + if r := recover(); r != nil { dbg(1, "panic in odns2grr()") } + }() + + /* get basic data */ + rname := v["Name"].(string) + rtypes := v["Type"].(string) + rttl := uint32(v["TTL"].(float64)) + + /* parse type & data */ + var rdata string + var rtype uint16 + switch rtypes { + case "A": + rtype = dns.TypeA + rdata = v["Address"].(string) + case "AAAA": + rtype = dns.TypeAAAA + rdata = v["Address"].(string) + case "CNAME": + rtype = dns.TypeCNAME + rdata = v["Target"].(string) + case "MX": + rtype = dns.TypeMX + mx := v["MailExchanger"].(string) + pref := v["Preference"].(float64) + rdata = fmt.Sprintf("%d %s", int(pref), mx) + case "NS": + rtype = dns.TypeNS + rdata = v["Target"].(string) + case "NAPTR": + rtype = dns.TypeNAPTR + flg := v["Flags"].(string) + ord := v["Order"].(float64) + svc := v["Service"].(string) + prf := v["Preference"].(float64) + reg := v["Regexp"].(string) + rep := v["Replacement"].(string) + rdata = fmt.Sprintf("%d %d \"%s\" \"%s\" \"%s\" %s", + int(ord), int(prf), flg, svc, reg, rep) + case "PTR": + rtype = dns.TypePTR + rdata = v["Target"].(string) + case "SOA": + rtype = dns.TypeSOA + msn := v["MasterServerName"].(string) + mn := v["MaintainerName"].(string) + ser := v["Serial"].(float64) + ref := v["Refresh"].(float64) + ret := v["Retry"].(float64) + exp := v["Expire"].(float64) + nttl := v["NegativeTtl"].(float64) + rdata = fmt.Sprintf("%s %s %d %d %d %d %d", + msn, mn, int(ser), int(ref), int(ret), int(exp), int(nttl)) + case "TXT": + rtype = dns.TypeTXT + rdata = v["TxtData"].(string) + default: + dbg(1, "odns2grr(): %s unsupported", rtypes) + return nil + } + + return &GRR{rname, rtype, rttl, rdata} +} + +/* register module */ +var _ = register("odns", new(Odns))