fix(dnsdial): вернуть простую UDP-пробу auto до auto-пробы реального хоста

This commit is contained in:
samosvalishe
2026-06-17 22:48:17 +03:00
parent f00465c9c2
commit 7d1ae10a47
3 changed files with 59 additions and 285 deletions
-5
View File
@@ -92,11 +92,6 @@ func main() {
dnsdial.SetUDPDNSServers(cfg.DNS.Servers)
logger.Infof("[DNS] using custom UDP servers: %v", cfg.DNS.Servers)
}
if cfg.Provider.Name == config.ProviderVK {
// auto-проба валидирует резолв реального control-plane хоста VK, а не
// нейтрального домена: ТСПУ режет login.vk.ru выборочно.
dnsdial.SetProbeHost("login.vk.ru")
}
appDialer := dnsdial.AppDialer(cfg.DNS.Mode)
dnsdial.InstallGlobalResolver(cfg.DNS.Mode)
if cfg.Obf.GenKey {
+54 -173
View File
@@ -1,6 +1,6 @@
// Package dnsdial владеет DNS-резолвингом и net.Dialer'ом, прокинутым во все
// outbound HTTP/TLS клиенты. По Mode выбирает UDP/53, DNS-over-HTTPS или auto
// (UDP-probe -> sticky DoH fallback).
// (UDP-probe → sticky DoH fallback).
package dnsdial
import (
@@ -18,6 +18,8 @@ import (
"sync/atomic"
"time"
"github.com/miekg/dns"
"github.com/samosvalishe/free-turn-proxy/internal/logx"
// встроенные Mozilla CA roots для CGO_ENABLED=0 сборок (Android).
@@ -46,16 +48,7 @@ const (
forwarderUDPBufSize = 4096
forwarderTCPReadDL = 30 * time.Second
forwarderTCPWriteDL = 10 * time.Second
autoUDPProbeBudget = 1500 * time.Millisecond
autoReevalInterval = 30 * time.Second // мин. интервал авто-перепроб (recovery)
autoUDPFailThreshold = 2 // подряд неудачных UDP-резолвов -> перепроба
dnsMinResponseBytes = 12 // размер DNS-заголовка; меньше = не ответ
// defaultProbeHost - нейтральный дефолт auto-пробы, если провайдер не задал
// свой через SetProbeHost. Провайдеру стоит указывать реальную control-plane
// цель (VK -> login.vk.ru), чтобы проба мерила резолв нужного домена.
defaultProbeHost = "dns.google"
autoUDPBudget = 1500 * time.Millisecond
)
// DohEndpoint - один DNS-over-HTTPS сервер с bootstrap-IP, чтобы резолв самого
@@ -469,176 +462,64 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
return nil, lastErr
}
// probeHostPtr хранит control-plane хост, чей резолв валидирует auto-проба.
// Дефолт нейтральный (defaultProbeHost); провайдер задаёт свой через SetProbeHost.
var probeHostPtr atomic.Pointer[string]
func probeHost() string {
if h := probeHostPtr.Load(); h != nil && *h != "" {
return *h
}
return defaultProbeHost
}
// SetProbeHost задаёт хост для auto-пробы (реальная цель провайдера, напр.
// login.vk.ru). Пустой игнорируется. Вызывать до первого резолва - проба ленивая
// (см. autoDial). Указатель меняется атомарно, безопасно конкурентно.
func SetProbeHost(host string) {
host = strings.TrimSpace(host)
if host == "" {
return
}
probeHostPtr.Store(&host)
}
// autoState держит решение auto-режима о DNS-транспорте и восстанавливает его в
// рантайме (не one-shot): на UDP серия неудачных резолвов уводит на DoH; на DoH
// периодическая перепроба возвращает на UDP, когда цель снова резолвится. Решение
// принимается по резолву реального control-plane хоста (probeHost), а не
// нейтрального домена: ТСПУ фильтрует выборочно, "UDP жив на постороннем домене"
// ничего не гарантирует.
type autoState struct {
udpDial dialFunc
dohDial dialFunc
host func() string
once sync.Once
useDoH atomic.Bool
udpFails atomic.Int32
lastEval atomic.Int64 // UnixNano последней decide()
evaluating atomic.Bool
}
func newAutoState(r *DohResolver) *autoState {
return &autoState{
udpDial: udpDNSDial,
dohDial: dohForwarderDial(r),
host: probeHost,
}
}
// autoDial возвращает dialFunc auto-режима поверх свежего autoState.
// autoDial возвращает Dial, который один раз пробит UDP/53 реальным DNS
// round-trip'ом; при провале залипает на DoH до конца процесса. Сделано под
// Android, где сеть скачет между Wi-Fi (UDP/53 работает) и мобилкой (блок).
//
// Просто dial-timeout не годится: UDP "dial" безсоединительный и всегда успешен
// мгновенно. Единственный способ узнать - реально отправить запрос и ждать ответ.
func autoDial(r *DohResolver) dialFunc {
return newAutoState(r).dial
}
// decide пробует резолвить цель и обновляет useDoH:
// 1. UDP оператора резолвит цель в валидный адрес -> UDP;
// 2. иначе -> DoH (последний резерв).
//
// Валидационную DoH-пробу тут НЕ делаем: решение от неё не зависит (при провале
// UDP уходим на DoH в любом случае), а её бюджет вешал старт на синхронном пути
// (once.Do первого dial). Восстановление UDP проверяет фоновый maybeReeval.
//
// quiet подавляет лог перехода - для стартового решения, логируемого в dial().
func (a *autoState) decide(quiet bool) {
host := a.host()
a.lastEval.Store(time.Now().UnixNano())
a.udpFails.Store(0)
if probeResolves(a.udpDial, host, autoUDPProbeBudget) {
if a.useDoH.Swap(false) && !quiet {
Log.Infof("[DNS] auto: UDP/53 resolves %s again - switching back to UDP", host)
var (
probed sync.Once
useDoH atomic.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")
} else {
Log.Warnf("[DNS] UDP/53 unreachable; sticky-switching to DoH")
useDoH.Store(true)
}
})
if useDoH.Load() {
return doh(ctx, network, addr)
}
return
}
if !a.useDoH.Swap(true) && !quiet {
Log.Warnf("[DNS] auto: UDP/53 can't resolve %s - switching to DoH", host)
return udpDNSDial(ctx, network, addr)
}
}
// maybeReeval запускает decide() в фоне не чаще autoReevalInterval и без гонок
// (один проб за раз). Дёргается с DoH-пути (проверить recovery UDP) и при серии
// неудач UDP (уйти на DoH).
func (a *autoState) maybeReeval() {
if time.Since(time.Unix(0, a.lastEval.Load())) < autoReevalInterval {
return
}
if !a.evaluating.CompareAndSwap(false, true) {
return
}
go func() {
defer a.evaluating.Store(false)
a.decide(false)
}()
}
// onUDPResult учитывает исход одного UDP-резолва; серия неудач инициирует перепробу.
func (a *autoState) onUDPResult(ok bool) {
if ok {
a.udpFails.Store(0)
return
}
if a.udpFails.Add(1) >= autoUDPFailThreshold {
a.maybeReeval()
}
}
func (a *autoState) dial(ctx context.Context, network, addr string) (net.Conn, error) {
a.once.Do(func() { //nolint:contextcheck // проба со своим бюджетом, не привязана к ctx одного резолва
a.decide(true)
if a.useDoH.Load() {
Log.Warnf("[DNS] auto: starting on DoH (UDP/53 can't resolve %s)", a.host())
} else {
Log.Infof("[DNS] auto: starting on UDP/53 (resolves %s)", a.host())
}
})
if a.useDoH.Load() {
a.maybeReeval() //nolint:contextcheck // фоновая перепроба со своим бюджетом
return a.dohDial(ctx, network, addr)
}
conn, err := a.udpDial(ctx, network, addr)
if err != nil {
a.onUDPResult(false) //nolint:contextcheck // фоновая перепроба со своим бюджетом
return nil, err
}
return &dnsHealthConn{Conn: conn, report: a.onUDPResult}, nil
}
// dnsHealthConn наблюдает один UDP DNS-обмен: после Write дожидается Read и
// сообщает report(ok), где ok = ответ получен (≥dnsMinResponseBytes), а !ok =
// таймаут/дроп/ошибка. Так auto-режим ловит рантайм-деградацию UDP (селективный
// дроп цели), не дожидаясь рестарта процесса.
type dnsHealthConn struct {
net.Conn
report func(bool)
wrote bool
reported bool
}
func (c *dnsHealthConn) Write(b []byte) (int, error) {
n, err := c.Conn.Write(b)
if err == nil {
c.wrote = true
}
return n, err
}
func (c *dnsHealthConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
if c.wrote && !c.reported {
c.reported = true
c.report(err == nil && n >= dnsMinResponseBytes)
}
return n, err
}
// probeResolves сообщает, резолвит ли dial хост host в валидный публичный IPv4
// за timeout. Валидный = NOERROR + хотя бы один global-unicast не-private адрес.
// Таймаут/дроп, пустой ответ и подмена на 0.0.0.0/loopback/private успехом не
// считаются - иначе селективный дроп или DNS-подмена цели читались бы как
// рабочий транспорт. Резолв идёт через Go-резолвер поверх dial - тот же путь,
// что и боевые запросы (UDP/53 либо DoH-форвардер).
func probeResolves(dial dialFunc, host string, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
res := &net.Resolver{PreferGo: true, Dial: dial}
ips, err := res.LookupIP(ctx, "ip4", host)
// udpProbe шлёт реальный DNS A-запрос к known-домену по UDP и проверяет, придёт
// ли ответ до дедлайна. Пробуем первые два сервера из udpDNSServers под общим
// deadline - если ни один не ответил, UDP/53 заблокирован.
func udpProbe(timeout time.Duration) bool {
m := new(dns.Msg)
m.SetQuestion("dns.google.", dns.TypeA)
m.RecursionDesired = true
wire, err := m.Pack()
if err != nil {
return false
}
for _, ip := range ips {
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
deadline := time.Now().Add(timeout)
buf := make([]byte, 512)
servers := udpDNSServers()
limit := min(len(servers), 2)
for _, server := range servers[:limit] {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
conn, err := net.DialTimeout("udp", server, remaining) //nolint:noctx
if err != nil {
continue
}
_ = conn.SetDeadline(deadline) //nolint:errcheck
_, _ = conn.Write(wire)
n, err := conn.Read(buf)
_ = conn.Close()
if err == nil && n > 12 {
return true
}
}
+5 -107
View File
@@ -2,7 +2,6 @@ package dnsdial
import (
"context"
"errors"
"io"
"net"
"net/http"
@@ -75,8 +74,8 @@ func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
dial := autoDial(resolver)
// Poison udpDNSServers so the UDP probe fails - udpDNSDial rejects the
// malformed address; auto then validates DoH (mock) and sticks to it.
// 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)
@@ -91,9 +90,9 @@ func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
}
_ = conn1.Close()
// Second call must skip UDP entirely (sticky within autoReevalInterval -
// maybeReeval is rate-gated, so no re-probe touches UDP here). We assert this
// by poisoning udpDNSServers with a value that would fail parsing.
// 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")
@@ -102,104 +101,3 @@ func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
}
_ = conn2.Close()
}
// startFakeUDPDNS поднимает локальный UDP DNS, отвечающий на A-запрос фиксированным
// answer (nil -> NOERROR без записей). Возвращает адрес ip:port.
func startFakeUDPDNS(t *testing.T, answer net.IP) string {
t.Helper()
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)
}
t.Cleanup(func() { _ = pc.Close() })
go func() {
buf := make([]byte, 1024)
for {
n, addr, rerr := pc.ReadFromUDP(buf)
if rerr != nil {
return
}
req := new(dns.Msg)
if req.Unpack(buf[:n]) != nil {
continue
}
reply := new(dns.Msg)
reply.SetReply(req)
if answer != nil && len(req.Question) == 1 && req.Question[0].Qtype == dns.TypeA {
if v4 := answer.To4(); v4 != nil {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: v4,
})
}
}
out, perr := reply.Pack()
if perr != nil {
continue
}
_, _ = pc.WriteToUDP(out, addr)
}
}()
return pc.LocalAddr().String()
}
func TestProbeResolves(t *testing.T) {
cases := []struct {
name string
answer net.IP // nil -> NOERROR без A-записей (домен дропнут/отфильтрован)
want bool
}{
{"valid public A", net.ParseIP("93.184.216.34"), true},
{"poisoned unspecified", net.ParseIP("0.0.0.0"), false},
{"no answer", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := startFakeUDPDNS(t, tc.answer)
old := udpDNSServersPtr.Load()
s := []string{server}
udpDNSServersPtr.Store(&s)
defer udpDNSServersPtr.Store(old)
if got := probeResolves(udpDNSDial, "login.vk.ru", 2*time.Second); got != tc.want {
t.Errorf("probeResolves(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
func failDial(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("dial unavailable")
}
// UDP не резолвит цель и DoH-проба тоже падает -> auto залипает на DoH.
func TestAutoState_FailsOverToDoH(t *testing.T) {
a := &autoState{udpDial: udpDNSDial, dohDial: failDial, host: func() string { return "login.vk.ru" }}
old := udpDNSServersPtr.Load()
bad := []string{"not-a-valid-host-port"}
udpDNSServersPtr.Store(&bad)
defer udpDNSServersPtr.Store(old)
a.decide(true)
if !a.useDoH.Load() {
t.Fatal("expected sticky DoH when UDP can't resolve target")
}
}
// Залипли на DoH, UDP снова резолвит цель -> перепроба возвращает на UDP (recovery).
func TestAutoState_RecoversToUDP(t *testing.T) {
a := &autoState{udpDial: udpDNSDial, dohDial: failDial, host: func() string { return "login.vk.ru" }}
a.useDoH.Store(true) // имитируем залипание на DoH
server := startFakeUDPDNS(t, net.ParseIP("93.184.216.34"))
old := udpDNSServersPtr.Load()
s := []string{server}
udpDNSServersPtr.Store(&s)
defer udpDNSServersPtr.Store(old)
a.decide(false)
if a.useDoH.Load() {
t.Fatal("expected switch back to UDP after target resolves over UDP again")
}
}