fix(dnsdial): динамический фоллбэк на DoH при отказе UDP/53 после пробы

This commit is contained in:
samosvalishe
2026-06-11 19:27:30 +03:00
parent 2534bbe96a
commit f24c5416de
2 changed files with 85 additions and 1 deletions
+21 -1
View File
@@ -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
}
}
+64
View File
@@ -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) {