author | Alberto Bertogli
<albertito@blitiri.com.ar> 2017-07-20 20:44:08 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2017-07-30 12:03:20 UTC |
parent | bea88d7c4c7460fa5bb38ee030e4f027f3617258 |
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") +}