mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 13:05:46 +00:00
net/dns/resolver: upgrade forwarded MagicDNS queries to DoH when IP known
Recognize Cloudflare, Google, Quad9 which are by far the majority of upstream DNS servers that people use. RELNOTE=MagicDNS now uses DNS-over-HTTPS when querying popular upstream resolvers, so DNS queries aren't sent in the clear over the Internet. Updates #915 (might fix it?) Updates #988 (gets us closer, if it fixes Android) Updates #74 (not yet configurable, but progress) Updates #2056 (not yet configurable, dup of #74?) Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
4f89fe17a2
commit
236eb4d04d
83
net/dns/resolver/doh_test.go
Normal file
83
net/dns/resolver/doh_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
)
|
||||
|
||||
var testDoH = flag.Bool("test-doh", false, "do real DoH tests against the network")
|
||||
|
||||
const someDNSID = 123 // something non-zero as a test; in violation of spec's SHOULD of 0
|
||||
|
||||
func someDNSQuestion(t testing.TB) []byte {
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
|
||||
OpCode: 0, // query
|
||||
RecursionDesired: true,
|
||||
ID: someDNSID,
|
||||
})
|
||||
b.StartQuestions() // err
|
||||
b.Question(dnsmessage.Question{
|
||||
Name: dnsmessage.MustNewName("tailscale.com."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
})
|
||||
msg, err := b.Finish()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func TestDoH(t *testing.T) {
|
||||
if !*testDoH {
|
||||
t.Skip("skipping manual test without --test-doh flag")
|
||||
}
|
||||
if len(knownDoH) == 0 {
|
||||
t.Fatal("no known DoH")
|
||||
}
|
||||
|
||||
f := new(forwarder)
|
||||
|
||||
for ip := range knownDoH {
|
||||
t.Run(ip.String(), func(t *testing.T) {
|
||||
urlBase, c, ok := f.getDoHClient(ip)
|
||||
if !ok {
|
||||
t.Fatal("expected DoH")
|
||||
}
|
||||
res, err := f.sendDoH(context.Background(), urlBase, c, someDNSQuestion(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Transport.(*http.Transport).CloseIdleConnections()
|
||||
|
||||
var p dnsmessage.Parser
|
||||
h, err := p.Start(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.ID != someDNSID {
|
||||
t.Errorf("response DNS ID = %v; want %v", h.ID, someDNSID)
|
||||
}
|
||||
|
||||
p.SkipAllQuestions()
|
||||
aa, err := p.AllAnswers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(aa) == 0 {
|
||||
t.Fatal("no answers")
|
||||
}
|
||||
for _, r := range aa {
|
||||
t.Logf("got: %v", r.GoString())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -9,15 +9,20 @@
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
dns "golang.org/x/net/dns/dnsmessage"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/wgengine/monitor"
|
||||
@ -29,6 +34,11 @@
|
||||
const (
|
||||
// responseTimeout is the maximal amount of time to wait for a DNS response.
|
||||
responseTimeout = 5 * time.Second
|
||||
|
||||
// dohTransportTimeout is how long to keep idle HTTP
|
||||
// connections open to DNS-over-HTTPs servers. This is pretty
|
||||
// arbitrary.
|
||||
dohTransportTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errNoUpstreams = errors.New("upstream nameservers not set")
|
||||
@ -158,6 +168,8 @@ type forwarder struct {
|
||||
|
||||
mu sync.Mutex // guards following
|
||||
|
||||
dohClient map[netaddr.IP]*http.Client
|
||||
|
||||
// routes are per-suffix resolvers to use, with
|
||||
// the most specific routes first.
|
||||
routes []route
|
||||
@ -210,6 +222,63 @@ func (f *forwarder) packetListener(ip netaddr.IP) (packetListener, error) {
|
||||
return lc, nil
|
||||
}
|
||||
|
||||
func (f *forwarder) getDoHClient(ip netaddr.IP) (urlBase string, c *http.Client, ok bool) {
|
||||
urlBase, ok = knownDoH[ip]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if c, ok := f.dohClient[ip]; ok {
|
||||
return urlBase, c, true
|
||||
}
|
||||
if f.dohClient == nil {
|
||||
f.dohClient = map[netaddr.IP]*http.Client{}
|
||||
}
|
||||
nsDialer := netns.NewDialer()
|
||||
c = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
IdleConnTimeout: dohTransportTimeout,
|
||||
DialContext: func(ctx context.Context, netw, addr string) (net.Conn, error) {
|
||||
if !strings.HasPrefix(netw, "tcp") {
|
||||
return nil, fmt.Errorf("unexpected network %q", netw)
|
||||
}
|
||||
return nsDialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), "443"))
|
||||
},
|
||||
},
|
||||
}
|
||||
f.dohClient[ip] = c
|
||||
return urlBase, c, true
|
||||
}
|
||||
|
||||
const dohType = "application/dns-message"
|
||||
|
||||
func (f *forwarder) sendDoH(ctx context.Context, urlBase string, c *http.Client, packet []byte) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", urlBase, bytes.NewReader(packet))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", dohType)
|
||||
// Note: we don't currently set the Accept header (which is
|
||||
// only a SHOULD in the spec) as iOS doesn't use HTTP/2 and
|
||||
// we'd rather save a few bytes on outgoing requests when
|
||||
// empirically no provider cares about the Accept header's
|
||||
// absence.
|
||||
|
||||
hres, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer hres.Body.Close()
|
||||
if hres.StatusCode != 200 {
|
||||
return nil, errors.New(hres.Status)
|
||||
}
|
||||
if ct := hres.Header.Get("Content-Type"); ct != dohType {
|
||||
return nil, fmt.Errorf("unexpected response Content-Type %q", ct)
|
||||
}
|
||||
return ioutil.ReadAll(hres.Body)
|
||||
}
|
||||
|
||||
// send sends packet to dst. It is best effort.
|
||||
//
|
||||
// send expects the reply to have the same txid as txidOut.
|
||||
@ -220,9 +289,14 @@ func (f *forwarder) packetListener(ip netaddr.IP) (packetListener, error) {
|
||||
// iOS and we want the number of pending DNS lookups to be bursty
|
||||
// without too much associated goroutine/memory cost.
|
||||
func (f *forwarder) send(ctx context.Context, txidOut txid, closeOnCtxDone *closePool, packet []byte, dst netaddr.IPPort) ([]byte, error) {
|
||||
// TODO(bradfitz): if dst.IP is 8.8.8.8 or 8.8.4.4 or 1.1.1.1, etc, or
|
||||
// something dynamically probed earlier to support DoH or DoT,
|
||||
// do that here instead.
|
||||
// Upgrade known DNS IPs to DoH (DNS-over-HTTPs).
|
||||
if urlBase, dc, ok := f.getDoHClient(dst.IP()); ok {
|
||||
res, err := f.sendDoH(ctx, urlBase, dc, packet)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return res, err
|
||||
}
|
||||
f.logf("DoH error from %v: %v", dst.IP, err)
|
||||
}
|
||||
|
||||
ln, err := f.packetListener(dst.IP())
|
||||
if err != nil {
|
||||
@ -432,3 +506,48 @@ func (p *closePool) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var knownDoH = map[netaddr.IP]string{}
|
||||
|
||||
func addDoH(ip, base string) { knownDoH[netaddr.MustParseIP(ip)] = base }
|
||||
|
||||
func init() {
|
||||
// Cloudflare
|
||||
addDoH("1.1.1.1", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("1.0.0.1", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1111", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1001", "https://cloudflare-dns.com/dns-query")
|
||||
|
||||
// Cloudflare -Malware
|
||||
addDoH("1.1.1.2", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("1.0.0.2", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1112", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1002", "https://cloudflare-dns.com/dns-query")
|
||||
|
||||
// Cloudflare -Malware -Adult
|
||||
addDoH("1.1.1.3", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("1.0.0.3", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1113", "https://cloudflare-dns.com/dns-query")
|
||||
addDoH("2606:4700:4700::1003", "https://cloudflare-dns.com/dns-query")
|
||||
|
||||
// Google
|
||||
addDoH("8.8.8.8", "https://dns.google/dns-query")
|
||||
addDoH("8.8.4.4", "https://dns.google/dns-query")
|
||||
addDoH("2001:4860:4860::8888", "https://dns.google/dns-query")
|
||||
addDoH("2001:4860:4860::8844", "https://dns.google/dns-query")
|
||||
|
||||
// OpenDNS
|
||||
// TODO(bradfitz): OpenDNS is unique amongst this current set in that
|
||||
// its DoH DNS names resolve to different IPs than its normal DNS
|
||||
// IPs. Support that later. For now we assume that they're the same.
|
||||
// addDoH("208.67.222.222", "https://doh.opendns.com/dns-query")
|
||||
// addDoH("208.67.220.220", "https://doh.opendns.com/dns-query")
|
||||
// addDoH("208.67.222.123", "https://doh.familyshield.opendns.com/dns-query")
|
||||
// addDoH("208.67.220.123", "https://doh.familyshield.opendns.com/dns-query")
|
||||
|
||||
// Quad9
|
||||
addDoH("9.9.9.9", "https://dns.quad9.net/dns-query")
|
||||
addDoH("149.112.112.112", "https://dns.quad9.net/dns-query")
|
||||
addDoH("2620:fe::fe", "https://dns.quad9.net/dns-query")
|
||||
addDoH("2620:fe::fe:9", "https://dns.quad9.net/dns-query")
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user