From 8d02e5daa092d7ada85dc1c36844154507447084 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Sat, 18 Apr 2026 00:32:34 +0300 Subject: [PATCH] feat(dns): DNS-over-HTTPS resolver for mobile networks --- client/doh.go | 601 +++++++++++++++++++++++++++++++++++++++ client/doh_test.go | 197 +++++++++++++ client/main.go | 83 +++--- client/manual_captcha.go | 16 +- go.mod | 7 +- go.sum | 12 +- 6 files changed, 852 insertions(+), 64 deletions(-) create mode 100644 client/doh.go create mode 100644 client/doh_test.go diff --git a/client/doh.go b/client/doh.go new file mode 100644 index 00000000..76744e6c --- /dev/null +++ b/client/doh.go @@ -0,0 +1,601 @@ +// DNS-over-HTTPS resolver for mobile networks where UDP/53 is blocked or +// spoofed. + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/dns" + + // Embedded Mozilla CA roots for CGO_ENABLED=0 builds (Android). + _ "golang.org/x/crypto/x509roots/fallback" +) + +const ( + dohQueryTimeout = 6 * time.Second + dohCacheMinTTL = 10 * time.Second + dohCacheMaxTTL = 1 * time.Hour + dohMaxResponseBytes = 64 * 1024 + dohContentType = "application/dns-message" + + dohDialerTimeout = 5 * time.Second + dohDialerKeepAlive = 30 * time.Second + appDialerTimeout = 20 * time.Second + appDialerKeepAlive = 30 * time.Second + + forwarderUDPBufSize = 4096 + forwarderTCPReadDL = 30 * time.Second + forwarderTCPWriteDL = 10 * time.Second + autoUDPBudget = 1500 * time.Millisecond +) + +// DohEndpoint describes a single DNS-over-HTTPS server together with the IPs +// we bootstrap to — so that resolving the endpoint hostname does not itself +// require DNS. +type DohEndpoint struct { + URL string + Hostname string + BootstrapIPs []string +} + +// Yandex is tried first because it tends to stay reachable on RU mobile +// operators even when international resolvers get blocked; Google and +// Cloudflare follow as fallbacks. +var defaultDohEndpoints = []DohEndpoint{ + {"https://common.dot.dns.yandex.net/dns-query", "common.dot.dns.yandex.net", []string{"77.88.8.8", "77.88.8.1"}}, + {"https://secure.dot.dns.yandex.net/dns-query", "secure.dot.dns.yandex.net", []string{"77.88.8.88", "77.88.8.2"}}, + {"https://family.dot.dns.yandex.net/dns-query", "family.dot.dns.yandex.net", []string{"77.88.8.7", "77.88.8.3"}}, + {"https://dns.google/dns-query", "dns.google", []string{"8.8.8.8", "8.8.4.4"}}, + {"https://cloudflare-dns.com/dns-query", "cloudflare-dns.com", []string{"1.1.1.1", "1.0.0.1"}}, +} + +// DohResolver resolves hostnames to IPs via DNS-over-HTTPS (RFC 8484). +type DohResolver struct { + endpoints []DohEndpoint + client *http.Client + cache *dohCache +} + +// NewDohResolver constructs a resolver using defaultDohEndpoints if endpoints +// is nil. Endpoint hostnames are dialed by IP using BootstrapIPs, so the DoH +// transport never depends on the system resolver. +func NewDohResolver(endpoints []DohEndpoint) *DohResolver { + if len(endpoints) == 0 { + endpoints = defaultDohEndpoints + } + return &DohResolver{ + endpoints: endpoints, + client: &http.Client{Timeout: dohQueryTimeout, Transport: newBootstrapTransport(endpoints)}, + cache: newDohCache(), + } +} + +// newDohResolverWithClient is a test hook that skips the bootstrap transport. +func newDohResolverWithClient(endpoints []DohEndpoint, client *http.Client) *DohResolver { + return &DohResolver{endpoints: endpoints, client: client, cache: newDohCache()} +} + +// newBootstrapTransport returns an http.Transport whose DialContext only +// knows how to reach the configured DoH endpoint hostnames, by mapping each +// to its BootstrapIPs. +func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport { + bootstrap := make(map[string][]string, len(endpoints)) + for _, ep := range endpoints { + bootstrap[ep.Hostname] = ep.BootstrapIPs + } + dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive} + + return &http.Transport{ + MaxIdleConns: 8, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, ok := bootstrap[host] + if !ok { + return nil, fmt.Errorf("doh: no bootstrap IPs for %q", host) + } + var lastErr error + for _, ip := range ips { + conn, derr := dialer.DialContext(ctx, network, net.JoinHostPort(ip, port)) + if derr == nil { + return conn, nil + } + lastErr = derr + } + return nil, lastErr + }, + } +} + +// LookupIPAddr resolves host to a combined list of A+AAAA IPs (IPv4 first). +// Cached results bypass the network entirely. +func (r *DohResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IP, error) { + if ip := net.ParseIP(host); ip != nil { + return []net.IP{ip}, nil + } + if ips, ok := r.cache.get(host); ok { + return ips, nil + } + + type res struct { + ips []net.IP + ttl time.Duration + err error + } + results := make(chan res, 2) + for _, qt := range [...]uint16{dns.TypeA, dns.TypeAAAA} { + go func(qtype uint16) { + ips, ttl, err := r.queryIPs(ctx, host, qtype) + results <- res{ips, ttl, err} + }(qt) + } + + var ( + all []net.IP + lastErr error + minTTL = dohCacheMaxTTL + ) + for range 2 { + rr := <-results + if rr.err != nil { + lastErr = rr.err + continue + } + all = append(all, rr.ips...) + if rr.ttl > 0 && rr.ttl < minTTL { + minTTL = rr.ttl + } + } + if len(all) == 0 { + if lastErr == nil { + lastErr = fmt.Errorf("doh: no records for %s", host) + } + return nil, lastErr + } + + // IPv4 before IPv6 — better compat with mobile IPv4-only CGNAT. + sort.SliceStable(all, func(i, j int) bool { + return (all[i].To4() != nil) && (all[j].To4() == nil) + }) + + if minTTL < dohCacheMinTTL { + minTTL = dohCacheMinTTL + } + r.cache.set(host, all, minTTL) + return all, nil +} + +// queryIPs issues one DoH query for qtype, walking endpoints until one +// succeeds, and parses the wire reply into IPs + min TTL. +func (r *DohResolver) queryIPs(ctx context.Context, host string, qtype uint16) ([]net.IP, time.Duration, error) { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(host), qtype) + m.Id = 0 // RFC 8484 §4.1 — zero ID is cache-friendly on shared caches. + m.RecursionDesired = true + wire, err := m.Pack() + if err != nil { + return nil, 0, fmt.Errorf("doh: pack query: %w", err) + } + + body, ep, err := r.forwardRaw(ctx, wire) + if err != nil { + return nil, 0, err + } + ips, ttl, err := parseAnswer(body) + if err != nil { + return nil, 0, fmt.Errorf("doh: parse %s: %w", ep.Hostname, err) + } + log.Printf("[DoH] %s %s via %s → %d IPs (ttl %s)", host, dns.TypeToString[qtype], ep.Hostname, len(ips), ttl) + return ips, ttl, nil +} + +// parseAnswer decodes a DNS wire reply into A/AAAA records and the minimum TTL. +func parseAnswer(body []byte) ([]net.IP, time.Duration, error) { + reply := new(dns.Msg) + if err := reply.Unpack(body); err != nil { + return nil, 0, fmt.Errorf("unpack: %w", err) + } + if reply.Rcode != dns.RcodeSuccess { + return nil, 0, fmt.Errorf("rcode %s", dns.RcodeToString[reply.Rcode]) + } + var ( + ips []net.IP + minTTL uint32 + ) + updateTTL := func(ttl uint32) { + if minTTL == 0 || ttl < minTTL { + minTTL = ttl + } + } + for _, ans := range reply.Answer { + switch a := ans.(type) { + case *dns.A: + ips = append(ips, a.A) + updateTTL(a.Hdr.Ttl) + case *dns.AAAA: + ips = append(ips, a.AAAA) + updateTTL(a.Hdr.Ttl) + } + } + return ips, time.Duration(minTTL) * time.Second, nil +} + +// forwardRaw POSTs an opaque DNS-wire query to the configured DoH endpoints +// in order and returns the first successful raw response together with the +// endpoint that produced it. No parsing — useful for the local forwarder +// which needs to pass through whatever the upstream resolver answers +// (RESINFO/HTTPS/SVCB/EDNS options/…). +func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, DohEndpoint, error) { + if len(r.endpoints) == 0 { + return nil, DohEndpoint{}, errors.New("doh: no endpoints configured") + } + var lastErr error + for _, ep := range r.endpoints { + body, err := r.postWire(ctx, ep, query) + if err != nil { + log.Printf("[DoH] %s: %v", ep.Hostname, err) + lastErr = err + continue + } + return body, ep, nil + } + return nil, DohEndpoint{}, lastErr +} + +// postWire performs a single application/dns-message POST to one endpoint. +func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(query)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", dohContentType) + req.Header.Set("Accept", dohContentType) + + resp, err := r.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return body, nil +} + +type dohCacheEntry struct { + ips []net.IP + expiry time.Time +} + +type dohCache struct { + mu sync.RWMutex + m map[string]dohCacheEntry +} + +func newDohCache() *dohCache { + return &dohCache{m: make(map[string]dohCacheEntry)} +} + +func (c *dohCache) get(host string) ([]net.IP, bool) { + c.mu.RLock() + e, ok := c.m[host] + c.mu.RUnlock() + if !ok || time.Now().After(e.expiry) { + return nil, false + } + out := make([]net.IP, len(e.ips)) + copy(out, e.ips) + return out, true +} + +func (c *dohCache) set(host string, ips []net.IP, ttl time.Duration) { + if ttl <= 0 { + return + } + if ttl > dohCacheMaxTTL { + ttl = dohCacheMaxTTL + } + cp := make([]net.IP, len(ips)) + copy(cp, ips) + c.mu.Lock() + c.m[host] = dohCacheEntry{ips: cp, expiry: time.Now().Add(ttl)} + c.mu.Unlock() +} + +// Go's net.Resolver dials this stub like a regular nameserver, which avoids +// the many edge cases of a fake-net.Conn approach (RESINFO probes, EDNS +// handshakes, truncation, …). Whatever it reads on UDP/TCP is sent verbatim +// to a DoH endpoint and the wire response is sent back to the client. + +type dohForwarder struct { + udpAddr string + tcpAddr string +} + +var ( + dohForwarderOnce sync.Once + dohForwarderInst *dohForwarder + dohForwarderErr error +) + +// sharedDohForwarder lazily starts a process-wide forwarder bound to the +// supplied resolver. The first caller wins; subsequent callers reuse the +// same forwarder regardless of what they pass in. +func sharedDohForwarder(r *DohResolver) (*dohForwarder, error) { + dohForwarderOnce.Do(func() { + dohForwarderInst, dohForwarderErr = startDohForwarder(r) + }) + return dohForwarderInst, dohForwarderErr +} + +func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) { + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + return nil, fmt.Errorf("doh forwarder: listen UDP: %w", err) + } + defer func() { + if err != nil { + _ = udpConn.Close() + } + }() + tcpLn, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + return nil, fmt.Errorf("doh forwarder: listen TCP: %w", err) + } + defer func() { + if err != nil { + _ = tcpLn.Close() + } + }() + + fwd := &dohForwarder{ + udpAddr: udpConn.LocalAddr().String(), + tcpAddr: tcpLn.Addr().String(), + } + log.Printf("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr) + + go fwd.serveUDP(udpConn, r) + go fwd.serveTCP(tcpLn, r) + return fwd, nil +} + +func (f *dohForwarder) serveUDP(conn *net.UDPConn, r *DohResolver) { + defer func() { _ = conn.Close() }() + buf := make([]byte, forwarderUDPBufSize) + for { + n, client, err := conn.ReadFromUDP(buf) + if err != nil { + log.Printf("[DoH] udp read: %v", err) + return + } + query := append([]byte(nil), buf[:n]...) + go func(q []byte, c *net.UDPAddr) { + ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout) + defer cancel() + resp, _, err := r.forwardRaw(ctx, q) + if err != nil { + log.Printf("[DoH] udp forward failed: %v", err) + return + } + if _, err := conn.WriteToUDP(resp, c); err != nil { + log.Printf("[DoH] udp write: %v", err) + } + }(query, client) + } +} + +func (f *dohForwarder) serveTCP(ln *net.TCPListener, r *DohResolver) { + defer func() { _ = ln.Close() }() + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("[DoH] tcp accept: %v", err) + return + } + go handleDohForwarderTCP(conn, r) + } +} + +func handleDohForwarderTCP(conn net.Conn, r *DohResolver) { + defer func() { _ = conn.Close() }() + for { + _ = conn.SetReadDeadline(time.Now().Add(forwarderTCPReadDL)) + var lenBuf [2]byte + if _, err := io.ReadFull(conn, lenBuf[:]); err != nil { + return + } + qlen := int(lenBuf[0])<<8 | int(lenBuf[1]) + if qlen == 0 || qlen > forwarderUDPBufSize { + return + } + query := make([]byte, qlen) + if _, err := io.ReadFull(conn, query); err != nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout) + resp, _, err := r.forwardRaw(ctx, query) + cancel() + if err != nil { + log.Printf("[DoH] tcp forward failed: %v", err) + return + } + out := make([]byte, 2+len(resp)) + out[0] = byte(len(resp) >> 8) + out[1] = byte(len(resp)) + copy(out[2:], resp) + _ = conn.SetWriteDeadline(time.Now().Add(forwarderTCPWriteDL)) + if _, err := conn.Write(out); err != nil { + return + } + } +} + +// dohForwarderDial returns a Resolver.Dial that connects to the local DoH +// forwarder over UDP or TCP (whichever the resolver asked for). +func dohForwarderDial(r *DohResolver) dialFunc { + return func(ctx context.Context, network, _ string) (net.Conn, error) { + fwd, err := sharedDohForwarder(r) + if err != nil { + return nil, err + } + var d net.Dialer + switch network { + case "tcp", "tcp4", "tcp6": + return d.DialContext(ctx, "tcp", fwd.tcpAddr) + default: + return d.DialContext(ctx, "udp", fwd.udpAddr) + } + } +} + +const ( + DNSModeUDP = "udp" + DNSModeDoH = "doh" + DNSModeAuto = "auto" +) + +var udpDNSServers = []string{ + "77.88.8.8:53", "77.88.8.1:53", + "8.8.8.8:53", "8.8.4.4:53", + "1.1.1.1:53", "1.0.0.1:53", +} + +type dialFunc = func(context.Context, string, string) (net.Conn, error) + +// buildDialer returns a net.Dialer whose internal Go resolver uses the +// chosen DNS transport. In "auto" mode the first total-failure of UDP/53 +// sticks the process onto DoH for the rest of its lifetime. +func buildDialer(mode string, r *DohResolver) net.Dialer { + switch mode { + case DNSModeUDP: + return newAppDialer(udpDNSDial) + case DNSModeDoH: + return newAppDialer(dohForwarderDial(r)) + case DNSModeAuto: + return newAppDialer(autoDial(r)) + default: + log.Panicf("unknown DNS mode %q", mode) + return net.Dialer{} + } +} + +// newAppDialer wraps a Resolver.Dial with the timeouts used everywhere in +// the app for outbound TCP/HTTP connections. +func newAppDialer(dial dialFunc) net.Dialer { + return net.Dialer{ + Timeout: appDialerTimeout, + KeepAlive: appDialerKeepAlive, + Resolver: &net.Resolver{PreferGo: true, Dial: dial}, + } +} + +// udpDNSDial picks the first reachable UDP/53 resolver from udpDNSServers. +func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) { + var ( + d net.Dialer + lastErr error + ) + for _, s := range udpDNSServers { + conn, err := d.DialContext(ctx, "udp", s) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("no UDP DNS servers available") + } + return nil, lastErr +} + +// autoDial returns a Dial that probes UDP/53 once with a real DNS round-trip; +// if the probe fails it latches onto DoH for the rest of the process. Built +// for Android, where the network can flip between Wi-Fi (UDP/53 works) and +// mobile (UDP/53 blocked). +// +// A simple dial-timeout doesn't work for UDP because UDP "dial" is +// connectionless and always succeeds instantly. The only way to know whether +// UDP/53 actually works is to send a real query and wait for a response. +func autoDial(r *DohResolver) dialFunc { + 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.Printf("[DNS] UDP/53 probe OK, using UDP") + } else { + log.Printf("[DNS] UDP/53 unreachable; sticky-switching to DoH") + useDoH.Store(true) + } + }) + if useDoH.Load() { + return doh(ctx, network, addr) + } + return udpDNSDial(ctx, network, addr) + } +} + +// udpProbe sends a real DNS A query for a well-known domain via UDP and +// checks whether any response arrives within the deadline. We try the first +// two servers from udpDNSServers under a shared deadline — if neither +// responds, UDP/53 is blocked. +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 + } + + deadline := time.Now().Add(timeout) + buf := make([]byte, 512) + limit := min(len(udpDNSServers), 2) + for _, server := range udpDNSServers[:limit] { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + conn, err := net.DialTimeout("udp", server, remaining) + if err != nil { + continue + } + _ = conn.SetDeadline(deadline) + _, _ = conn.Write(wire) + n, err := conn.Read(buf) + _ = conn.Close() + if err == nil && n > 12 { + return true + } + } + return false +} diff --git a/client/doh_test.go b/client/doh_test.go new file mode 100644 index 00000000..39d9088f --- /dev/null +++ b/client/doh_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "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 { + 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, + }) + } + } + out, err := reply.Pack() + if err != nil { + t.Fatalf("pack reply: %v", err) + } + return out +} + +func readWire(t *testing.T, r io.Reader) []byte { + t.Helper() + b, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read body: %v", err) + } + return b +} + +func TestDohResolver_LookupIPAddr_Success(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if ct := r.Header.Get("Content-Type"); ct != "application/dns-message" { + t.Errorf("wrong Content-Type: %q", ct) + } + body := readWire(t, r.Body) + w.Header().Set("Content-Type", "application/dns-message") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(dohAnswer(t, body, net.ParseIP("93.184.216.34"), 300)) + })) + defer srv.Close() + + r := newDohResolverWithClient( + []DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}}, + srv.Client(), + ) + + ips, err := r.LookupIPAddr(context.Background(), "example.com") + if err != nil { + t.Fatalf("lookup: %v", err) + } + if len(ips) == 0 { + t.Fatalf("no ips returned") + } + if ips[0].String() != "93.184.216.34" { + t.Fatalf("unexpected ip %s", ips[0]) + } + // Two concurrent queries fire (A + AAAA), so we expect 2 hits. + if got := hits.Load(); got != 2 { + t.Fatalf("expected 2 HTTP hits, got %d", got) + } +} + +func TestDohResolver_Fallback(t *testing.T) { + var firstHits, secondHits atomic.Int32 + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + firstHits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer first.Close() + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secondHits.Add(1) + body := readWire(t, r.Body) + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(dohAnswer(t, body, net.ParseIP("1.2.3.4"), 300)) + })) + defer second.Close() + + r := newDohResolverWithClient( + []DohEndpoint{ + {URL: first.URL, Hostname: "first", BootstrapIPs: []string{"127.0.0.1"}}, + {URL: second.URL, Hostname: "second", BootstrapIPs: []string{"127.0.0.1"}}, + }, + first.Client(), + ) + ips, err := r.LookupIPAddr(context.Background(), "example.com") + if err != nil { + t.Fatalf("lookup: %v", err) + } + if len(ips) != 1 || ips[0].String() != "1.2.3.4" { + t.Fatalf("unexpected ips: %v", ips) + } + if firstHits.Load() == 0 || secondHits.Load() == 0 { + t.Fatalf("fallback did not probe both endpoints: first=%d second=%d", firstHits.Load(), secondHits.Load()) + } +} + +func TestDohResolver_Cache(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + body := readWire(t, r.Body) + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(dohAnswer(t, body, net.ParseIP("5.6.7.8"), 300)) + })) + defer srv.Close() + + r := newDohResolverWithClient( + []DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}}, + srv.Client(), + ) + if _, err := r.LookupIPAddr(context.Background(), "example.com"); err != nil { + t.Fatalf("first lookup: %v", err) + } + firstHits := hits.Load() + if _, err := r.LookupIPAddr(context.Background(), "example.com"); err != nil { + t.Fatalf("second lookup: %v", err) + } + if hits.Load() != firstHits { + t.Fatalf("cache miss: expected %d HTTP hits, got %d", firstHits, hits.Load()) + } +} + +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) + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) + })) + defer srv.Close() + + resolver := 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 := udpDNSServers + udpDNSServers = []string{"not-a-valid-host-port"} + defer func() { udpDNSServers = 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. + udpDNSServers = []string{"still-not-a-valid-host-port"} + conn2, err := dial(ctx, "udp", "unused") + if err != nil { + t.Fatalf("second dial: %v", err) + } + _ = conn2.Close() +} diff --git a/client/main.go b/client/main.go index 73dfd172..d4ad403a 100644 --- a/client/main.go +++ b/client/main.go @@ -32,7 +32,6 @@ import ( tlsclient "github.com/bogdanfinn/tls-client" "github.com/bogdanfinn/tls-client/profiles" - "github.com/bschaatsbergen/dnsdialer" "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/cbeuw/connutil" "github.com/google/uuid" @@ -247,27 +246,26 @@ func generateFakeCursor() string { return "[" + strings.Join(points, ",") + "]" } -func getCustomNetDialer() net.Dialer { - return net.Dialer{ - Timeout: 20 * time.Second, - KeepAlive: 30 * time.Second, - Resolver: &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - var d net.Dialer - dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} - var lastErr error - for _, dns := range dnsServers { - conn, err := d.DialContext(ctx, "udp", dns) - if err == nil { - return conn, nil - } - lastErr = err - } - return nil, lastErr - }, - }, - } +// dnsMode is set in main() from the -dns flag and consumed by appDialer(). +var dnsMode = DNSModeAuto + +// dohResolverSingleton is shared across all callers of appDialer(). +var ( + dohResolverOnce sync.Once + dohResolverInstance *DohResolver +) + +func sharedDohResolver() *DohResolver { + dohResolverOnce.Do(func() { + dohResolverInstance = NewDohResolver(nil) + }) + return dohResolverInstance +} + +// appDialer returns the net.Dialer used by tls-client and other HTTP callers. +// DNS transport is selected by the -dns flag (udp | doh | auto). +func appDialer() net.Dialer { + return buildDialer(dnsMode, sharedDohResolver()) } // endregion @@ -710,7 +708,7 @@ func (c *StreamCredentialsCache) invalidate(streamID int) { log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID) } -func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { +func getVkCredsCached(ctx context.Context, link string, streamID int) (string, string, string, error) { cache := getStreamCache(streamID) cacheID := getCacheID(streamID) @@ -734,7 +732,7 @@ func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dn return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil } - user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer) + user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID) if err != nil { return "", "", "", err } @@ -748,7 +746,7 @@ var ( globalLastVkFetchTime time.Time ) -func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { +func fetchVkCredsSerialized(ctx context.Context, link string, streamID int) (string, string, string, error) { vkRequestMu.Lock() defer vkRequestMu.Unlock() @@ -770,10 +768,10 @@ func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dial globalLastVkFetchTime = time.Now() }() - return fetchVkCreds(ctx, link, streamID, dialer) + return fetchVkCreds(ctx, link, streamID) } -func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { +func fetchVkCreds(ctx context.Context, link string, streamID int) (string, string, string, error) { // Check Global Lockout to prevent API bans if time.Now().Unix() < globalCaptchaLockout.Load() { return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") @@ -785,7 +783,7 @@ func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdia for _, creds := range vkCredentialsList { log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID) - user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, dialer, jar) + user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, jar) if err == nil { log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID) @@ -808,7 +806,7 @@ func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdia return "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr) } -func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, error) { +func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, string, error) { profile := Profile{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`, @@ -820,7 +818,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede tlsclient.WithTimeoutSeconds(20), tlsclient.WithClientProfile(profiles.Chrome_146), tlsclient.WithCookieJar(jar), - tlsclient.WithDialer(getCustomNetDialer()), + tlsclient.WithDialer(appDialer()), ) if err != nil { return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err) @@ -969,7 +967,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede var t, k string var e error if captchaErr.RedirectURI != "" { - t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer) + t, e = solveCaptchaViaProxy(captchaErr.RedirectURI) } else if captchaErr.CaptchaImg != "" { k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg) } else { @@ -1077,6 +1075,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede if !ok || len(urlsRaw) == 0 { return "", "", "", fmt.Errorf("missing or empty urls in turn_server") } + log.Printf("[STREAM %d] [VK Auth] turn_server urls: %v", streamID, urlsRaw) urlStr, ok := urlsRaw[0].(string) if !ok { return "", "", "", fmt.Errorf("turn server url is not a string") @@ -1209,10 +1208,12 @@ func getYandexCreds(link string) (string, string, string, error) { } endpoint := "https://" + telemostConfHost + telemostConfPath + appD := appDialer() tr := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, + DialContext: appD.DialContext, } client := &http.Client{ Timeout: 20 * time.Second, @@ -1264,7 +1265,8 @@ func getYandexCreds(link string) (string, string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - dialer := websocket.Dialer{} + wsAppD := appDialer() + dialer := websocket.Dialer{NetDialContext: wsAppD.DialContext} var conn *websocket.Conn conn, resp, err = dialer.DialContext(ctx, data.Wss, h) if err != nil { @@ -1553,6 +1555,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } var turnServerAddr string turnServerAddr = net.JoinHostPort(urlhost, urlport) + log.Printf("[STREAM %d] [TURN] dialing %s (udp=%v)", streamID, turnServerAddr, turnParams.udp) turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) if err1 != nil { err = fmt.Errorf("failed to resolve TURN server address: %s", err1) @@ -1812,7 +1815,15 @@ func main() { vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") debugFlag := flag.Bool("debug", false, "enable debug logging") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") + dnsFlag := flag.String("dns", DNSModeAuto, "DNS resolution mode: udp | doh | auto (auto tries UDP/53 first, sticky-fallback to DoH on total failure)") flag.Parse() + switch *dnsFlag { + case DNSModeUDP, DNSModeDoH, DNSModeAuto: + dnsMode = *dnsFlag + default: + log.Panicf("invalid -dns value %q (expected udp|doh|auto)", *dnsFlag) + } + log.Printf("[DNS] mode=%s", dnsMode) if *peerAddr == "" { log.Panicf("Need peer address!") } @@ -1834,14 +1845,8 @@ func main() { parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] - dialer := dnsdialer.New( - dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"), - dnsdialer.WithStrategy(dnsdialer.Fallback{}), - dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour), - ) - getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) { - return getVkCredsCached(ctx, s, streamID, dialer) + return getVkCredsCached(ctx, s, streamID) } if *n <= 0 { *n = 10 diff --git a/client/manual_captcha.go b/client/manual_captcha.go index 826478cd..fb677f65 100644 --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -17,8 +17,6 @@ import ( "runtime" "strings" "time" - - "github.com/bschaatsbergen/dnsdialer" ) const captchaListenPort = "8765" @@ -335,19 +333,17 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { } } -func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { - transport := &http.Transport{ +func newCaptchaProxyTransport() *http.Transport { + d := appDialer() + return &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: false, + DialContext: d.DialContext, } - if dialer != nil { - transport.DialContext = dialer.DialContext - } - return transport } func startCaptchaServer(srv *http.Server, logPrefix string) error { @@ -443,7 +439,7 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer} return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") } -func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { +func solveCaptchaViaProxy(redirectURI string) (string, error) { keyCh := make(chan string, 1) targetURL, err := neturl.Parse(redirectURI) @@ -451,7 +447,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, return "", fmt.Errorf("invalid redirect URI: %v", err) } - transport := newCaptchaProxyTransport(dialer) + transport := newCaptchaProxyTransport() proxy := &httputil.ReverseProxy{ Transport: transport, diff --git a/go.mod b/go.mod index e6e59294..9c221a0a 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,17 @@ go 1.25.5 require ( github.com/bogdanfinn/fhttp v0.6.8 github.com/bogdanfinn/tls-client v1.14.0 - github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 github.com/cbeuw/connutil v1.0.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/miekg/dns v1.1.72 github.com/pion/dtls/v3 v3.1.2 github.com/pion/logging v0.2.4 github.com/pion/transport/v4 v4.0.1 github.com/pion/turn/v5 v5.0.3 github.com/xtaci/kcp-go/v5 v5.6.18 github.com/xtaci/smux v1.5.34 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b ) require ( @@ -25,11 +26,9 @@ require ( github.com/bogdanfinn/utls v1.7.7-barnius // indirect github.com/bogdanfinn/websocket v1.5.5-barnius // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/reedsolomon v1.12.4 // indirect - github.com/miekg/dns v1.1.72 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -46,6 +45,4 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/grpc v1.80.0 // indirect ) diff --git a/go.sum b/go.sum index aef2d971..5fa25119 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5Zl github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= -github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5TvZm8FVcuHP1288k+DEu1XM26DtRjcidOxpGXs= -github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU= github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA= github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -49,8 +47,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= @@ -105,6 +101,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b h1:ZG2SxTKsx1w3pUpOMD9dliRYnhWC5R5jmL6UDPCbYj4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -154,22 +152,16 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=