feat: recognize every NextDNS endpoint under nextdns.io

NextDNS serves alternative DoH endpoints beside dns.nextdns.io: the
ultralow and anycast variants of dns, dns1 and dns2. They are the same
service and take the same client info headers, but isNextDNS matched
the one host, so an upstream pointed at any of them was treated as a
third party and sent no client info.

Recognition now follows the parent domain through dns.IsSubDomain, as
IsControlD already does for the ControlD domains. The label by label
comparison is case insensitive and keeps lookalikes such as
notnextdns.io and nextdns.io.example.com out.

Based on the change proposed by Mike (Github username @mike406).
See: https://github.com/Control-D-Inc/ctrld/pull/335.
This commit is contained in:
Cuong Manh Le committed 2026-09-22 21:08:18 +07:00
1 parent 4879574605
commit 682c6c8a29
2 files changed
+71 -1

No files matched your search

+8 -1
View File
@@ -78,6 +78,12 @@ const (
controlDNetDomain = "controld.net"
controlDDevDomain = "controld.dev"
// nextDNSDomain is the parent domain of the NextDNS DoH endpoints. Beside
// dns.nextdns.io, NextDNS serves alternative endpoints under it, such as
// ultralow.dns.nextdns.io and anycast.dns2.nextdns.io, which are the same
// service and take the same client info headers.
nextDNSDomain = "nextdns.io"
endpointPrefixHTTPS = "https://"
endpointPrefixQUIC = "quic://"
endpointPrefixH3 = "h3://"
@@ -761,6 +767,7 @@ func (uc *UpstreamConfig) IsControlD() bool {
return false
}
// isNextDNS reports whether this is a NextDNS upstream.
func (uc *UpstreamConfig) isNextDNS() bool {
domain := uc.Domain
if domain == "" {
@@ -768,7 +775,7 @@ func (uc *UpstreamConfig) isNextDNS() bool {
domain = u.Hostname()
}
}
return domain == "dns.nextdns.io"
return dns.IsSubDomain(nextDNSDomain, domain)
}
func (uc *UpstreamConfig) dohTransport(ctx context.Context, dnsType uint16) http.RoundTripper {
+63
View File
@@ -398,6 +398,59 @@ func TestUpstreamConfig_VerifyDomain(t *testing.T) {
}
}
// TestUpstreamConfig_isNextDNS is a regression test for issue-610: NextDNS
// serves its alternative DoH endpoints, the "ultralow" and "anycast" variants,
// under nextdns.io, so recognition follows the parent domain instead of the
// single dns.nextdns.io host. Each case runs through the Domain field and
// through the endpoint-hostname fallback, the two ways an upstream names its
// host.
func TestUpstreamConfig_isNextDNS(t *testing.T) {
tests := []struct {
name string
domain string
want bool
}{
{"the default endpoint", "dns.nextdns.io", true},
{"the numbered endpoints", "dns1.nextdns.io", true},
{"the second numbered endpoint", "dns2.nextdns.io", true},
{"an ultralow endpoint", "ultralow.dns.nextdns.io", true},
{"a numbered ultralow endpoint", "ultralow.dns1.nextdns.io", true},
{"the second numbered ultralow endpoint", "ultralow.dns2.nextdns.io", true},
{"an anycast endpoint", "anycast.dns.nextdns.io", true},
{"a numbered anycast endpoint", "anycast.dns1.nextdns.io", true},
{"the second numbered anycast endpoint", "anycast.dns2.nextdns.io", true},
{"a mixed case endpoint", "UltraLow.DNS.NextDNS.IO", true},
{"the parent domain itself", "nextdns.io", true},
{"another TLD", "dns.nextdns.com", false},
{"a domain ending in the same labels", "notnextdns.io", false},
{"the parent domain as a prefix", "xnextdns.io", false},
{"the parent domain under another one", "nextdns.io.example.com", false},
{"an unrelated resolver", "dns.google", false},
{"a ControlD upstream", "freedns.controld.com", false},
{"no host at all", "", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name+" as a domain", func(t *testing.T) {
t.Parallel()
uc := &UpstreamConfig{Domain: tc.domain}
if got := uc.isNextDNS(); got != tc.want {
t.Errorf("isNextDNS() = %v for domain %q, want %v", got, tc.domain, tc.want)
}
})
t.Run(tc.name+" as an endpoint", func(t *testing.T) {
t.Parallel()
// Domain is empty, so the lookup falls back to the endpoint host.
uc := &UpstreamConfig{Endpoint: "https://" + tc.domain + "/abc123"}
if got := uc.isNextDNS(); got != tc.want {
t.Errorf("isNextDNS() = %v for endpoint %q, want %v", got, uc.Endpoint, tc.want)
}
})
}
}
func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
tests := []struct {
name string
@@ -414,6 +467,16 @@ func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH3},
true,
},
{
"default with nextdns upstream DoH",
&UpstreamConfig{Endpoint: "https://dns.nextdns.io/abc123", Type: ResolverTypeDOH},
true,
},
{
"default with nextdns ultralow upstream DoH",
&UpstreamConfig{Endpoint: "https://ultralow.dns2.nextdns.io/abc123", Type: ResolverTypeDOH},
true,
},
{
"default with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH},