mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
fix(dnsdial): вернуть простую UDP-пробу auto до auto-пробы реального хоста
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user