diff --git a/cmd/client/main.go b/cmd/client/main.go index 206b31b..200b766 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -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 { diff --git a/internal/client/dnsdial/doh.go b/internal/client/dnsdial/doh.go index 6145e50..f00d445 100644 --- a/internal/client/dnsdial/doh.go +++ b/internal/client/dnsdial/doh.go @@ -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 } } diff --git a/internal/client/dnsdial/doh_test.go b/internal/client/dnsdial/doh_test.go index 3cac690..e26995a 100644 --- a/internal/client/dnsdial/doh_test.go +++ b/internal/client/dnsdial/doh_test.go @@ -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") - } -}