diff --git a/internal/client/dnsdial/doh.go b/internal/client/dnsdial/doh.go index 427d492..6b83ed3 100644 --- a/internal/client/dnsdial/doh.go +++ b/internal/client/dnsdial/doh.go @@ -429,23 +429,27 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) { return nil, lastErr } -// autoDial выполняет DNS probe по UDP/53 и при недоступности переключается на DoH. func autoDial(r *DohResolver) dialFunc { var ( - probed sync.Once - useDoH atomic.Bool + mu sync.Mutex + probed *[]string + useDoH bool doh = dohForwarderDial(r) ) return func(ctx context.Context, network, addr string) (net.Conn, error) { - probed.Do(func() { - if udpProbe(autoUDPBudget) { - Log().Infof("[DNS] UDP/53 probe OK, using UDP") + mu.Lock() + if servers := udpDNSServersPtr.Load(); servers != probed { + probed = servers + useDoH = !udpProbe(autoUDPBudget) + if useDoH { + Log().Warnf("[DNS] UDP/53 unreachable; switching to DoH until network change") } else { - Log().Warnf("[DNS] UDP/53 unreachable; sticky-switching to DoH") - useDoH.Store(true) + Log().Infof("[DNS] UDP/53 probe OK, using UDP") } - }) - if useDoH.Load() { + } + viaDoH := useDoH + mu.Unlock() + if viaDoH { return doh(ctx, network, addr) } return udpDNSDial(ctx, network, addr) @@ -466,24 +470,31 @@ func udpProbe(timeout time.Duration) bool { buf := make([]byte, 512) servers := udpDNSServers() limit := min(len(servers), 2) - for _, server := range servers[:limit] { + for i, server := range servers[:limit] { remaining := time.Until(deadline) if remaining <= 0 { break } + // Остаток бюджета делится поровну: молчащий сервер не съедает попытку следующего. + attempt := remaining / time.Duration(limit-i) // netctl.Apply обязателен для исключения сокета пробы из VPN-туннеля. - d := net.Dialer{Timeout: remaining, Control: netctl.Apply} + d := net.Dialer{Timeout: attempt, Control: netctl.Apply} conn, err := d.Dial("udp", server) //nolint:noctx if err != nil { continue } - _ = conn.SetDeadline(deadline) //nolint:errcheck + _ = conn.SetDeadline(time.Now().Add(attempt)) //nolint:errcheck _, _ = conn.Write(wire) n, err := conn.Read(buf) _ = conn.Close() - if err == nil && n > 12 { + if err == nil && isReply(m, buf[:n]) { return true } } return false } + +func isReply(query *dns.Msg, raw []byte) bool { + var resp dns.Msg + return resp.Unpack(raw) == nil && resp.Response && resp.Id == query.Id +} diff --git a/internal/client/dnsdial/doh_test.go b/internal/client/dnsdial/doh_test.go index e26995a..7a237a7 100644 --- a/internal/client/dnsdial/doh_test.go +++ b/internal/client/dnsdial/doh_test.go @@ -12,35 +12,22 @@ import ( "github.com/miekg/dns" ) -// dohAnswer builds a wire-format DNS reply for a single question with one -// answer of the matching type (A or AAAA). TTL is returned as-is. -func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte { +func dnsAnswer(t *testing.T, query []byte) []byte { t.Helper() req := new(dns.Msg) if err := req.Unpack(query); err != nil { t.Fatalf("unpack query: %v", err) } - reply := new(dns.Msg) - reply.SetReply(req) if len(req.Question) != 1 { t.Fatalf("expected 1 question, got %d", len(req.Question)) } - q := req.Question[0] - switch q.Qtype { - case dns.TypeA: - if v4 := ip.To4(); v4 != nil { - reply.Answer = append(reply.Answer, &dns.A{ - Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl}, - A: v4, - }) - } - case dns.TypeAAAA: - if ip.To4() == nil { - reply.Answer = append(reply.Answer, &dns.AAAA{ - Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl}, - AAAA: ip, - }) - } + reply := new(dns.Msg) + reply.SetReply(req) + if q := req.Question[0]; q.Qtype == dns.TypeA { + reply.Answer = append(reply.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, + A: net.IPv4(9, 9, 9, 9), + }) } out, err := reply.Pack() if err != nil { @@ -49,55 +36,97 @@ func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte { return out } -func readWire(t *testing.T, r io.Reader) []byte { +func mockDoH(t *testing.T) *DohResolver { t.Helper() - b, err := io.ReadAll(r) - if err != nil { - t.Fatalf("read body: %v", err) - } - return b -} - -func TestAutoDial_StickyAfterUDPFailure(t *testing.T) { - // DoH backend: always responds with a valid wire-format reply. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body := readWire(t, r.Body) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + return + } w.Header().Set("Content-Type", "application/dns-message") - _, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) //nolint:errcheck + _, _ = w.Write(dnsAnswer(t, body)) })) - defer srv.Close() - - resolver := newDohResolverWithClient( + t.Cleanup(srv.Close) + return newDohResolverWithClient( []DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}}, srv.Client(), ) - - dial := autoDial(resolver) - - // Poison udpDNSServers so that udpProbe (real DNS round-trip) fails - // immediately — net.DialTimeout rejects the malformed address. - old := udpDNSServersPtr.Load() - bad := []string{"not-a-valid-host-port"} - udpDNSServersPtr.Store(&bad) - defer func() { udpDNSServersPtr.Store(old) }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - conn1, err := dial(ctx, "udp", "unused") - if err != nil { - t.Fatalf("first dial: %v", err) - } - _ = conn1.Close() - - // Second call must skip UDP entirely. We assert this by poisoning - // udpDNSServers with a value that would fail parsing — if the dialer - // touches UDP again the call errors loudly. - bad2 := []string{"still-not-a-valid-host-port"} - udpDNSServersPtr.Store(&bad2) - conn2, err := dial(ctx, "udp", "unused") - if err != nil { - t.Fatalf("second dial: %v", err) - } - _ = conn2.Close() +} + +// udpDNSServer поднимает UDP/53-стенд; reply == nil - сервер молчит. +func udpDNSServer(t *testing.T, reply func(query []byte) []byte) string { + t.Helper() + pc, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pc.Close() }) + go func() { + buf := make([]byte, 512) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + return + } + if reply != nil { + _, _ = pc.WriteTo(reply(buf[:n]), addr) + } + } + }() + return pc.LocalAddr().String() +} + +func useUDPServers(t *testing.T, servers []string) { + t.Helper() + old := udpDNSServersPtr.Load() + udpDNSServersPtr.Store(&servers) + t.Cleanup(func() { udpDNSServersPtr.Store(old) }) +} + +func TestAutoDial_DoHStickyUntilServersChange(t *testing.T) { + dial := autoDial(mockDoH(t)) + // Невалидный адрес валит UDP-пробу сразу. + servers := []string{"not-a-valid-host-port"} + useUDPServers(t, servers) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + dialTo := func() string { + t.Helper() + conn, err := dial(ctx, "udp", "unused") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + return conn.RemoteAddr().String() + } + dialTo() + + good := udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) }) + servers[0] = good + if dialTo() == good { + t.Fatal("re-probed UDP without servers change") + } + + SetUDPDNSServers([]string{good}) + if got := dialTo(); got != good { + t.Fatalf("after servers change dial went to %s, want UDP %s", got, good) + } +} + +// Молчащий первый сервер не должен съедать бюджет второго. +func TestUDPProbe_SilentFirstServer(t *testing.T) { + useUDPServers(t, []string{udpDNSServer(t, nil), udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })}) + if !udpProbe(300 * time.Millisecond) { + t.Fatal("udpProbe = false, want true via second server") + } +} + +// Эхо запроса - не DNS-ответ, UDP/53 такой сервер не обслуживает. +func TestUDPProbe_RejectsNonResponse(t *testing.T) { + useUDPServers(t, []string{udpDNSServer(t, func(q []byte) []byte { return append([]byte(nil), q...) })}) + if udpProbe(300 * time.Millisecond) { + t.Fatal("udpProbe = true on echoed query, want false") + } }