git » dnss » commit 8b2bc9a

Add an HTTPS-to-DNS proxy mode

author Alberto Bertogli
2017-07-20 20:44:08 UTC
committer Alberto Bertogli
2017-07-30 12:03:20 UTC
parent bea88d7c4c7460fa5bb38ee030e4f027f3617258

Add an HTTPS-to-DNS proxy mode

This patch adds a new mode, HTTPS-to-DNS proxy.

It implements an HTTPS server with an endpoint with the same API as
https://dns.google.com, so another dnss (or similar) can use it.

README.md +24 -5
dnss.go +30 -1
internal/dnsjson/dnsjson.go +22 -0
internal/dnstohttps/resolver.go +3 -20
internal/httpstodns/server.go +240 -0

diff --git a/README.md b/README.md
index 3493e96..5115537 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,15 @@
 
 dnss is a tool for encapsulating DNS over HTTPS.
 
+
 ## Quick start
 
-If you want to set up dnss quickly, in DNS-over-HTTPS mode and using
-https://dns.google.com as a server, you can run the following:
+If you're using Debian or Ubuntu, `apt install dnss` will install a dnss
+instance already configured in DNS-over-HTTPS mode and using
+https://dns.google.com as a server.
+
+
+To do the same manually:
 
 ```
 # If you have Go installed but no environment prepared, do:
@@ -26,10 +31,12 @@ sudo systemctl dnss enable
 ```
 
 
-## DNS over HTTPS
+## DNS to HTTPS proxy
 
-dnss can act as a DNS-over-HTTPS proxy, using https://dns.google.com as a
-server.
+dnss can act as a DNS-to-HTTPS proxy, using https://dns.google.com as a
+server, or anything implementing the same API, which is documented at
+https://developers.google.com/speed/public-dns/docs/dns-over-https (note it's
+in beta and subject to changes).
 
 ```
 +--------+       +----------------+        +----------------+
@@ -42,6 +49,18 @@ server.
 ```
 
 
+## HTTPS to DNS proxy
+
+dnss can also act as an HTTPS-to-DNS proxy, implementing the HTTP-based API
+documented at
+https://developers.google.com/speed/public-dns/docs/dns-over-https (note it's
+in beta and subject to changes).
+
+You can use this instead of https://dns.google.com if you want more control
+over the servers and the final DNS server used (for example if you are in an
+isolated environment, such as a test lab or a private network).
+
+
 ## Alternatives
 
 https://dnscrypt.org/ is a great, more end-to-end alternative to dnss.
diff --git a/dnss.go b/dnss.go
index 387f675..8de022c 100644
--- a/dnss.go
+++ b/dnss.go
@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"blitiri.com.ar/go/dnss/internal/dnstohttps"
+	"blitiri.com.ar/go/dnss/internal/httpstodns"
 
 	"github.com/golang/glog"
 
@@ -37,6 +38,18 @@ var (
 	httpsClientCAFile = flag.String("https_client_cafile", "",
 		"CA file to use for the HTTPS client")
 
+	enableHTTPStoDNS = flag.Bool("enable_https_to_dns", false,
+		"enable HTTPS-to-DNS proxy")
+	dnsUpstream = flag.String("dns_upstream",
+		"8.8.8.8:53",
+		"Address of the upstream DNS server (for the HTTPS-to-DNS proxy)")
+	httpsCertFile = flag.String("https_cert", "",
+		"certificate to use for the HTTPS server")
+	httpsKeyFile = flag.String("https_key", "",
+		"key to use for the HTTPS server")
+	httpsAddr = flag.String("https_server_addr", ":443",
+		"address to listen on for HTTPS-to-DNS requests")
+
 	logFlushEvery = flag.Duration("log_flush_every", 30*time.Second,
 		"how often to flush logs")
 	monitoringListenAddr = flag.String("monitoring_listen_addr", "",
@@ -61,9 +74,10 @@ func main() {
 		launchMonitoringServer(*monitoringListenAddr)
 	}
 
-	if !*enableDNStoHTTPS {
+	if !(*enableDNStoHTTPS || *enableHTTPStoDNS) {
 		glog.Error("Need to set one of the following:")
 		glog.Error("  --enable_dns_to_https")
+		glog.Error("  --enable_https_to_dns")
 		glog.Fatal("")
 	}
 
@@ -84,6 +98,21 @@ func main() {
 		}()
 	}
 
+	// HTTPS to DNS.
+	if *enableHTTPStoDNS {
+		s := httpstodns.Server{
+			Addr:     *httpsAddr,
+			Upstream: *dnsUpstream,
+			CertFile: *httpsCertFile,
+			KeyFile:  *httpsKeyFile,
+		}
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			s.ListenAndServe()
+		}()
+	}
+
 	wg.Wait()
 }
 
diff --git a/internal/dnsjson/dnsjson.go b/internal/dnsjson/dnsjson.go
new file mode 100644
index 0000000..820ea9f
--- /dev/null
+++ b/internal/dnsjson/dnsjson.go
@@ -0,0 +1,22 @@
+// Package dnsjson contains structures for representing DNS responses as JSON.
+//
+// Matches the API implemented by https://dns.google.com/.
+package dnsjson
+
+type Response struct {
+	Status   int  // Standard DNS response code (32 bit integer).
+	TC       bool // Whether the response is truncated
+	RD       bool // Whether recursion is desired.
+	RA       bool // Whether recursion is available.
+	AD       bool // Whether all response data was validated with DNSSEC
+	CD       bool // Whether the client asked to disable DNSSEC
+	Question []RR // Question we're responding to.
+	Answer   []RR // Answer to the question.
+}
+
+type RR struct {
+	Name string `json:name`
+	Type uint16 `json:type`
+	TTL  uint32 `json:TTL`
+	Data string `json:data`
+}
diff --git a/internal/dnstohttps/resolver.go b/internal/dnstohttps/resolver.go
index 2ebf82c..5309521 100644
--- a/internal/dnstohttps/resolver.go
+++ b/internal/dnstohttps/resolver.go
@@ -12,6 +12,8 @@ import (
 	"sync"
 	"time"
 
+	"blitiri.com.ar/go/dnss/internal/dnsjson"
+
 	"github.com/golang/glog"
 	"github.com/miekg/dns"
 	"golang.org/x/net/trace"
@@ -99,25 +101,6 @@ func (r *httpsResolver) Init() error {
 func (r *httpsResolver) Maintain() {
 }
 
-// Structure for parsing JSON responses.
-type jsonResponse struct {
-	Status   int
-	TC       bool
-	RD       bool
-	RA       bool
-	AD       bool
-	CD       bool
-	Question []jsonRR
-	Answer   []jsonRR
-}
-
-type jsonRR struct {
-	Name string `json:name`
-	Type uint16 `json:type`
-	TTL  uint32 `json:TTL`
-	Data string `json:data`
-}
-
 func (r *httpsResolver) Query(req *dns.Msg, tr trace.Trace) (*dns.Msg, error) {
 	// Only answer single-question queries.
 	// In practice, these are all we get, and almost no server supports
@@ -160,7 +143,7 @@ func (r *httpsResolver) Query(req *dns.Msg, tr trace.Trace) (*dns.Msg, error) {
 		return nil, fmt.Errorf("Failed to read body: %v", err)
 	}
 
-	jr := &jsonResponse{}
+	jr := &dnsjson.Response{}
 	err = json.Unmarshal(body, jr)
 	if err != nil {
 		return nil, fmt.Errorf("Failed to unmarshall: %v", err)
diff --git a/internal/httpstodns/server.go b/internal/httpstodns/server.go
new file mode 100644
index 0000000..2233262
--- /dev/null
+++ b/internal/httpstodns/server.go
@@ -0,0 +1,240 @@
+// Package httpstodns implements an HTTPS server which handles DNS requests
+// over HTTPS.
+package httpstodns
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"blitiri.com.ar/go/dnss/internal/dnsjson"
+	"blitiri.com.ar/go/dnss/internal/util"
+	"github.com/golang/glog"
+	"github.com/miekg/dns"
+	"golang.org/x/net/trace"
+)
+
+type Server struct {
+	Addr     string
+	Upstream string
+	CertFile string
+	KeyFile  string
+}
+
+func (s *Server) ListenAndServe() {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/resolve", s.Resolve)
+	srv := http.Server{
+		Addr:    s.Addr,
+		Handler: mux,
+	}
+
+	glog.Infof("HTTPS listening on %s", s.Addr)
+	err := srv.ListenAndServeTLS(s.CertFile, s.KeyFile)
+	glog.Fatalf("HTTPS exiting: %s", err)
+}
+
+func (s *Server) Resolve(w http.ResponseWriter, req *http.Request) {
+	tr := trace.New("httpstodns", "/resolve")
+	defer tr.Finish()
+
+	tr.LazyPrintf("from:%v", req.RemoteAddr)
+
+	// Construct the DNS request from the http query.
+	q, err := parseQuery(req.URL)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	r := &dns.Msg{}
+	r.CheckingDisabled = q.cd
+	r.SetQuestion(dns.Fqdn(q.name), q.rrType)
+
+	if q.clientSubnet != nil {
+		o := new(dns.OPT)
+		o.Hdr.Name = "."
+		o.Hdr.Rrtype = dns.TypeOPT
+		e := new(dns.EDNS0_SUBNET)
+		e.Code = dns.EDNS0SUBNET
+		if ipv4 := q.clientSubnet.IP.To4(); ipv4 != nil {
+			e.Family = 1 // IPv4 source address
+			e.Address = ipv4
+		} else {
+			e.Family = 2 // IPv6 source address
+			e.Address = q.clientSubnet.IP
+		}
+		e.SourceScope = 0
+
+		_, maskSize := q.clientSubnet.Mask.Size()
+		e.SourceNetmask = uint8(maskSize)
+
+		o.Option = append(o.Option, e)
+		r.Extra = append(r.Extra, o)
+	}
+
+	if glog.V(3) {
+		tr.LazyPrintf(util.QuestionsToString(r.Question))
+	}
+
+	// Do the DNS request, get the reply.
+	from_up, err := dns.Exchange(r, s.Upstream)
+	if err != nil {
+		msg := fmt.Sprintf("dns exchange error: %v", err)
+		glog.Info(msg)
+		tr.LazyPrintf(msg)
+		tr.SetError()
+
+		// TODO: reply via json anyway?
+		http.Error(w, err.Error(), http.StatusFailedDependency)
+		return
+	}
+
+	if from_up == nil {
+		err = fmt.Errorf("no response from upstream")
+		tr.LazyPrintf(err.Error())
+		tr.SetError()
+		http.Error(w, err.Error(), http.StatusRequestTimeout)
+		return
+	}
+
+	if glog.V(3) {
+		util.TraceAnswer(tr, from_up)
+	}
+
+	// Convert the reply to json, and write it back.
+	jr := &dnsjson.Response{
+		Status: from_up.Rcode,
+		TC:     from_up.Truncated,
+		RD:     from_up.RecursionDesired,
+		RA:     from_up.RecursionAvailable,
+		AD:     from_up.AuthenticatedData,
+		CD:     from_up.CheckingDisabled,
+	}
+
+	for _, q := range from_up.Question {
+		rr := dnsjson.RR{
+			Name: q.Name,
+			Type: q.Qtype,
+		}
+		jr.Question = append(jr.Question, rr)
+	}
+
+	for _, a := range from_up.Answer {
+		hdr := a.Header()
+		ja := dnsjson.RR{
+			Name: hdr.Name,
+			Type: hdr.Rrtype,
+			TTL:  hdr.Ttl,
+		}
+
+		hs := hdr.String()
+		ja.Data = a.String()[len(hs):]
+		jr.Answer = append(jr.Answer, ja)
+	}
+
+	buf, err := json.Marshal(jr)
+	if err != nil {
+		err = fmt.Errorf("failed to marshal: %v", err)
+		tr.LazyPrintf(err.Error())
+		tr.SetError()
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.Write(buf)
+}
+
+type query struct {
+	name   string
+	rrType uint16
+	cd     bool
+
+	// EDNS client subnet (address+mask).
+	clientSubnet *net.IPNet
+}
+
+func parseQuery(u *url.URL) (query, error) {
+	q := query{
+		name:         "",
+		rrType:       1,
+		cd:           false,
+		clientSubnet: nil,
+	}
+
+	// Simplify the values map, as all our parameters are single-value only.
+	vs := map[string]string{}
+	for k, values := range u.Query() {
+		if len(values) > 0 {
+			vs[k] = values[0]
+		} else {
+			vs[k] = ""
+		}
+	}
+	var ok bool
+	var err error
+
+	if q.name, ok = vs["name"]; !ok || q.name == "" {
+		return q, fmt.Errorf("empty name")
+	}
+	if len(q.name) > 253 {
+		return q, fmt.Errorf("name too long")
+	}
+
+	q.rrType, err = stringToRRType(vs["type"])
+	if err != nil {
+		return q, err
+	}
+
+	if cd, ok := vs["cd"]; ok {
+		q.cd, err = stringToBool(cd)
+		if err != nil {
+			return q, err
+		}
+	}
+
+	if clientSubnet, ok := vs["edns_client_subnet"]; ok {
+		_, q.clientSubnet, err = net.ParseCIDR(clientSubnet)
+		if err != nil {
+			return q, fmt.Errorf("invalid edns_client_subnet: %v", err)
+		}
+	}
+
+	return q, nil
+}
+
+// stringToRRType converts a string into a DNS type constant.
+// The string can be a number in the [1, 65535] range, or a canonical type
+// string (case-insensitive, such as "A" or "aaaa").
+func stringToRRType(s string) (uint16, error) {
+	i, err := strconv.ParseInt(s, 10, 16)
+	if err == nil {
+		if 1 <= i && i <= 65535 {
+			return uint16(i), nil
+		}
+		return 0, fmt.Errorf("invalid type (int out of range)")
+	}
+
+	rrType, ok := dns.StringToType[strings.ToUpper(s)]
+	if !ok {
+		return 0, fmt.Errorf("invalid type (unknown string type)")
+	}
+	return rrType, nil
+}
+
+func stringToBool(s string) (bool, error) {
+	switch strings.ToLower(s) {
+	case "", "1", "true":
+		// Note the empty string is intentionally considered true, as long as
+		// the parameter is present in the query.
+		return true, nil
+	case "0", "false":
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid cd value")
+}