Files
ctrld/config_internal_test.go
T
Cuong Manh Le 682c6c8a29 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.
2026-09-22 21:08:18 +07:00

622 lines
15 KiB
Go

package ctrld
import (
"context"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
}{
{
name: "doh/doh3",
uc: &UpstreamConfig{
Name: "doh",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p2",
Timeout: 5000,
},
},
{
name: "doq/dot",
uc: &UpstreamConfig{
Name: "dot",
Type: ResolverTypeDOT,
Endpoint: "p2.freedns.controld.com",
Timeout: 5000,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
// t.Parallel()
tc.uc.Init(context.Background())
tc.uc.SetupBootstrapIP(context.Background())
if len(tc.uc.bootstrapIPs) == 0 {
t.Log(defaultNameservers(context.Background()))
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
}
})
}
}
func TestUpstreamConfig_Init(t *testing.T) {
u1, _ := url.Parse("https://example.com")
u2, _ := url.Parse("https://example.com?k=v")
u3, _ := url.Parse("https://freedns.controld.com/p1")
tests := []struct {
name string
uc *UpstreamConfig
expected *UpstreamConfig
}{
{
"doh+doh3",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"doh+doh3 with query param",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u2,
},
},
{
"dot+doq",
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackSplit,
},
},
{
"dot+doq without port",
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackSplit,
},
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackSplit,
},
},
{
"legacy",
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"legacy without port",
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"doh+doh3 with send client info set",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "",
Timeout: 0,
SendClientInfo: ptrBool(false),
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
SendClientInfo: ptrBool(false),
IPStack: IpStackBoth,
u: u2,
},
},
{
"h3",
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "h3://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"h3 without type",
&UpstreamConfig{
Name: "doh3",
Endpoint: "h3://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"sdns -> doh",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AgMAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29tAy9wMQ",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "doh",
Endpoint: "https://freedns.controld.com/p1",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u3,
},
},
{
"sdns -> dot",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AwcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "dot",
Endpoint: "freedns.controld.com:843",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns -> doq",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://BAcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "doq",
Endpoint: "freedns.controld.com:784",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns -> legacy",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "legacy",
Endpoint: "76.76.2.11:53",
BootstrapIP: "76.76.2.11",
Domain: "76.76.2.11",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns without type",
&UpstreamConfig{
Name: "sdns",
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "legacy",
Endpoint: "76.76.2.11:53",
BootstrapIP: "76.76.2.11",
Domain: "76.76.2.11",
Timeout: 0,
IPStack: IpStackBoth,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init(context.Background())
tc.uc.uid = "" // we don't care about the uid.
assert.Equal(t, tc.expected, tc.uc)
})
}
}
func TestUpstreamConfig_VerifyDomain(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
verifyDomain string
}{
{
controlDComDomain,
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2"},
controldVerifiedDomain[controlDComDomain],
},
{
controlDDevDomain,
&UpstreamConfig{Endpoint: "https://freedns.controld.dev/p2"},
controldVerifiedDomain[controlDDevDomain],
},
{
"non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query"},
"",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.uc.VerifyDomain(); got != tc.verifyDomain {
t.Errorf("unexpected verify domain, want: %q, got: %q", tc.verifyDomain, got)
}
})
}
}
// 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
uc *UpstreamConfig
sendClientInfo bool
}{
{
"default with controld upstream DoH",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH},
true,
},
{
"default with controld upstream DoH3",
&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},
false,
},
{
"set false with controld upstream",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH, SendClientInfo: ptrBool(false)},
false,
},
{
"set true with controld upstream",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", SendClientInfo: ptrBool(true)},
true,
},
{
"set false with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", SendClientInfo: ptrBool(false)},
false,
},
{
"set true with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH, SendClientInfo: ptrBool(true)},
true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.uc.UpstreamSendClientInfo(); got != tc.sendClientInfo {
t.Errorf("unexpected result, want: %v, got: %v", tc.sendClientInfo, got)
}
})
}
}
func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
discoverable bool
}{
{
"loopback",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy},
true,
},
{
"rfc1918",
&UpstreamConfig{Endpoint: "192.168.1.1", Type: ResolverTypeLegacy},
true,
},
{
"CGNAT",
&UpstreamConfig{Endpoint: "100.66.67.68", Type: ResolverTypeLegacy},
true,
},
{
"Public IP",
&UpstreamConfig{Endpoint: "8.8.8.8", Type: ResolverTypeLegacy},
false,
},
{
"override discoverable",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(false)},
false,
},
{
"override non-public",
&UpstreamConfig{Endpoint: "1.1.1.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(true)},
true,
},
{
"non-legacy upstream",
&UpstreamConfig{Endpoint: "https://192.168.1.1/custom-doh", Type: ResolverTypeDOH},
false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init(context.Background())
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
}
})
}
}
func TestRebootstrapRace(t *testing.T) {
uc := &UpstreamConfig{
Name: "test-doh",
Type: ResolverTypeDOH,
Endpoint: "https://example.com/dns-query",
Domain: "example.com",
bootstrapIPs: []string{"1.1.1.1", "1.0.0.1"},
}
ctx := LoggerCtx(context.Background(), NopLogger)
uc.SetupTransport(ctx)
if uc.transport == nil {
t.Fatal("initial transport should be set")
}
const goroutines = 100
uc.ReBootstrap(ctx)
started := make(chan struct{})
go func() {
close(started)
for {
switch uc.rebootstrap.Load() {
case rebootstrapStarted, rebootstrapInProgress:
uc.ReBootstrap(ctx)
default:
return
}
}
}()
<-started
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
uc.ensureSetupTransport(ctx)
})
}
wg.Wait()
}
func ptrBool(b bool) *bool {
return &b
}