fix(dns): проба udp/53 не теряет второй сервер, doh до смены сети

This commit is contained in:
samosvalishe
2026-09-22 20:04:05 +03:00
parent 5a1c6e299c
commit b0e3cfe20a
2 changed files with 119 additions and 79 deletions
+25 -14
View File
@@ -429,23 +429,27 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
return nil, lastErr
}
// autoDial выполняет DNS probe по UDP/53 и при недоступности переключается на DoH.
func autoDial(r *DohResolver) dialFunc {
var (
probed sync.Once
useDoH atomic.Bool
mu sync.Mutex
probed *[]string
useDoH 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")
mu.Lock()
if servers := udpDNSServersPtr.Load(); servers != probed {
probed = servers
useDoH = !udpProbe(autoUDPBudget)
if useDoH {
Log().Warnf("[DNS] UDP/53 unreachable; switching to DoH until network change")
} else {
Log().Warnf("[DNS] UDP/53 unreachable; sticky-switching to DoH")
useDoH.Store(true)
Log().Infof("[DNS] UDP/53 probe OK, using UDP")
}
})
if useDoH.Load() {
}
viaDoH := useDoH
mu.Unlock()
if viaDoH {
return doh(ctx, network, addr)
}
return udpDNSDial(ctx, network, addr)
@@ -466,24 +470,31 @@ func udpProbe(timeout time.Duration) bool {
buf := make([]byte, 512)
servers := udpDNSServers()
limit := min(len(servers), 2)
for _, server := range servers[:limit] {
for i, server := range servers[:limit] {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
// Остаток бюджета делится поровну: молчащий сервер не съедает попытку следующего.
attempt := remaining / time.Duration(limit-i)
// netctl.Apply обязателен для исключения сокета пробы из VPN-туннеля.
d := net.Dialer{Timeout: remaining, Control: netctl.Apply}
d := net.Dialer{Timeout: attempt, Control: netctl.Apply}
conn, err := d.Dial("udp", server) //nolint:noctx
if err != nil {
continue
}
_ = conn.SetDeadline(deadline) //nolint:errcheck
_ = conn.SetDeadline(time.Now().Add(attempt)) //nolint:errcheck
_, _ = conn.Write(wire)
n, err := conn.Read(buf)
_ = conn.Close()
if err == nil && n > 12 {
if err == nil && isReply(m, buf[:n]) {
return true
}
}
return false
}
func isReply(query *dns.Msg, raw []byte) bool {
var resp dns.Msg
return resp.Unpack(raw) == nil && resp.Response && resp.Id == query.Id
}
+94 -65
View File
@@ -12,35 +12,22 @@ import (
"github.com/miekg/dns"
)
// dohAnswer builds a wire-format DNS reply for a single question with one
// answer of the matching type (A or AAAA). TTL is returned as-is.
func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
func dnsAnswer(t *testing.T, query []byte) []byte {
t.Helper()
req := new(dns.Msg)
if err := req.Unpack(query); err != nil {
t.Fatalf("unpack query: %v", err)
}
reply := new(dns.Msg)
reply.SetReply(req)
if len(req.Question) != 1 {
t.Fatalf("expected 1 question, got %d", len(req.Question))
}
q := req.Question[0]
switch q.Qtype {
case dns.TypeA:
if v4 := ip.To4(); v4 != nil {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: v4,
})
}
case dns.TypeAAAA:
if ip.To4() == nil {
reply.Answer = append(reply.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
AAAA: ip,
})
}
reply := new(dns.Msg)
reply.SetReply(req)
if q := req.Question[0]; q.Qtype == dns.TypeA {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.IPv4(9, 9, 9, 9),
})
}
out, err := reply.Pack()
if err != nil {
@@ -49,55 +36,97 @@ func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
return out
}
func readWire(t *testing.T, r io.Reader) []byte {
func mockDoH(t *testing.T) *DohResolver {
t.Helper()
b, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read body: %v", err)
}
return b
}
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) {
body := readWire(t, r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
return
}
w.Header().Set("Content-Type", "application/dns-message")
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) //nolint:errcheck
_, _ = w.Write(dnsAnswer(t, body))
}))
defer srv.Close()
resolver := newDohResolverWithClient(
t.Cleanup(srv.Close)
return newDohResolverWithClient(
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
srv.Client(),
)
dial := autoDial(resolver)
// 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)
defer func() { udpDNSServersPtr.Store(old) }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn1, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("first dial: %v", err)
}
_ = conn1.Close()
// 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")
if err != nil {
t.Fatalf("second dial: %v", err)
}
_ = conn2.Close()
}
// udpDNSServer поднимает UDP/53-стенд; reply == nil - сервер молчит.
func udpDNSServer(t *testing.T, reply func(query []byte) []byte) string {
t.Helper()
pc, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pc.Close() })
go func() {
buf := make([]byte, 512)
for {
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return
}
if reply != nil {
_, _ = pc.WriteTo(reply(buf[:n]), addr)
}
}
}()
return pc.LocalAddr().String()
}
func useUDPServers(t *testing.T, servers []string) {
t.Helper()
old := udpDNSServersPtr.Load()
udpDNSServersPtr.Store(&servers)
t.Cleanup(func() { udpDNSServersPtr.Store(old) })
}
func TestAutoDial_DoHStickyUntilServersChange(t *testing.T) {
dial := autoDial(mockDoH(t))
// Невалидный адрес валит UDP-пробу сразу.
servers := []string{"not-a-valid-host-port"}
useUDPServers(t, servers)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
dialTo := func() string {
t.Helper()
conn, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
return conn.RemoteAddr().String()
}
dialTo()
good := udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })
servers[0] = good
if dialTo() == good {
t.Fatal("re-probed UDP without servers change")
}
SetUDPDNSServers([]string{good})
if got := dialTo(); got != good {
t.Fatalf("after servers change dial went to %s, want UDP %s", got, good)
}
}
// Молчащий первый сервер не должен съедать бюджет второго.
func TestUDPProbe_SilentFirstServer(t *testing.T) {
useUDPServers(t, []string{udpDNSServer(t, nil), udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })})
if !udpProbe(300 * time.Millisecond) {
t.Fatal("udpProbe = false, want true via second server")
}
}
// Эхо запроса - не DNS-ответ, UDP/53 такой сервер не обслуживает.
func TestUDPProbe_RejectsNonResponse(t *testing.T) {
useUDPServers(t, []string{udpDNSServer(t, func(q []byte) []byte { return append([]byte(nil), q...) })})
if udpProbe(300 * time.Millisecond) {
t.Fatal("udpProbe = true on echoed query, want false")
}
}