mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Internal Domains now support three modes. "os" (network default) is unchanged. "resolvers" becomes explicit resolver with network fallback, the default explicit selection: the configured resolvers are tried first, and a timeout, unreachable resolver, SERVFAIL, NXDOMAIN, REFUSED or NOTIMP hands the query to the network resolvers - matching VPN DNS servers, then domain-less VPN DNS servers, then the OS resolver's LAN nameservers. A valid empty answer stays final. "resolvers_only" keeps the previous strict behavior. The OS step uses a new LanOnlyQueryCtx: the OS resolver then asks only its LAN-classified nameservers (private, loopback, link-local, CGNAT) and sends nothing when it has none, so the private name never reaches a public nameserver, whether DHCP supplied it or it is ctrld's own public fallback. LanQueryCtx only dropped the latter. LAN-only queries get their own singleflight and hot-cache key, so they are never answered with an ordinary query's public answer. In fallback mode a configured resolver that the upstream monitor has marked down is skipped instead of waited on, so an endpoint off the organization network reaches the fallback without paying the resolver's 2s timeout on every query. Nothing else marks a generated resolver up again, so skipping one re-checks it in the background, at most once every 30s; an answer marks it up. Strict mode never skips. An absent mode with resolvers gets the fallback mode. An unrecognized mode with resolvers fails closed to explicit resolver only, so a wire value this build does not know never sends the domain to the Control D upstream; without resolvers it is dropped. Switching between the explicit modes is detected on refresh. Generated upstreams are now identified by an UpstreamConfig.InternalDomain marker that records their mode and that no configuration file can set, instead of by the internal_ key prefix. An upstream a local ctrld.toml or custom config defines as internal_foo, or as a copy of a generated one, is an ordinary upstream again: it keeps the OS-resolver catch-all, recovery and query-health grading. The "resolvers_only" wire value is provisional until the API agrees on it; the dashboard does not offer strict mode yet.
1169 lines
36 KiB
Go
1169 lines
36 KiB
Go
package ctrld
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func Test_osResolver_Resolve(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
resolver := newResolverWithNameserver([]string{"127.0.0.127:5353"})
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("controld.com.", dns.TypeA)
|
|
m.RecursionDesired = true
|
|
_, _ = resolver.Resolve(context.Background(), m)
|
|
}()
|
|
|
|
select {
|
|
case <-time.After(10 * time.Second):
|
|
t.Error("os resolver hangs")
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_ResolveLanHostname(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reqId := "req-id"
|
|
ctx = context.WithValue(ctx, ReqIdCtxKey{}, reqId)
|
|
ctx = LanQueryCtx(ctx)
|
|
|
|
go func(ctx context.Context) {
|
|
defer cancel()
|
|
id, ok := ctx.Value(ReqIdCtxKey{}).(string)
|
|
if !ok || id != reqId {
|
|
t.Error("missing request id")
|
|
return
|
|
}
|
|
lan, ok := ctx.Value(LanQueryCtxKey{}).(bool)
|
|
if !ok || !lan {
|
|
t.Error("not a LAN query")
|
|
return
|
|
}
|
|
resolver := newResolverWithNameserver([]string{"76.76.2.0:53"})
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("controld.com.", dns.TypeA)
|
|
m.RecursionDesired = true
|
|
_, err := resolver.Resolve(ctx, m)
|
|
if err == nil {
|
|
t.Error("os resolver succeeded unexpectedly")
|
|
return
|
|
}
|
|
}(ctx)
|
|
|
|
select {
|
|
case <-time.After(10 * time.Second):
|
|
t.Error("os resolver hangs")
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_RetriesUnboundOnUnreachableSource(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
boundIP net.IP
|
|
server string
|
|
errno syscall.Errno
|
|
}{
|
|
{"ipv4 network unreachable", net.ParseIP("192.0.2.10"), "192.0.2.53:53", syscall.ENETUNREACH},
|
|
{"ipv4 host unreachable", net.ParseIP("192.0.2.10"), "192.0.2.53:53", syscall.EHOSTUNREACH},
|
|
{"ipv6 network unreachable", net.ParseIP("2001:db8::10"), "[2001:db8::53]:53", syscall.ENETUNREACH},
|
|
{"ipv6 host unreachable", net.ParseIP("2001:db8::10"), "[2001:db8::53]:53", syscall.EHOSTUNREACH},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
var localIPs []net.IP
|
|
var servers []string
|
|
exchange := func(_ context.Context, msg *dns.Msg, server string, localIP net.IP) (*dns.Msg, time.Duration, error) {
|
|
localIPs = append(localIPs, append(net.IP(nil), localIP...))
|
|
servers = append(servers, server)
|
|
if localIP != nil {
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: &os.SyscallError{Syscall: "write", Err: tt.errno}}
|
|
}
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
answer, _, err := customDNSExchangeWith(context.Background(), msg, tt.server, tt.boundIP, true, exchange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if answer == nil {
|
|
t.Fatal("expected answer from route-selected retry")
|
|
}
|
|
if len(localIPs) != 2 {
|
|
t.Fatalf("exchange calls: got %d, want 2", len(localIPs))
|
|
}
|
|
if !localIPs[0].Equal(tt.boundIP) {
|
|
t.Fatalf("first source: got %v, want %v", localIPs[0], tt.boundIP)
|
|
}
|
|
if localIPs[1] != nil {
|
|
t.Fatalf("retry source: got %v, want route-selected nil", localIPs[1])
|
|
}
|
|
if len(servers) != 2 || servers[0] != tt.server || servers[1] != tt.server {
|
|
t.Fatalf("exchange servers: got %v, want two attempts to %s", servers, tt.server)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_PreservesReachableBoundSource(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
boundIP := net.ParseIP("192.0.2.10")
|
|
calls := 0
|
|
exchange := func(_ context.Context, msg *dns.Msg, _ string, localIP net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
if !localIP.Equal(boundIP) {
|
|
t.Fatalf("source: got %v, want %v", localIP, boundIP)
|
|
}
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
answer, _, err := customDNSExchangeWith(context.Background(), msg, "192.0.2.53:53", boundIP, true, exchange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if answer == nil {
|
|
t.Fatal("expected answer from bound exchange")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_DoesNotRetryOtherFailures(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
calls := 0
|
|
exchange := func(_ context.Context, _ *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
return nil, 0, context.DeadlineExceeded
|
|
}
|
|
|
|
_, _, err := customDNSExchangeWith(context.Background(), msg, "192.0.2.53:53", net.ParseIP("192.0.2.10"), true, exchange)
|
|
if err == nil {
|
|
t.Fatal("expected exchange failure")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_DoesNotRetryWithoutBoundSource(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
calls := 0
|
|
exchange := func(_ context.Context, _ *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
|
|
_, _, err := customDNSExchangeWith(context.Background(), msg, "192.0.2.53:53", nil, true, exchange)
|
|
if err == nil {
|
|
t.Fatal("expected exchange failure")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_DoesNotRetryCanceledContext(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
calls := 0
|
|
exchange := func(_ context.Context, _ *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
|
|
_, _, err := customDNSExchangeWith(ctx, msg, "192.0.2.53:53", net.ParseIP("192.0.2.10"), true, exchange)
|
|
if err == nil {
|
|
t.Fatal("expected exchange failure")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_ReturnsUnboundRetryFailure(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
retryErr := errors.New("route-selected exchange failed")
|
|
calls := 0
|
|
exchange := func(_ context.Context, _ *dns.Msg, _ string, localIP net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
if localIP != nil {
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
return nil, 0, retryErr
|
|
}
|
|
|
|
_, _, err := customDNSExchangeWith(context.Background(), msg, "192.0.2.53:53", net.ParseIP("192.0.2.10"), true, exchange)
|
|
if !errors.Is(err, retryErr) {
|
|
t.Fatalf("exchange error: got %v, want retry error %v", err, retryErr)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("exchange calls: got %d, want 2", calls)
|
|
}
|
|
}
|
|
|
|
func Test_customDNSExchangeWith_DoesNotRetryReadSideUnreachable(t *testing.T) {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
calls := 0
|
|
exchange := func(_ context.Context, _ *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
return nil, 0, &net.OpError{Op: "read", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
|
|
_, _, err := customDNSExchangeWith(context.Background(), msg, "192.0.2.53:53", net.ParseIP("192.0.2.10"), true, exchange)
|
|
if err == nil {
|
|
t.Fatal("expected exchange failure")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_ResolveUsesRouteSelectedFallbackForLANServer(t *testing.T) {
|
|
const server = "10.0.0.53:53"
|
|
boundIP := net.ParseIP("192.0.2.10")
|
|
resolver := newResolverWithNameserver([]string{server})
|
|
resolver.localIP = func(string) net.IP { return boundIP }
|
|
|
|
var localIPs []net.IP
|
|
var servers []string
|
|
resolver.exchangeDNS = func(_ context.Context, msg *dns.Msg, gotServer string, localIP net.IP) (*dns.Msg, time.Duration, error) {
|
|
servers = append(servers, gotServer)
|
|
localIPs = append(localIPs, append(net.IP(nil), localIP...))
|
|
if localIP != nil {
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
answer, err := resolver.Resolve(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if answer == nil {
|
|
t.Fatal("expected answer from route-selected retry")
|
|
}
|
|
if len(localIPs) != 2 || !localIPs[0].Equal(boundIP) || localIPs[1] != nil {
|
|
t.Fatalf("exchange sources: got %v, want [%v <nil>]", localIPs, boundIP)
|
|
}
|
|
if len(servers) != 2 || servers[0] != server || servers[1] != server {
|
|
t.Fatalf("exchange servers: got %v, want two attempts to %s", servers, server)
|
|
}
|
|
}
|
|
|
|
// A VPN-pushed public DNS address is categorized as public by IP, but it is
|
|
// still a system-selected resolver and must get the same route-compatible retry.
|
|
func Test_osResolver_ResolveUsesRouteSelectedFallbackForPublicVPNServer(t *testing.T) {
|
|
const server = "192.0.2.53:53"
|
|
boundIP := net.ParseIP("198.51.100.10")
|
|
resolver := newResolverWithNameserver([]string{server})
|
|
resolver.localIP = func(string) net.IP { return boundIP }
|
|
|
|
var localIPs []net.IP
|
|
resolver.exchangeDNS = func(_ context.Context, msg *dns.Msg, _ string, localIP net.IP) (*dns.Msg, time.Duration, error) {
|
|
localIPs = append(localIPs, append(net.IP(nil), localIP...))
|
|
if localIP != nil {
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
answer, err := resolver.Resolve(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if answer == nil {
|
|
t.Fatal("expected answer from route-selected retry")
|
|
}
|
|
if len(localIPs) != 2 || !localIPs[0].Equal(boundIP) || localIPs[1] != nil {
|
|
t.Fatalf("exchange sources: got %v, want [%v <nil>]", localIPs, boundIP)
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_ResolveDoesNotRetrySyntheticControlDFallbackUnbound(t *testing.T) {
|
|
resolver := newResolverWithNameserver([]string{controldPublicDnsWithPort})
|
|
resolver.localIP = func(string) net.IP { return net.ParseIP("198.51.100.10") }
|
|
calls := 0
|
|
resolver.exchangeDNS = func(_ context.Context, _ *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
return nil, 0, &net.OpError{Op: "write", Net: "udp", Err: syscall.EHOSTUNREACH}
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.example.", dns.TypeA)
|
|
_, err := resolver.Resolve(context.Background(), msg)
|
|
if err == nil {
|
|
t.Fatal("expected exchange failure")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("exchange calls: got %d, want one bound synthetic fallback attempt", calls)
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_ResolveWithNonSuccessAnswer(t *testing.T) {
|
|
// Set up a LAN nameserver that returns a success response.
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0") // 127.0.0.1 is considered LAN (loopback)
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, successHandler())
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
// Set up two public nameservers that return non-success responses.
|
|
publicHandlers := []dns.Handler{
|
|
nonSuccessHandlerWithRcode(dns.RcodeRefused),
|
|
nonSuccessHandlerWithRcode(dns.RcodeNameError),
|
|
}
|
|
var publicNS []string
|
|
var publicServers []*dns.Server
|
|
for _, handler := range publicHandlers {
|
|
pc, err := net.ListenPacket("udp", ":0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on public address: %v", err)
|
|
}
|
|
s, addr, err := runLocalPacketConnTestServer(t, pc, handler)
|
|
if err != nil {
|
|
t.Fatalf("failed to run public test server: %v", err)
|
|
}
|
|
publicNS = append(publicNS, addr)
|
|
publicServers = append(publicServers, s)
|
|
}
|
|
defer func() {
|
|
for _, s := range publicServers {
|
|
s.Shutdown()
|
|
}
|
|
}()
|
|
|
|
// We now create an osResolver which has both a LAN and public nameserver.
|
|
nss := []string{lanAddr}
|
|
nss = append(nss, publicNS...)
|
|
resolver := newResolverWithNameserver(nss)
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion(".", dns.TypeNS)
|
|
answer, err := resolver.Resolve(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Since a LAN nameserver is available and returns a success answer, we expect RcodeSuccess.
|
|
if answer.Rcode != dns.RcodeSuccess {
|
|
t.Errorf("expected a success answer from LAN nameserver (RcodeSuccess) but got: %s", dns.RcodeToString[answer.Rcode])
|
|
}
|
|
}
|
|
|
|
func TestOSResolverNameserverSetsKeepsSyntheticFallbackOutOfSystemDiscovery(t *testing.T) {
|
|
system := []string{"fe80::1"}
|
|
effective, discovered, skip := osResolverNameserverSets(system, false)
|
|
if skip {
|
|
t.Fatal("non-empty discovery unexpectedly skipped resolver replacement")
|
|
}
|
|
|
|
if len(discovered) != 1 || discovered[0] != system[0] {
|
|
t.Fatalf("discovered nameservers = %v, want raw system list %v", discovered, system)
|
|
}
|
|
if len(effective) != 2 || effective[0] != "[fe80::1]:53" || effective[1] != controldPublicDnsWithPort {
|
|
t.Fatalf("effective nameservers = %v, want IPv6 system resolver plus synthetic fallback", effective)
|
|
}
|
|
}
|
|
|
|
func TestOSResolverNameserverSetsHonorsEmptyGuard(t *testing.T) {
|
|
effective, discovered, skip := osResolverNameserverSets(nil, true)
|
|
if len(effective) != 0 || len(discovered) != 0 {
|
|
t.Fatalf("guarded empty discovery returned effective=%v discovered=%v", effective, discovered)
|
|
}
|
|
if !skip {
|
|
t.Fatal("guarded empty discovery did not return the skip decision")
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_InitializationRace(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
n := 10
|
|
wg.Add(n)
|
|
for range n {
|
|
go func() {
|
|
defer wg.Done()
|
|
InitializeOsResolver(false)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func Test_osResolver_Singleflight(t *testing.T) {
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
call := &atomic.Int64{}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
or := newResolverWithNameserver([]string{lanAddr})
|
|
domain := "controld.com"
|
|
n := 10
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
|
m.RecursionDesired = true
|
|
_, err := or.Resolve(context.Background(), m)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// All above queries should only make 1 call to server.
|
|
if call.Load() != 1 {
|
|
t.Fatalf("expected 1 result from singleflight lookup, got %d", call)
|
|
}
|
|
}
|
|
|
|
func Test_osResolver_HotCache(t *testing.T) {
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
call := &atomic.Int64{}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
or := newResolverWithNameserver([]string{lanAddr})
|
|
domain := "controld.com"
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
|
m.RecursionDesired = true
|
|
|
|
// Make 2 repeated queries to server, should hit hot cache.
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := or.Resolve(context.Background(), m.Copy()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if call.Load() != 1 {
|
|
t.Fatalf("cache not hit, server was called: %d", call.Load())
|
|
}
|
|
|
|
timeoutChan := make(chan struct{})
|
|
time.AfterFunc(5*time.Second, func() {
|
|
close(timeoutChan)
|
|
})
|
|
|
|
for {
|
|
select {
|
|
case <-timeoutChan:
|
|
t.Fatal("timed out waiting for cache cleaned")
|
|
default:
|
|
count := 0
|
|
or.cache.Range(func(key, value interface{}) bool {
|
|
count++
|
|
return true
|
|
})
|
|
if count != 0 {
|
|
t.Logf("hot cache is not empty: %d elements", count)
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
if _, err := or.Resolve(context.Background(), m.Copy()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if call.Load() != 2 {
|
|
t.Fatal("cache hit unexpectedly")
|
|
}
|
|
}
|
|
|
|
func Test_Edns0_CacheReply(t *testing.T) {
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
call := &atomic.Int64{}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
or := newResolverWithNameserver([]string{lanAddr})
|
|
domain := "controld.com"
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
|
m.RecursionDesired = true
|
|
|
|
do := func() *dns.Msg {
|
|
msg := m.Copy()
|
|
msg.SetEdns0(4096, true)
|
|
cookieOption := new(dns.EDNS0_COOKIE)
|
|
cookieOption.Code = dns.EDNS0COOKIE
|
|
cookieOption.Cookie = generateEdns0ClientCookie()
|
|
msg.IsEdns0().Option = append(msg.IsEdns0().Option, cookieOption)
|
|
|
|
answer, err := or.Resolve(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return answer
|
|
}
|
|
answer1 := do()
|
|
answer2 := do()
|
|
// Ensure the cache was hit, so we can check that edns0 cookie must be modified.
|
|
if call.Load() != 1 {
|
|
t.Fatalf("cache not hit, server was called: %d", call.Load())
|
|
}
|
|
cookie1 := getEdns0Cookie(answer1.IsEdns0())
|
|
cookie2 := getEdns0Cookie(answer2.IsEdns0())
|
|
if cookie1 == nil || cookie2 == nil {
|
|
t.Fatalf("unexpected nil cookie value (cookie1: %v, cookie2: %v)", cookie1, cookie2)
|
|
}
|
|
if cookie1.Cookie == cookie2.Cookie {
|
|
t.Fatalf("edns0 cookie is not modified: %v", cookie1)
|
|
}
|
|
}
|
|
|
|
// ecsAnswerHandler returns a distinct A record per EDNS Client Subnet, so a test can
|
|
// prove one subnet never receives another subnet's cached record. It counts upstream
|
|
// calls to confirm the hot cache/singleflight is partitioned by ECS rather than shared.
|
|
func ecsAnswerHandler(call *atomic.Int64) dns.HandlerFunc {
|
|
return func(w dns.ResponseWriter, msg *dns.Msg) {
|
|
call.Add(1)
|
|
a := "203.0.113.1" // no/other subnet
|
|
if opt := msg.IsEdns0(); opt != nil {
|
|
for _, o := range opt.Option {
|
|
if e, ok := o.(*dns.EDNS0_SUBNET); ok {
|
|
switch {
|
|
case e.Address.Equal(net.ParseIP("2001:db8:1::")):
|
|
a = "192.0.2.1"
|
|
case e.Address.Equal(net.ParseIP("2001:db8:2::")):
|
|
a = "198.51.100.1"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
m := new(dns.Msg)
|
|
m.SetReply(msg)
|
|
rr, _ := dns.NewRR(msg.Question[0].Name + " 300 IN A " + a)
|
|
m.Answer = []dns.RR{rr}
|
|
w.WriteMsg(m)
|
|
}
|
|
}
|
|
|
|
// Test_osResolver_HotCache_ECSPartition is the real cache-path regression test for #564 on
|
|
// the osResolver hot cache / singleflight path: the upstream returns a different A record
|
|
// per subnet, and a client in subnet B must never be served subnet A's hot-cached record.
|
|
func Test_osResolver_HotCache_ECSPartition(t *testing.T) {
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
call := &atomic.Int64{}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, ecsAnswerHandler(call))
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
or := newResolverWithNameserver([]string{lanAddr})
|
|
query := func(subnet string) string {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn("controld.com"), dns.TypeA)
|
|
m.RecursionDesired = true
|
|
m.SetEdns0(4096, true)
|
|
m.IsEdns0().Option = append(m.IsEdns0().Option, &dns.EDNS0_SUBNET{
|
|
Code: dns.EDNS0SUBNET,
|
|
Family: 2,
|
|
SourceNetmask: 64,
|
|
Address: net.ParseIP(subnet),
|
|
})
|
|
answer, err := or.Resolve(context.Background(), m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, rr := range answer.Answer {
|
|
if a, ok := rr.(*dns.A); ok {
|
|
return a.A.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Subnet A populates the hot cache; a repeat hits it (upstream called once).
|
|
if got := query("2001:db8:1::"); got != "192.0.2.1" {
|
|
t.Fatalf("subnet A: got %q, want 192.0.2.1", got)
|
|
}
|
|
if got := query("2001:db8:1::"); got != "192.0.2.1" {
|
|
t.Fatalf("subnet A repeat: got %q, want 192.0.2.1", got)
|
|
}
|
|
if call.Load() != 1 {
|
|
t.Fatalf("subnet A repeat did not hit the hot cache: %d upstream calls", call.Load())
|
|
}
|
|
|
|
// Subnet B must get ITS OWN record, not subnet A's hot-cached one, and this
|
|
// requires a fresh upstream call (the cache is partitioned, not shared).
|
|
if got := query("2001:db8:2::"); got != "198.51.100.1" {
|
|
t.Fatalf("subnet B was served the wrong record %q (want 198.51.100.1); hot cache is not ECS-partitioned", got)
|
|
}
|
|
if call.Load() != 2 {
|
|
t.Fatalf("subnet B unexpectedly served from subnet A's cache: %d upstream calls, want 2", call.Load())
|
|
}
|
|
}
|
|
|
|
// https://github.com/Control-D-Inc/ctrld/issues/255
|
|
func Test_legacyResolverWithBigExtraSection(t *testing.T) {
|
|
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0") // 127.0.0.1 is considered LAN (loopback)
|
|
if err != nil {
|
|
t.Fatalf("failed to listen on LAN address: %v", err)
|
|
}
|
|
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, bigExtraSectionHandler())
|
|
if err != nil {
|
|
t.Fatalf("failed to run LAN test server: %v", err)
|
|
}
|
|
defer lanServer.Shutdown()
|
|
|
|
uc := &UpstreamConfig{
|
|
Name: "Legacy",
|
|
Type: ResolverTypeLegacy,
|
|
Endpoint: lanAddr,
|
|
}
|
|
uc.Init()
|
|
r, err := NewResolver(uc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = r.Resolve(context.Background(), uc.VerifyMsg())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func Test_upstreamTypeFromEndpoint(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
endpoint string
|
|
resolverType string
|
|
}{
|
|
{"doh", "https://freedns.controld.com/p2", ResolverTypeDOH},
|
|
{"doq", "quic://p2.freedns.controld.com", ResolverTypeDOQ},
|
|
{"dot", "p2.freedns.controld.com", ResolverTypeDOT},
|
|
{"legacy", "8.8.8.8:53", ResolverTypeLegacy},
|
|
{"legacy ipv6", "[2404:6800:4005:809::200e]:53", ResolverTypeLegacy},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if rt := ResolverTypeFromEndpoint(tc.endpoint); rt != tc.resolverType {
|
|
t.Errorf("mismatch, want: %s, got: %s", tc.resolverType, rt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func runLocalPacketConnTestServer(t *testing.T, pc net.PacketConn, handler dns.Handler, opts ...func(*dns.Server)) (*dns.Server, string, error) {
|
|
t.Helper()
|
|
|
|
server := &dns.Server{
|
|
PacketConn: pc,
|
|
ReadTimeout: time.Hour,
|
|
WriteTimeout: time.Hour,
|
|
Handler: handler,
|
|
}
|
|
|
|
waitLock := sync.Mutex{}
|
|
waitLock.Lock()
|
|
server.NotifyStartedFunc = waitLock.Unlock
|
|
|
|
for _, opt := range opts {
|
|
opt(server)
|
|
}
|
|
|
|
addr, closer := pc.LocalAddr().String(), pc
|
|
go func() {
|
|
if err := server.ActivateAndServe(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
closer.Close()
|
|
}()
|
|
|
|
waitLock.Lock()
|
|
return server, addr, nil
|
|
}
|
|
|
|
func successHandler() dns.HandlerFunc {
|
|
return func(w dns.ResponseWriter, msg *dns.Msg) {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(msg, dns.RcodeSuccess)
|
|
w.WriteMsg(m)
|
|
}
|
|
}
|
|
|
|
func nonSuccessHandlerWithRcode(rcode int) dns.HandlerFunc {
|
|
return func(w dns.ResponseWriter, msg *dns.Msg) {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(msg, rcode)
|
|
w.WriteMsg(m)
|
|
}
|
|
}
|
|
|
|
func countHandler(call *atomic.Int64) dns.HandlerFunc {
|
|
return func(w dns.ResponseWriter, msg *dns.Msg) {
|
|
// Count the call before writing the reply. The client returns as soon
|
|
// as it receives the response, so a caller that reads this counter right
|
|
// after Resolve returns would race an increment done after WriteMsg and
|
|
// could observe a stale zero.
|
|
call.Add(1)
|
|
m := new(dns.Msg)
|
|
m.SetRcode(msg, dns.RcodeSuccess)
|
|
if cookie := getEdns0Cookie(msg.IsEdns0()); cookie != nil {
|
|
if m.IsEdns0() == nil {
|
|
m.SetEdns0(4096, false)
|
|
}
|
|
cookieOption := new(dns.EDNS0_COOKIE)
|
|
cookieOption.Code = dns.EDNS0COOKIE
|
|
cookieOption.Cookie = generateEdns0ServerCookie(cookie.Cookie)
|
|
m.IsEdns0().Option = append(m.IsEdns0().Option, cookieOption)
|
|
}
|
|
w.WriteMsg(m)
|
|
}
|
|
}
|
|
|
|
func mustRR(s string) dns.RR {
|
|
r, err := dns.NewRR(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func bigExtraSectionHandler() dns.HandlerFunc {
|
|
return func(w dns.ResponseWriter, msg *dns.Msg) {
|
|
m := &dns.Msg{
|
|
Answer: []dns.RR{
|
|
mustRR(". 7149 IN NS m.root-servers.net."),
|
|
mustRR(". 7149 IN NS c.root-servers.net."),
|
|
mustRR(". 7149 IN NS e.root-servers.net."),
|
|
mustRR(". 7149 IN NS j.root-servers.net."),
|
|
mustRR(". 7149 IN NS g.root-servers.net."),
|
|
mustRR(". 7149 IN NS k.root-servers.net."),
|
|
mustRR(". 7149 IN NS l.root-servers.net."),
|
|
mustRR(". 7149 IN NS d.root-servers.net."),
|
|
mustRR(". 7149 IN NS h.root-servers.net."),
|
|
mustRR(". 7149 IN NS b.root-servers.net."),
|
|
mustRR(". 7149 IN NS a.root-servers.net."),
|
|
mustRR(". 7149 IN NS f.root-servers.net."),
|
|
mustRR(". 7149 IN NS i.root-servers.net."),
|
|
},
|
|
Extra: []dns.RR{
|
|
mustRR("m.root-servers.net. 656 IN A 202.12.27.33"),
|
|
mustRR("m.root-servers.net. 656 IN AAAA 2001:dc3::35"),
|
|
mustRR("c.root-servers.net. 656 IN A 192.33.4.12"),
|
|
mustRR("c.root-servers.net. 656 IN AAAA 2001:500:2::c"),
|
|
mustRR("e.root-servers.net. 656 IN A 192.203.230.10"),
|
|
mustRR("e.root-servers.net. 656 IN AAAA 2001:500:a8::e"),
|
|
mustRR("j.root-servers.net. 656 IN A 192.58.128.30"),
|
|
mustRR("j.root-servers.net. 656 IN AAAA 2001:503:c27::2:30"),
|
|
mustRR("g.root-servers.net. 656 IN A 192.112.36.4"),
|
|
mustRR("g.root-servers.net. 656 IN AAAA 2001:500:12::d0d"),
|
|
mustRR("k.root-servers.net. 656 IN A 193.0.14.129"),
|
|
mustRR("k.root-servers.net. 656 IN AAAA 2001:7fd::1"),
|
|
mustRR("l.root-servers.net. 656 IN A 199.7.83.42"),
|
|
mustRR("l.root-servers.net. 656 IN AAAA 2001:500:9f::42"),
|
|
mustRR("d.root-servers.net. 656 IN A 199.7.91.13"),
|
|
mustRR("d.root-servers.net. 656 IN AAAA 2001:500:2d::d"),
|
|
mustRR("h.root-servers.net. 656 IN A 198.97.190.53"),
|
|
mustRR("h.root-servers.net. 656 IN AAAA 2001:500:1::53"),
|
|
mustRR("b.root-servers.net. 656 IN A 170.247.170.2"),
|
|
mustRR("b.root-servers.net. 656 IN AAAA 2801:1b8:10::b"),
|
|
mustRR("a.root-servers.net. 656 IN A 198.41.0.4"),
|
|
mustRR("a.root-servers.net. 656 IN AAAA 2001:503:ba3e::2:30"),
|
|
mustRR("f.root-servers.net. 656 IN A 192.5.5.241"),
|
|
mustRR("f.root-servers.net. 656 IN AAAA 2001:500:2f::f"),
|
|
mustRR("i.root-servers.net. 656 IN A 192.36.148.17"),
|
|
mustRR("i.root-servers.net. 656 IN AAAA 2001:7fe::53"),
|
|
},
|
|
}
|
|
|
|
m.Compress = true
|
|
m.SetReply(msg)
|
|
w.WriteMsg(m)
|
|
}
|
|
}
|
|
|
|
func generateEdns0ClientCookie() string {
|
|
cookie := make([]byte, 8)
|
|
if _, err := rand.Read(cookie); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(cookie)
|
|
}
|
|
|
|
func generateEdns0ServerCookie(clientCookie string) string {
|
|
cookie := make([]byte, 32)
|
|
if _, err := rand.Read(cookie); err != nil {
|
|
panic(err)
|
|
}
|
|
return clientCookie + hex.EncodeToString(cookie)
|
|
}
|
|
|
|
// syncLogBuffer collects the log lines that any goroutine writes.
|
|
type syncLogBuffer struct {
|
|
mu sync.Mutex
|
|
sb strings.Builder
|
|
}
|
|
|
|
func (b *syncLogBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.sb.Write(p)
|
|
}
|
|
|
|
func (b *syncLogBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.sb.String()
|
|
}
|
|
|
|
// captureProxyLog sends the proxy log to a buffer at debug level. It puts the
|
|
// logger and the global level back when the test ends.
|
|
func captureProxyLog(t *testing.T) *syncLogBuffer {
|
|
t.Helper()
|
|
buf := &syncLogBuffer{}
|
|
logger := zerolog.New(buf).Level(zerolog.DebugLevel)
|
|
previousLogger := ProxyLogger.Load()
|
|
previousLevel := zerolog.GlobalLevel()
|
|
ProxyLogger.Store(&logger)
|
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
t.Cleanup(func() {
|
|
ProxyLogger.Store(previousLogger)
|
|
zerolog.SetGlobalLevel(previousLevel)
|
|
})
|
|
return buf
|
|
}
|
|
|
|
// logEventsWithPrefix returns the log events whose message starts with prefix.
|
|
func logEventsWithPrefix(t *testing.T, logs, prefix string) []map[string]any {
|
|
t.Helper()
|
|
var events []map[string]any
|
|
for _, line := range strings.Split(logs, "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
event := map[string]any{}
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("log line is not JSON: %q: %v", line, err)
|
|
}
|
|
message, _ := event["message"].(string)
|
|
if !strings.HasPrefix(message, prefix) {
|
|
continue
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
// wantLogField fails the test when an event misses a field or holds another value.
|
|
func wantLogField(t *testing.T, event map[string]any, field string, want any) {
|
|
t.Helper()
|
|
got, ok := event[field]
|
|
if !ok {
|
|
t.Fatalf("log event has no field %q: %v", field, event)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("log event field %q = %v, want %v", field, got, want)
|
|
}
|
|
}
|
|
|
|
// wantLogStrings fails the test when a list field does not hold want.
|
|
func wantLogStrings(t *testing.T, event map[string]any, field string, want []string) {
|
|
t.Helper()
|
|
values, ok := event[field].([]any)
|
|
if !ok {
|
|
t.Fatalf("log event field %q is not a list: %v", field, event[field])
|
|
}
|
|
got := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
t.Fatalf("log event field %q holds a value that is not a string: %v", field, value)
|
|
}
|
|
got = append(got, text)
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("log event field %q = %v, want %v", field, got, want)
|
|
}
|
|
}
|
|
|
|
// stubNameservers answers each system nameserver read from lists, in order.
|
|
// The last list answers every read after it.
|
|
func stubNameservers(t *testing.T, lists ...[]string) {
|
|
t.Helper()
|
|
previous := NameserversFn
|
|
reads := 0
|
|
NameserversFn = func() []string {
|
|
list := lists[min(reads, len(lists)-1)]
|
|
reads++
|
|
return slices.Clone(list)
|
|
}
|
|
t.Cleanup(func() { NameserversFn = previous })
|
|
}
|
|
|
|
// resetOsResolverLog clears the change-only state, so one test does not see
|
|
// the nameserver reads of another.
|
|
func resetOsResolverLog(t *testing.T) {
|
|
t.Helper()
|
|
reset := func() {
|
|
osResolverLog.mu.Lock()
|
|
defer osResolverLog.mu.Unlock()
|
|
osResolverLog.system = nameserverReads{}
|
|
osResolverLog.final = nameserverReads{}
|
|
osResolverLog.reason = osResolverReasonUnspecified
|
|
}
|
|
reset()
|
|
t.Cleanup(reset)
|
|
}
|
|
|
|
// keepOsResolver puts back the OS resolver that the process had before the test.
|
|
func keepOsResolver(t *testing.T) {
|
|
t.Helper()
|
|
resolverMutex.Lock()
|
|
previous := or
|
|
resolverMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
resolverMutex.Lock()
|
|
storeOsResolver(previous)
|
|
resolverMutex.Unlock()
|
|
})
|
|
}
|
|
|
|
// TestOsResolverNameserversReadsWithoutTheResolverLock covers a log header
|
|
// render during a resolver initialization. That initialization holds
|
|
// resolverMutex across a scutil read of several seconds, and the header must
|
|
// not wait for it.
|
|
func TestOsResolverNameserversReadsWithoutTheResolverLock(t *testing.T) {
|
|
keepOsResolver(t)
|
|
resolverMutex.Lock()
|
|
storeOsResolver(newResolverWithNameserver([]string{"192.0.2.1:53"}))
|
|
|
|
read := make(chan []string, 1)
|
|
go func() { read <- OsResolverNameservers() }()
|
|
|
|
select {
|
|
case nameservers := <-read:
|
|
resolverMutex.Unlock()
|
|
if want := []string{"192.0.2.1:53"}; !slices.Equal(nameservers, want) {
|
|
t.Fatalf("nameservers = %v, want %v", nameservers, want)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
resolverMutex.Unlock()
|
|
t.Fatal("OsResolverNameservers waited for resolverMutex")
|
|
}
|
|
}
|
|
|
|
func TestJournalMarksTheEventAndKeepsItsLevel(t *testing.T) {
|
|
logs := captureProxyLog(t)
|
|
|
|
Journal(ProxyLogger.Load().Info()).Msg("Journal test event")
|
|
|
|
events := logEventsWithPrefix(t, logs.String(), "Journal test event")
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1: %s", len(events), logs.String())
|
|
}
|
|
wantLogField(t, events[0], JournalField, true)
|
|
wantLogField(t, events[0], "level", "info")
|
|
}
|
|
|
|
func TestOsResolverNameserverReadsLogOnChange(t *testing.T) {
|
|
logs := captureProxyLog(t)
|
|
resetOsResolverLog(t)
|
|
keepOsResolver(t)
|
|
first := []string{"192.0.2.1", "192.0.2.2"}
|
|
second := []string{"192.0.2.3"}
|
|
stubNameservers(t, first, first, second)
|
|
|
|
for range 3 {
|
|
InitializeOsResolverWithReason(false, "transition")
|
|
}
|
|
|
|
systemReads := logEventsWithPrefix(t, logs.String(), "Got system nameservers")
|
|
if len(systemReads) != 2 {
|
|
t.Fatalf("got %d system nameserver lines, want 2: %s", len(systemReads), logs.String())
|
|
}
|
|
wantLogField(t, systemReads[0], "repeats", float64(0))
|
|
wantLogField(t, systemReads[1], "repeats", float64(1))
|
|
wantLogField(t, systemReads[1], "message", "Got system nameservers: [192.0.2.3]")
|
|
|
|
finalReads := logEventsWithPrefix(t, logs.String(), "Final available nameservers")
|
|
if len(finalReads) != 2 {
|
|
t.Fatalf("got %d final nameserver lines, want 2: %s", len(finalReads), logs.String())
|
|
}
|
|
wantLogField(t, finalReads[1], "repeats", float64(1))
|
|
|
|
changes := logEventsWithPrefix(t, logs.String(), "OS resolver set changed")
|
|
if len(changes) != 2 {
|
|
t.Fatalf("got %d resolver change events, want one for each changed list: %s", len(changes), logs.String())
|
|
}
|
|
last := changes[1]
|
|
wantLogStrings(t, last, "before", first)
|
|
wantLogStrings(t, last, "after", second)
|
|
wantLogField(t, last, JournalField, true)
|
|
wantLogField(t, last, "level", "info")
|
|
wantLogField(t, last, "reason", "transition")
|
|
if _, ok := last["default_route"]; !ok {
|
|
t.Fatalf("resolver change event has no default_route field: %v", last)
|
|
}
|
|
wantSource := map[string]string{"darwin": "scutil", "windows": "dhcp", "linux": "resolv.conf"}[runtime.GOOS]
|
|
if wantSource != "" {
|
|
wantLogField(t, last, "source", wantSource)
|
|
}
|
|
}
|
|
|
|
func TestInitializeOsResolverReportsTheUnspecifiedReason(t *testing.T) {
|
|
logs := captureProxyLog(t)
|
|
resetOsResolverLog(t)
|
|
keepOsResolver(t)
|
|
stubNameservers(t, []string{"192.0.2.10"})
|
|
|
|
InitializeOsResolver(false)
|
|
|
|
changes := logEventsWithPrefix(t, logs.String(), "OS resolver set changed")
|
|
if len(changes) != 1 {
|
|
t.Fatalf("got %d resolver change events, want 1: %s", len(changes), logs.String())
|
|
}
|
|
wantLogField(t, changes[0], "reason", "unspecified")
|
|
wantLogStrings(t, changes[0], "after", []string{"192.0.2.10"})
|
|
}
|
|
|
|
// A LAN-only query must reach the LAN nameservers and nothing else. A public
|
|
// nameserver in the same pool, as DHCP hands out next to the router, is not
|
|
// asked at all, so the name is never sent to it.
|
|
func Test_osResolver_LanOnlyQuerySkipsPublicNameservers(t *testing.T) {
|
|
const lan, public = "10.0.0.53:53", "192.0.2.53:53"
|
|
resolver := newResolverWithNameserver([]string{lan, public, controldPublicDnsWithPort})
|
|
|
|
var mu sync.Mutex
|
|
var asked []string
|
|
resolver.exchangeDNS = func(_ context.Context, msg *dns.Msg, server string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
mu.Lock()
|
|
asked = append(asked, server)
|
|
mu.Unlock()
|
|
answer := new(dns.Msg)
|
|
answer.SetRcode(msg, dns.RcodeNameError)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("host.corp.example.", dns.TypeA)
|
|
answer, err := resolver.Resolve(LanOnlyQueryCtx(context.Background()), msg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if answer.Rcode != dns.RcodeNameError {
|
|
t.Errorf("rcode = %s, want the LAN nameserver's NXDOMAIN", dns.RcodeToString[answer.Rcode])
|
|
}
|
|
if len(asked) != 1 || asked[0] != lan {
|
|
t.Fatalf("nameservers asked = %v, want only %s", asked, lan)
|
|
}
|
|
}
|
|
|
|
// With no LAN nameserver at all, a LAN-only query fails without sending
|
|
// anything, rather than falling through to the public ones.
|
|
func Test_osResolver_LanOnlyQueryWithoutLanNameserverSendsNothing(t *testing.T) {
|
|
resolver := newResolverWithNameserver([]string{"192.0.2.53:53", controldPublicDnsWithPort})
|
|
calls := 0
|
|
resolver.exchangeDNS = func(_ context.Context, msg *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
calls++
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("host.corp.example.", dns.TypeA)
|
|
if _, err := resolver.Resolve(LanOnlyQueryCtx(context.Background()), msg); err == nil {
|
|
t.Fatal("a LAN-only query with no LAN nameserver returned an answer")
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("exchange calls = %d, want none", calls)
|
|
}
|
|
}
|
|
|
|
// The hot cache is keyed separately for LAN-only queries: an answer an
|
|
// ordinary query got from a public nameserver must not serve a LAN-only one.
|
|
func Test_osResolver_LanOnlyQueryDoesNotShareOrdinaryHotCache(t *testing.T) {
|
|
resolver := newResolverWithNameserver([]string{"192.0.2.53:53"})
|
|
resolver.exchangeDNS = func(_ context.Context, msg *dns.Msg, _ string, _ net.IP) (*dns.Msg, time.Duration, error) {
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(msg)
|
|
return answer, time.Millisecond, nil
|
|
}
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("host.corp.example.", dns.TypeA)
|
|
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
|
|
t.Fatalf("ordinary query: %v", err)
|
|
}
|
|
if _, err := resolver.Resolve(LanOnlyQueryCtx(context.Background()), msg); err == nil {
|
|
t.Fatal("a LAN-only query was served the ordinary query's public answer from the hot cache")
|
|
}
|
|
}
|