package netresolve import ( "context" "errors" "net" "net/netip" "sync/atomic" "testing" "time" ) type lookupFunc func(context.Context, string, string) ([]netip.Addr, error) func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { return f(ctx, network, host) } func TestDNSOutageFallbackAndReturnToSystem(t *testing.T) { t.Parallel() var healthy atomic.Bool var fallbacks atomic.Int32 r := &Resolver{timeout: time.Second} r.system = lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { if network != "ip4" || host != "api.ipify.org" { t.Errorf("unexpected query %s %s", network, host) } if healthy.Load() { return []netip.Addr{netip.MustParseAddr("1.0.0.1")}, nil } return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} }) r.fallbacks = []lookup{lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { fallbacks.Add(1) if network != "ip4" || host != "api.ipify.org." { t.Errorf("fallback must use absolute family-specific query: %s %s", network, host) } return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil })} for _, recovered := range []bool{false, true} { healthy.Store(recovered) ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org") want := "1.1.1.1" if recovered { want = "1.0.0.1" } if err != nil || len(ips) != 1 || ips[0].String() != want { t.Fatalf("DNS recovery: %v %v", ips, err) } } if fallbacks.Load() != 1 { t.Fatal("system recovery did not stop fallback use") } } func TestSlowDNSCannotBlockWorkingFallback(t *testing.T) { t.Parallel() slow := lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { <-ctx.Done(); return nil, ctx.Err() }) r := &Resolver{system: slow, timeout: 20 * time.Millisecond, fallbacks: []lookup{slow, lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil })}} started := time.Now() ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org") if err != nil || len(ips) != 1 || time.Since(started) > time.Second { t.Fatalf("blocked fallback: %v %v", ips, err) } r.fallbacks = []lookup{slow} if _, err = r.LookupNetIP(context.Background(), "ip4", "api.ipify.org"); err == nil { t.Fatal("total outage was hidden") } } func TestDNSFallbackDoesNotOverrideNegativeAnswersOrLocalNames(t *testing.T) { t.Parallel() for _, test := range []struct { host string err error }{ {"api.ipify.org", &net.DNSError{IsNotFound: true}}, {"api.ipify.org", errors.New("permanent DNS error")}, {"pi.hole", &net.DNSError{IsTemporary: true}}, {"private.example", &net.DNSError{IsTemporary: true}}, } { r := &Resolver{timeout: time.Second, system: lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { return nil, test.err }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { t.Error("unexpected external query") return nil, nil })}} if _, err := r.LookupNetIP(context.Background(), "ip4", test.host); !errors.Is(err, test.err) { t.Fatalf("original error lost: %v", err) } } } func TestCanceledDNSDoesNotStartFallback(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cancel() r := &Resolver{timeout: time.Second, system: lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { return nil, ctx.Err() }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { t.Error("fallback after cancellation") return nil, nil })}} if _, err := r.LookupNetIP(ctx, "ip6", "api6.ipify.org"); !errors.Is(err, context.Canceled) { t.Fatal(err) } } func TestDialAddressesTriesNextAddress(t *testing.T) { t.Parallel() listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() _, port, _ := net.SplitHostPort(listener.Addr().String()) conn, err := DialAddresses(context.Background(), "tcp4", []netip.Addr{netip.MustParseAddr("127.0.0.2"), netip.MustParseAddr("127.0.0.1")}, port) if err != nil { t.Fatal(err) } conn.Close() }