From f24c5416de91349fd6a0332cb4e4744da4ca95fc Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Thu, 11 Jun 2026 19:27:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(dnsdial):=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=84=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B1=D1=8D=D0=BA=20=D0=BD=D0=B0=20DoH=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B5=20UDP/53=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B1=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/client/dnsdial/doh.go | 22 +++++++++- internal/client/dnsdial/doh_test.go | 64 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/internal/client/dnsdial/doh.go b/internal/client/dnsdial/doh.go index 43b6050..5376f47 100644 --- a/internal/client/dnsdial/doh.go +++ b/internal/client/dnsdial/doh.go @@ -468,6 +468,10 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) { // // Просто dial-timeout не годится: UDP "dial" безсоединительный и всегда успешен // мгновенно. Единственный способ узнать — реально отправить запрос и ждать ответ. +// +// Проба one-shot: если сеть сменилась после неё (Wi-Fi→мобилка, UDP/53 серверы +// стали недостижимы), ловим это по ошибке udpDNSDial и динамически залипаем на +// DoH — иначе процесс навсегда застрял бы на мёртвом UDP. func autoDial(r *DohResolver) dialFunc { var ( probed sync.Once @@ -486,7 +490,23 @@ func autoDial(r *DohResolver) dialFunc { if useDoH.Load() { return doh(ctx, network, addr) } - return udpDNSDial(ctx, network, addr) + conn, err := udpDNSDial(ctx, network, addr) + if err == nil { + return conn, nil + } + if ctx.Err() != nil { + return nil, err + } + // UDP/53 отвалился (смена сети) — пробуем DoH; залипаем только если он + // реально поднялся, иначе отдаём исходную UDP-ошибку. + dohConn, dohErr := doh(ctx, network, addr) + if dohErr != nil { + return nil, err + } + if useDoH.CompareAndSwap(false, true) { + Log.Warnf("[DNS] UDP/53 dial failed (%v); sticky-switching to DoH", err) + } + return dohConn, nil } } diff --git a/internal/client/dnsdial/doh_test.go b/internal/client/dnsdial/doh_test.go index e26995a..f0b04bb 100644 --- a/internal/client/dnsdial/doh_test.go +++ b/internal/client/dnsdial/doh_test.go @@ -58,6 +58,70 @@ func readWire(t *testing.T, r io.Reader) []byte { return b } +func TestAutoDial_DynamicFallbackOnUDPDialError(t *testing.T) { + // DoH backend всегда отвечает валидным wire-ответом. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readWire(t, r.Body) + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) //nolint:errcheck + })) + defer srv.Close() + resolver := newDohResolverWithClient( + []DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}}, + srv.Client(), + ) + + // Локальный UDP DNS-ответчик: проба (udpProbe) пройдёт. + pc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatalf("listen udp: %v", err) + } + defer func() { _ = pc.Close() }() + go func() { + buf := make([]byte, 512) + for { + n, from, rerr := pc.ReadFromUDP(buf) + if rerr != nil { + return + } + _, _ = pc.WriteToUDP(dohAnswer(t, buf[:n], net.ParseIP("1.2.3.4"), 60), from) //nolint:errcheck + } + }() + + old := udpDNSServersPtr.Load() + good := []string{pc.LocalAddr().String()} + udpDNSServersPtr.Store(&good) + defer func() { udpDNSServersPtr.Store(old) }() + + dial := autoDial(resolver) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Первый вызов: проба OK + реальный UDP-dial OK. + conn1, err := dial(ctx, "udp", "unused") + if err != nil { + t.Fatalf("first dial (UDP): %v", err) + } + _ = conn1.Close() + + // Сеть "сменилась": UDP/53 серверы недостижимы → dial должен упасть и + // динамически переключиться на DoH. + bad := []string{"not-a-valid-host-port"} + udpDNSServersPtr.Store(&bad) + conn2, err := dial(ctx, "udp", "unused") + if err != nil { + t.Fatalf("second dial must fall back to DoH, got: %v", err) + } + _ = conn2.Close() + + // После фоллбэка процесс залип на DoH: третий вызов с битыми UDP проходит. + conn3, err := dial(ctx, "udp", "unused") + if err != nil { + t.Fatalf("third dial must stay on DoH, got: %v", err) + } + _ = conn3.Close() +} + 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) {