diff --git a/net/dns/publicdns/publicdns.go b/net/dns/publicdns/publicdns.go index 3666bd778..571e21348 100644 --- a/net/dns/publicdns/publicdns.go +++ b/net/dns/publicdns/publicdns.go @@ -7,15 +7,12 @@ import ( "bytes" - "encoding/binary" "encoding/hex" "fmt" - "log" "math/big" "net/netip" "slices" "sort" - "strconv" "strings" "sync" @@ -62,6 +59,11 @@ func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) { // Control D DoH URLs are of the form "https://dns.controld.com/8yezwenugs" // where the path component is represented by 8 bytes (7-14) of the IPv6 address in base36 + // + // TODO(#20433): the ID-encoded addresses in this /48 are legacy port-53-only + // endpoints and refuse DoH on :443, so upgrading them to DoH is wrong. Only the + // shared anycast addresses (e.g. freedns.controld.com/pN) actually serve DoH. + // Distinguish the two rather than mapping the whole range. if controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) { path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36) return controlDBase + path, true, true @@ -126,15 +128,12 @@ func DoHIPsOfBase(dohBase string) []netip.Addr { } } } - if pathStr, ok := strings.CutPrefix(dohBase, controlDBase); ok { - if i := strings.IndexFunc(pathStr, isSlashOrQuestionMark); i != -1 { - pathStr = pathStr[:i] - } + if strings.HasPrefix(dohBase, controlDBase) { return []netip.Addr{ controlDv4One, controlDv4Two, - controlDv6Gen(controlDv6RangeA.Addr(), pathStr), - controlDv6Gen(controlDv6RangeB.Addr(), pathStr), + controlDv6One, + controlDv6Two, } } return nil @@ -330,6 +329,8 @@ func populate() { controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48") controlDv4One = netip.MustParseAddr("76.76.2.22") controlDv4Two = netip.MustParseAddr("76.76.10.22") + controlDv6One = netip.MustParseAddr("2606:1a40::22") + controlDv6Two = netip.MustParseAddr("2606:1a40:1::22") ) // nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the @@ -343,23 +344,6 @@ func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr { return netip.AddrFrom16(a) } -// controlDv6Gen generates a Control D IPv6 address from provided ip and id. -// -// The id is taken from the DoH query path component and represents a unique resolver configuration. -// e.g. https://dns.controld.com/hyq3ipr2ct -func controlDv6Gen(ip netip.Addr, id string) netip.Addr { - b := make([]byte, 8) - decoded, err := strconv.ParseUint(id, 36, 64) - if err != nil { - log.Printf("controlDv6Gen: failed to parse id %q: %v", id, err) - } - binary.BigEndian.PutUint64(b, decoded) - a := ip.AsSlice() - copy(a[6:14], b) - addr, _ := netip.AddrFromSlice(a) - return addr -} - // IPIsDoHOnlyServer reports whether ip is a DNS server that should only use // DNS-over-HTTPS (not regular port 53 DNS). func IPIsDoHOnlyServer(ip netip.Addr) bool { diff --git a/net/dns/publicdns/publicdns_test.go b/net/dns/publicdns/publicdns_test.go index 4f494930b..7a4cc06a3 100644 --- a/net/dns/publicdns/publicdns_test.go +++ b/net/dns/publicdns/publicdns_test.go @@ -121,8 +121,8 @@ func TestDoHIPsOfBase(t *testing.T) { want: ips( "76.76.2.22", "76.76.10.22", - "2606:1a40:0:6:7b5b:5949:35ad:0", - "2606:1a40:1:6:7b5b:5949:35ad:0", + "2606:1a40::22", + "2606:1a40:1::22", ), }, { @@ -130,8 +130,8 @@ func TestDoHIPsOfBase(t *testing.T) { want: ips( "76.76.2.22", "76.76.10.22", - "2606:1a40:0:ffff:ffff:ffff:ffff:0", - "2606:1a40:1:ffff:ffff:ffff:ffff:0", + "2606:1a40::22", + "2606:1a40:1::22", ), }, { @@ -139,8 +139,8 @@ func TestDoHIPsOfBase(t *testing.T) { want: ips( "76.76.2.22", "76.76.10.22", - "2606:1a40:0:6:7b5b:5949:35ad:0", - "2606:1a40:1:6:7b5b:5949:35ad:0", + "2606:1a40::22", + "2606:1a40:1::22", ), }, } diff --git a/net/dns/resolver/forwarder_test.go b/net/dns/resolver/forwarder_test.go index fd96b3989..17f2732a3 100644 --- a/net/dns/resolver/forwarder_test.go +++ b/net/dns/resolver/forwarder_test.go @@ -12,6 +12,7 @@ "io" "net" "net/netip" + "os" "reflect" "slices" "strings" @@ -23,6 +24,7 @@ dns "golang.org/x/net/dns/dnsmessage" "tailscale.com/control/controlknobs" "tailscale.com/health" + "tailscale.com/net/dns/publicdns" "tailscale.com/net/netmon" "tailscale.com/net/tsdial" "tailscale.com/tstest" @@ -226,6 +228,84 @@ func TestGetKnownDoHClientForProvider(t *testing.T) { t.Logf("Got: %+v", res) } +// TestControlDPremiumDoHLive exercises the real DoH dial path against Control D's +// live infrastructure for a premium resolver, to confirm end-to-end that we use +// reachable DoH endpoints (see ESC-30: we previously synthesized per-resolver +// IPv6 addresses that only speak plaintext DNS on port 53 and refuse :443). +// +// It is disabled by default and only runs when TS_TEST_CONTROLD_RESOLVER_ID is +// set to a Control D premium resolver ID (the path component of a +// https://dns.controld.com/ DoH URL). For example: +// +// TS_TEST_CONTROLD_RESOLVER_ID=abc123 go test ./net/dns/resolver/ \ +// -run TestControlDPremiumDoHLive -v +// +// To reproduce the pre-fix failure, temporarily revert DoHIPsOfBase to return +// the controlDv6Gen-synthesized addresses: on an IPv6-only network this test +// then fails with a connection error instead of a successful response. +func TestControlDPremiumDoHLive(t *testing.T) { + id := os.Getenv("TS_TEST_CONTROLD_RESOLVER_ID") + if id == "" { + t.Skip("set TS_TEST_CONTROLD_RESOLVER_ID= to run") + } + + logf := tstest.WhileTestRunningLogger(t) + bus := eventbustest.NewBus(t) + netMon, err := netmon.New(bus, logf) + if err != nil { + t.Fatal(err) + } + var dialer tsdial.Dialer + dialer.SetNetMon(netMon) + dialer.SetBus(bus) + fwd := newForwarder(logf, netMon, nil, &dialer, health.NewTracker(bus), nil) + + urlBase := "https://dns.controld.com/" + id + c, ok := fwd.getKnownDoHClientForProvider(urlBase) + if !ok { + t.Fatalf("no known DoH client for %q", urlBase) + } + t.Logf("dialing DoH IPs: %v", publicdns.DoHIPsOfBase(urlBase)) + + // Build an A query for example.com. + builder := dns.NewBuilder(nil, dns.Header{RecursionDesired: true}) + builder.StartQuestions() + if err := builder.Question(dns.Question{ + Name: dns.MustNewName("example.com."), + Type: dns.TypeA, + Class: dns.ClassINET, + }); err != nil { + t.Fatal(err) + } + query, err := builder.Finish() + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + res, err := fwd.sendDoH(ctx, urlBase, c, query) + if err != nil { + t.Fatalf("sendDoH: %v", err) + } + if rcode := getRCode(res); rcode != dns.RCodeSuccess { + t.Fatalf("got rcode %v, want success", rcode) + } + + var p dns.Parser + if _, err := p.Start(res); err != nil { + t.Fatalf("parsing response: %v", err) + } + if err := p.SkipAllQuestions(); err != nil { + t.Fatalf("skipping questions: %v", err) + } + if _, err := p.AnswerHeader(); err != nil { + t.Fatalf("no answers returned: %v", err) + } + t.Logf("ControlD premium DoH query for example.com succeeded (%d bytes)", len(res)) +} + func BenchmarkNameFromQuery(b *testing.B) { builder := dns.NewBuilder(nil, dns.Header{}) builder.StartQuestions()