net/dns/publicdns: don't upgrade Control D port-53-only addresses to DoH (#20463)

DoHEndpointFromIP mapped the entire 2606:1a40::/48 range to a
dns.controld.com/<id> DoH URL, but the ID-encoded addresses in that range
are legacy plaintext-DNS endpoints that refuse :443. They now fall through
as ordinary port-53 resolvers; the free anycast freedns.controld.com/pN
addresses still upgrade via exact match.

Fixes #20433

Signed-off-by: Brendan Creane <bcreane@gmail.com>
This commit is contained in:
Brendan Creane 2026-07-17 15:33:17 -07:00 committed by GitHub
parent 689c6c2e6d
commit d2af6a4d39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 60 additions and 13 deletions

View File

@ -9,7 +9,6 @@
"bytes"
"encoding/hex"
"fmt"
"math/big"
"net/netip"
"slices"
"sort"
@ -57,17 +56,15 @@ func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) {
return sb.String(), true, true
}
// 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
// Control D's free anycast resolvers (freedns.controld.com/pN) are the only
// Control D addresses that serve DoH; they're registered as exact entries in
// dohOfIP (see populate) and are handled by the lookup above.
//
// 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
}
// The ID-encoded addresses in the 2606:1a40::/48 ranges (customer resolver ID
// base36-encoded into the address) are legacy plaintext-DNS (port 53) endpoints
// that refuse DoH on :443, so we deliberately don't upgrade them to DoH here:
// they fall through and are used as ordinary port-53 resolvers. Premium DoH is
// only reachable via the dns.controld.com/<id> URL path (see DoHIPsOfBase).
return "", false, false
}

View File

@ -28,6 +28,46 @@ func TestInit(t *testing.T) {
}
}
func TestDoHEndpointFromIP(t *testing.T) {
tests := []struct {
name string
ip string
wantBase string
wantOnly bool
wantOK bool
}{
// Control D free anycast resolvers (freedns.controld.com/pN) serve DoH
// and are dual-stack (also speak port 53), so dohOnly is false.
{"controld_free_v6_p0", "2606:1a40::", "https://freedns.controld.com/p0", false, true},
{"controld_free_v6_p1", "2606:1a40:1::1", "https://freedns.controld.com/p1", false, true},
{"controld_free_v4_p1", "76.76.2.1", "https://freedns.controld.com/p1", false, true},
{"controld_free_v4_family", "76.76.10.4", "https://freedns.controld.com/family", false, true},
// ID-encoded addresses in the Control D /48 ranges are legacy
// plaintext-DNS (port 53) endpoints that refuse DoH, so they must not be
// upgraded to DoH; DoHEndpointFromIP reports them as unknown.
{"controld_id_encoded_a", "2606:1a40:0:0:0:1234:5678:9abc", "", false, false},
{"controld_id_encoded_b", "2606:1a40:1:0:0:1234:5678:9abc", "", false, false},
// The premium anycast address has no resolver ID in the bare IP, so it
// can't be mapped to a DoH URL here either.
{"controld_premium_anycast", "2606:1a40::22", "", false, false},
// A well-known dual-stack DoH server upgrades but isn't dohOnly.
{"google_v4", "8.8.8.8", "https://dns.google/dns-query", false, true},
// A NextDNS ID-encoded address is DoH-only.
{"nextdns_v6", "2a07:a8c0::c3:a884", "https://dns.nextdns.io/c3a884", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base, only, ok := DoHEndpointFromIP(netip.MustParseAddr(tt.ip))
if base != tt.wantBase || only != tt.wantOnly || ok != tt.wantOK {
t.Errorf("DoHEndpointFromIP(%s) = (%q, %v, %v); want (%q, %v, %v)",
tt.ip, base, only, ok, tt.wantBase, tt.wantOnly, tt.wantOK)
}
})
}
}
func TestDoHV6(t *testing.T) {
tests := []struct {
in string

View File

@ -146,9 +146,19 @@ func TestResolversWithDelays(t *testing.T) {
want: o("https://dns.nextdns.io/c3a884"),
},
{
name: "controld-ipv6-expand",
// ID-encoded Control D addresses are legacy port-53-only
// endpoints (see #20433); they must not be upgraded to DoH and
// instead pass through as ordinary port-53 resolvers.
name: "controld-ipv6-id-encoded-not-doh",
in: q("2606:1a40:0:6:7b5b:5949:35ad:0"),
want: o("https://dns.controld.com/hyq3ipr2ct"),
want: o("2606:1a40:0:6:7b5b:5949:35ad:0"),
},
{
// The free anycast resolvers (freedns.controld.com/pN) do serve
// DoH and are upgraded.
name: "controld-free-anycast-doh",
in: q("2606:1a40::1"),
want: o("https://freedns.controld.com/p1", "2606:1a40::1+0.5s"),
},
{
name: "controld-doh-input",