net/dns/publicdns: use Control D anycast IPs for premium DoH (#20434)
For dns.controld.com premium resolvers we synthesized per-resolver IPv6 addresses by encoding the resolver ID into the 2606:1a40::/48 range, but those are legacy plaintext-DNS (port 53) endpoints that refuse TCP :443. DoH now dials Control D's shared anycast IPs (the resolver ID stays in the URL path), fixing SERVFAIL on IPv6-only/NAT64 networks where the v4 anycast fallback isn't reachable. Fixes #20430 Signed-off-by: Brendan Creane <bcreane@gmail.com>
This commit is contained in:
@@ -7,15 +7,12 @@ package publicdns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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"
|
// 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
|
// 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) {
|
if controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) {
|
||||||
path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36)
|
path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36)
|
||||||
return controlDBase + path, true, true
|
return controlDBase + path, true, true
|
||||||
@@ -126,15 +128,12 @@ func DoHIPsOfBase(dohBase string) []netip.Addr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pathStr, ok := strings.CutPrefix(dohBase, controlDBase); ok {
|
if strings.HasPrefix(dohBase, controlDBase) {
|
||||||
if i := strings.IndexFunc(pathStr, isSlashOrQuestionMark); i != -1 {
|
|
||||||
pathStr = pathStr[:i]
|
|
||||||
}
|
|
||||||
return []netip.Addr{
|
return []netip.Addr{
|
||||||
controlDv4One,
|
controlDv4One,
|
||||||
controlDv4Two,
|
controlDv4Two,
|
||||||
controlDv6Gen(controlDv6RangeA.Addr(), pathStr),
|
controlDv6One,
|
||||||
controlDv6Gen(controlDv6RangeB.Addr(), pathStr),
|
controlDv6Two,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -330,6 +329,8 @@ var (
|
|||||||
controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48")
|
controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48")
|
||||||
controlDv4One = netip.MustParseAddr("76.76.2.22")
|
controlDv4One = netip.MustParseAddr("76.76.2.22")
|
||||||
controlDv4Two = netip.MustParseAddr("76.76.10.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
|
// 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)
|
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
|
// IPIsDoHOnlyServer reports whether ip is a DNS server that should only use
|
||||||
// DNS-over-HTTPS (not regular port 53 DNS).
|
// DNS-over-HTTPS (not regular port 53 DNS).
|
||||||
func IPIsDoHOnlyServer(ip netip.Addr) bool {
|
func IPIsDoHOnlyServer(ip netip.Addr) bool {
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ func TestDoHIPsOfBase(t *testing.T) {
|
|||||||
want: ips(
|
want: ips(
|
||||||
"76.76.2.22",
|
"76.76.2.22",
|
||||||
"76.76.10.22",
|
"76.76.10.22",
|
||||||
"2606:1a40:0:6:7b5b:5949:35ad:0",
|
"2606:1a40::22",
|
||||||
"2606:1a40:1:6:7b5b:5949:35ad:0",
|
"2606:1a40:1::22",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,8 +130,8 @@ func TestDoHIPsOfBase(t *testing.T) {
|
|||||||
want: ips(
|
want: ips(
|
||||||
"76.76.2.22",
|
"76.76.2.22",
|
||||||
"76.76.10.22",
|
"76.76.10.22",
|
||||||
"2606:1a40:0:ffff:ffff:ffff:ffff:0",
|
"2606:1a40::22",
|
||||||
"2606:1a40:1:ffff:ffff:ffff:ffff:0",
|
"2606:1a40:1::22",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,8 +139,8 @@ func TestDoHIPsOfBase(t *testing.T) {
|
|||||||
want: ips(
|
want: ips(
|
||||||
"76.76.2.22",
|
"76.76.2.22",
|
||||||
"76.76.10.22",
|
"76.76.10.22",
|
||||||
"2606:1a40:0:6:7b5b:5949:35ad:0",
|
"2606:1a40::22",
|
||||||
"2606:1a40:1:6:7b5b:5949:35ad:0",
|
"2606:1a40:1::22",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
dns "golang.org/x/net/dns/dnsmessage"
|
dns "golang.org/x/net/dns/dnsmessage"
|
||||||
"tailscale.com/control/controlknobs"
|
"tailscale.com/control/controlknobs"
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/net/dns/publicdns"
|
||||||
"tailscale.com/net/netmon"
|
"tailscale.com/net/netmon"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
@@ -226,6 +228,84 @@ func TestGetKnownDoHClientForProvider(t *testing.T) {
|
|||||||
t.Logf("Got: %+v", res)
|
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/<id> 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=<premium 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) {
|
func BenchmarkNameFromQuery(b *testing.B) {
|
||||||
builder := dns.NewBuilder(nil, dns.Header{})
|
builder := dns.NewBuilder(nil, dns.Header{})
|
||||||
builder.StartQuestions()
|
builder.StartQuestions()
|
||||||
|
|||||||
Reference in New Issue
Block a user