Files
ctrld/config_bootstrap_test.go
T
Cuong Manh Le 1da3fcf269 fix: recover upstreams from a partial bootstrap lookup
A bootstrap lookup that returned only one address family, such as an
AAAA-only answer for a split-stack Control D upstream, was accepted as
is, and re-bootstrap rebuilt the transports from the same lists, so A
queries failed with "empty addresses" until a restart.

Bootstrap now checks the result against what the ip_stack needs and
fills a missing family from the Control D direct-IP table and bootstrap
server. If it is still missing, the next re-bootstrap looks it up again,
and a split transport borrows the other family meanwhile.

After the DoH direct-IP fallback fires, DoH and DoH3 also dial the
Control D direct IPs for the ip_stack's families when the bootstrap
addresses are empty or fail, keeping the domain for TLS. A working list
always wins, so nothing is pinned. DoT always verifies the server as
the upstream domain.
2026-10-20 17:23:47 +07:00

335 lines
13 KiB
Go

package ctrld
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"slices"
"strconv"
"testing"
"time"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
// useBootstrapLookup replaces the bootstrap lookup. system answers lookups
// through the system nameservers, controlD lookups through the Control D
// bootstrap server; the test changes them through the returned pointers.
func useBootstrapLookup(t *testing.T, system, controlD []string) (*[]string, *[]string) {
t.Helper()
sys, cd := append([]string{}, system...), append([]string{}, controlD...)
old := bootstrapLookupFn
bootstrapLookupFn = func(domain string, timeout int, nss []string) []string {
if slices.Equal(nss, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")}) {
return append([]string{}, cd...)
}
return append([]string{}, sys...)
}
t.Cleanup(func() { bootstrapLookupFn = old })
return &sys, &cd
}
// useIPv6 pins whether the host has IPv6. HasIPv6 probes once per process, so
// the probe runs first and the pinned value replaces its result.
func useIPv6(t *testing.T, available bool) {
t.Helper()
old := HasIPv6()
SetIPv6Available(available)
t.Cleanup(func() { SetIPv6Available(old) })
}
func newBootstrapTestUpstream(typ, endpoint, ipStack string) *UpstreamConfig {
uc := &UpstreamConfig{Type: typ, Endpoint: endpoint, Timeout: 1000}
uc.Init()
uc.IPStack = ipStack
return uc
}
// Bootstrap results must cover the address families the ip_stack needs (#624).
// A partial lookup is filled in from the Control D direct-IP table and the
// Control D bootstrap server; what is still missing marks the upstream for a
// new lookup instead of becoming a permanently empty transport. An IPv4-only
// host never needs IPv6.
func TestSetupBootstrapIPCompletesPerStack(t *testing.T) {
const (
cdEndpoint = "https://dns.controld.com/abcdef"
otherEndpoint = "https://dns.example.com/dns-query"
v4, v6 = "192.0.2.10", "2001:db8::10"
)
for _, tc := range []struct {
name string
endpoint string
ipStack string
ipv6 bool
system, cdBoot []string
want4, want6 []string
wantIncomplete bool
}{
{name: "Control D split, AAAA only", endpoint: cdEndpoint, ipStack: IpStackSplit, ipv6: true,
system: []string{"2606:1a40::99"}, want4: []string{PremiumDNSBoostrapIP}, want6: []string{"2606:1a40::99"}},
{name: "Control D split, A only", endpoint: cdEndpoint, ipStack: IpStackSplit, ipv6: true,
system: []string{"76.76.2.99"}, want4: []string{"76.76.2.99"}, want6: []string{PremiumDNSBoostrapIPv6}},
{name: "Control D split, A only, IPv4-only host", endpoint: cdEndpoint, ipStack: IpStackSplit, ipv6: false,
system: []string{"76.76.2.99"}, want4: []string{"76.76.2.99"}},
{name: "Control D split, complete", endpoint: cdEndpoint, ipStack: IpStackSplit, ipv6: true,
system: []string{"2606:1a40::99", "76.76.2.99"}, want4: []string{"76.76.2.99"}, want6: []string{"2606:1a40::99"}},
{name: "other v4, AAAA only", endpoint: otherEndpoint, ipStack: IpStackV4, ipv6: true,
system: []string{v6}, want6: []string{v6}, wantIncomplete: true},
{name: "other v6, A only", endpoint: otherEndpoint, ipStack: IpStackV6, ipv6: true,
system: []string{v4}, want4: []string{v4}, wantIncomplete: true},
{name: "other v4, complete", endpoint: otherEndpoint, ipStack: IpStackV4, ipv6: true,
system: []string{v4}, want4: []string{v4}},
{name: "other both, AAAA only", endpoint: otherEndpoint, ipStack: IpStackBoth, ipv6: true,
system: []string{v6}, want6: []string{v6}},
{name: "other split, AAAA only, bootstrap server has A", endpoint: otherEndpoint, ipStack: IpStackSplit, ipv6: true,
system: []string{v6}, cdBoot: []string{v4}, want4: []string{v4}, want6: []string{v6}},
{name: "other split, AAAA only, nowhere has A", endpoint: otherEndpoint, ipStack: IpStackSplit, ipv6: true,
system: []string{v6}, want6: []string{v6}, wantIncomplete: true},
} {
t.Run(tc.name, func(t *testing.T) {
useIPv6(t, tc.ipv6)
useBootstrapLookup(t, tc.system, tc.cdBoot)
uc := newBootstrapTestUpstream(ResolverTypeDOH, tc.endpoint, tc.ipStack)
uc.SetupBootstrapIP()
if !slices.Equal(uc.bootstrapIPs4, tc.want4) || !slices.Equal(uc.bootstrapIPs6, tc.want6) {
t.Fatalf("bootstrap IPs = v4 %v, v6 %v; want v4 %v, v6 %v", uc.bootstrapIPs4, uc.bootstrapIPs6, tc.want4, tc.want6)
}
if got := uc.bootstrapIncomplete.Load(); got != tc.wantIncomplete {
t.Fatalf("bootstrapIncomplete = %v, want %v", got, tc.wantIncomplete)
}
})
}
}
// After a partial lookup, the transport recovers once the missing family can
// be resolved again, without a restart: a re-bootstrap looks it up instead of
// rebuilding the transport from the same incomplete lists. Until then a split
// transport borrows the other family rather than staying empty.
func TestReBootstrapRefreshesMissingFamily(t *testing.T) {
const v4, v6 = "192.0.2.10", "2001:db8::10"
for _, tc := range []struct {
name string
ipStack string
wantInterimA4 []string // addresses A queries dial before recovery
}{
{name: "v4", ipStack: IpStackV4, wantInterimA4: nil},
{name: "split", ipStack: IpStackSplit, wantInterimA4: []string{v6}},
} {
t.Run(tc.name, func(t *testing.T) {
useIPv6(t, true)
system, _ := useBootstrapLookup(t, []string{v6}, nil)
uc := newBootstrapTestUpstream(ResolverTypeDOT, "dot.example.com:853", tc.ipStack)
t.Cleanup(uc.CloseTransports)
uc.SetupBootstrapIP()
if !uc.bootstrapIncomplete.Load() {
t.Fatal("an AAAA-only result was accepted as complete")
}
if got := uc.dotTransport(dns.TypeA).addrs; !slices.Equal(got, tc.wantInterimA4) {
t.Fatalf("A-query addresses before recovery = %v, want %v", got, tc.wantInterimA4)
}
// DNS is still broken: a re-bootstrap keeps what it has.
uc.ReBootstrap()
if got := uc.dotTransport(dns.TypeA).addrs; !slices.Equal(got, tc.wantInterimA4) {
t.Fatalf("A-query addresses while DNS is down = %v, want %v", got, tc.wantInterimA4)
}
// DNS is back: the next re-bootstrap picks up the missing family.
*system = []string{v6, v4}
uc.ReBootstrap()
if got := uc.dotTransport(dns.TypeA).addrs; !slices.Equal(got, []string{v4}) {
t.Fatalf("A-query addresses after recovery = %v, want [%s]", got, v4)
}
if uc.bootstrapIncomplete.Load() {
t.Fatal("still incomplete after the missing family was found")
}
})
}
}
// A complete result is not looked up again on every re-bootstrap.
func TestReBootstrapKeepsCompleteResult(t *testing.T) {
useIPv6(t, true)
lookups := 0
old := bootstrapLookupFn
bootstrapLookupFn = func(string, int, []string) []string {
lookups++
return []string{"192.0.2.10", "2001:db8::10"}
}
t.Cleanup(func() { bootstrapLookupFn = old })
uc := newBootstrapTestUpstream(ResolverTypeDOT, "dot.example.com:853", IpStackSplit)
t.Cleanup(uc.CloseTransports)
uc.SetupBootstrapIP()
before := lookups
uc.ReBootstrap()
_ = uc.dotTransport(dns.TypeA)
if lookups != before {
t.Fatalf("a complete result was looked up again: %d lookups", lookups-before)
}
}
// useDirectIPs replaces the Control D direct-IP table.
func useDirectIPs(t *testing.T, ips ...string) {
t.Helper()
old := controlDDirectIPsFn
controlDDirectIPsFn = func(string) []string { return append([]string{}, ips...) }
t.Cleanup(func() { controlDDirectIPsFn = old })
}
// listenLocal listens on ip:port, or on a free port when port is "0", and
// returns the port. It skips the test when ip is not available here.
func listenLocal(t *testing.T, ip, port string) string {
t.Helper()
ln, err := net.Listen("tcp", net.JoinHostPort(ip, port))
if err != nil {
t.Skipf("cannot listen on %s: %v", ip, err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
return strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)
}
func dialDOH(t *testing.T, transport *http.Transport, port string) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := transport.DialContext(ctx, "tcp", net.JoinHostPort("dns.controld.com", port))
if err != nil {
return "", err
}
defer conn.Close()
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return host, nil
}
// The DoH direct-IP fallback adds the Control D direct IPs as a backup for
// when the bootstrap addresses are empty or all fail. It never replaces a
// working list, so it is not pinned until restart, and it only dials the
// families the ip_stack dials.
func TestFallbackToDirectIPIsABackup(t *testing.T) {
t.Run("v6 upstream keeps dialing its IPv6 list after the fallback", func(t *testing.T) {
port := listenLocal(t, "::1", "0")
useDirectIPs(t, "127.0.0.1")
uc := newBootstrapTestUpstream(ResolverTypeDOH, "https://dns.controld.com/abcdef", IpStackV6)
transport := uc.newDOHTransport([]string{"::1"})
t.Cleanup(transport.CloseIdleConnections)
if !uc.FallbackToDirectIP() {
t.Fatal("FallbackToDirectIP did not fire for a Control D upstream")
}
if got, err := dialDOH(t, transport, port); err != nil || got != "::1" {
t.Fatalf("dialed %q (err %v), want the bootstrap address ::1", got, err)
}
if uc.u.Host != "dns.controld.com" {
t.Fatalf("request host = %q, want the domain kept for TLS", uc.u.Host)
}
})
t.Run("v6 upstream with no addresses only gets IPv6 direct IPs", func(t *testing.T) {
port := listenLocal(t, "::1", "0")
useDirectIPs(t, "127.0.0.1", "::1")
uc := newBootstrapTestUpstream(ResolverTypeDOH, "https://dns.controld.com/abcdef", IpStackV6)
transport := uc.newDOHTransport(nil)
t.Cleanup(transport.CloseIdleConnections)
uc.FallbackToDirectIP()
if got := uc.directFallbackIPs(); !slices.Equal(got, []string{"::1"}) {
t.Fatalf("direct IPs for a v6 upstream = %v, want only [::1]", got)
}
if got, err := dialDOH(t, transport, port); err != nil || got != "::1" {
t.Fatalf("dialed %q (err %v), want the IPv6 direct IP ::1", got, err)
}
})
t.Run("direct IPs only after the list fails, and the list wins again once reachable", func(t *testing.T) {
port := listenLocal(t, "127.0.0.1", "0") // the direct IP
useDirectIPs(t, "127.0.0.1")
uc := newBootstrapTestUpstream(ResolverTypeDOH, "https://dns.controld.com/abcdef", IpStackSplit)
transport := uc.newDOHTransport([]string{"::1"}) // nothing listens there yet
t.Cleanup(transport.CloseIdleConnections)
if _, err := dialDOH(t, transport, port); err == nil {
t.Fatal("a failed list was retried on the direct IPs before the fallback fired")
}
uc.FallbackToDirectIP()
if got, err := dialDOH(t, transport, port); err != nil || got != "127.0.0.1" {
t.Fatalf("dialed %q (err %v), want the direct IP after the list failed", got, err)
}
// The bootstrap address is reachable again: it is dialed, not the direct IP.
listenLocal(t, "::1", port)
if got, err := dialDOH(t, transport, port); err != nil || got != "::1" {
t.Fatalf("dialed %q (err %v), want the recovered bootstrap address ::1", got, err)
}
})
t.Run("doh3 dials the direct IPs after the list fails", func(t *testing.T) {
cert := generateTestCertificate(t)
ln, err := quic.ListenAddr("127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{cert.tlsCert},
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS13,
}, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
if _, err := ln.Accept(context.Background()); err != nil {
return
}
}
}()
port := strconv.Itoa(ln.Addr().(*net.UDPAddr).Port)
useDirectIPs(t, "127.0.0.1")
uc := newBootstrapTestUpstream(ResolverTypeDOH3, "https://dns.controld.com/abcdef", IpStackV4)
rt := uc.newDOH3Transport(nil).(*http3.Transport)
t.Cleanup(func() { _ = rt.Close() })
pool := x509.NewCertPool()
pool.AddCert(cert.cert)
tlsCfg := &tls.Config{RootCAs: pool, ServerName: "localhost", NextProtos: []string{http3.NextProtoH3}, MinVersion: tls.VersionTLS13}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
addr := net.JoinHostPort("dns.controld.com", port)
if _, err := rt.Dial(ctx, addr, tlsCfg, nil); err == nil {
t.Fatal("dial with no addresses succeeded before the fallback")
}
uc.FallbackToDirectIP()
conn, err := rt.Dial(ctx, addr, tlsCfg, nil)
if err != nil {
t.Fatalf("doh3 dial after the fallback: %v", err)
}
defer conn.CloseWithError(0, "")
if host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()); host != "127.0.0.1" {
t.Fatalf("doh3 dialed %q, want the direct IP 127.0.0.1", host)
}
})
}
// DoT verifies the server as the upstream domain on every dial path, including
// the empty-address path that dials the endpoint: tls.Client fails every
// handshake without ServerName, and verification must stay on.
func TestDOTClientPoolServerName(t *testing.T) {
uc := newBootstrapTestUpstream(ResolverTypeDOT, "dot.example.com:853", IpStackBoth)
p := newDOTClientPool(uc, nil)
t.Cleanup(p.Close)
if p.tlsConfig.ServerName != "dot.example.com" {
t.Fatalf("ServerName = %q, want the upstream domain", p.tlsConfig.ServerName)
}
if p.tlsConfig.InsecureSkipVerify {
t.Fatal("certificate verification is disabled")
}
}