Browse Source

Merge e115e4de8c into e8a96967dc

pull/151/merge
Anton 2 months ago
committed by GitHub
parent
commit
dfb9197d42
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 601
      client/doh.go
  2. 197
      client/doh_test.go
  3. 1208
      client/main.go
  4. 346
      client/manual_captcha.go
  5. 67
      client/profiles.go
  6. 317
      client/slider_captcha.go
  7. 7
      go.mod
  8. 12
      go.sum

601
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
}

197
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()
}

1208
client/main.go

File diff suppressed because it is too large

346
client/manual_captcha.go

@ -14,15 +14,44 @@ import (
"net/http/httputil"
neturl "net/url"
"os/exec"
"regexp"
"runtime"
"sort"
"strings"
"time"
"github.com/bschaatsbergen/dnsdialer"
)
const captchaListenPort = "8765"
// redactSensitiveQueryRe matches sensitive token/hash params in form bodies and
// query strings. Replaced with "<redacted:N>" so logs reveal presence and length
// without exposing the JWT itself.
var redactSensitiveQueryRe = regexp.MustCompile(`(?i)\b(session_token|access_token|success_token|hash|debug_info|browser_fp)=([^&\s]*)`)
var redactCookieValueRe = regexp.MustCompile(`(remix[a-z]+|prcl|domain_sid)=([^;\s]+)`)
func redactBodyForLog(s string) string {
return redactSensitiveQueryRe.ReplaceAllStringFunc(s, func(m string) string {
groups := redactSensitiveQueryRe.FindStringSubmatch(m)
if len(groups) < 3 {
return m
}
return groups[1] + "=<redacted:" + fmt.Sprint(len(groups[2])) + ">"
})
}
func redactHeaderForLog(name, value string) string {
switch strings.ToLower(name) {
case "cookie", "set-cookie":
return redactCookieValueRe.ReplaceAllString(value, "$1=<redacted>")
case "referer", "origin", "location":
return redactBodyForLog(value)
case "authorization", "proxy-authorization":
return "<redacted>"
}
return value
}
type browserCommand struct {
name string
args []string
@ -125,7 +154,23 @@ func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) {
req.Host = targetURL.Host
req.Header.Del("Accept-Encoding")
req.Header.Del("TE") // Disable transfer encoding compression
req.Header.Del("TE")
// Strip WebView identity / fingerprint leak headers. Android WebView
// auto-injects X-Requested-With with the host package name, which would
// reveal the proxy app to VK.
for _, h := range []string{
"X-Requested-With",
"X-Android-Package",
"X-Android-Cert",
"X-Client-Data",
"X-Discord-Locale",
"X-Discord-Timezone",
"Save-Data",
"Purpose",
"Sec-Purpose",
} {
req.Header.Del(h)
}
for _, headerName := range []string{"Origin", "Referer"} {
if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" {
req.Header.Set(headerName, rewritten)
@ -164,10 +209,84 @@ func rewriteProxyCookies(header http.Header) {
}
}
var htmlURLAttrDoubleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)"((?:https?:)?//[^"]+)"`)
var htmlURLAttrSingleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)'((?:https?:)?//[^']+)'`)
var (
scriptBlockRe = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
styleBlockRe = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
)
// rewriteHTMLAttrsServerSide rewrites absolute and protocol-relative URLs in
// src/href/action attributes of raw HTML. URLs matching the upstream origin go
// to localhost; other absolute URLs are routed through /generic_proxy. Skips
// <script> and <style> blocks: their contents are JS/CSS, not HTML attributes.
func rewriteHTMLAttrsServerSide(html string, targetURL *neturl.URL) string {
localOrigin := localCaptchaOrigin()
upstreamOrigin := targetOrigin(targetURL)
rewriteURL := func(rawURL string) string {
absURL := rawURL
if strings.HasPrefix(rawURL, "//") {
absURL = targetURL.Scheme + ":" + rawURL
}
if strings.HasPrefix(absURL, upstreamOrigin) {
return localOrigin + absURL[len(upstreamOrigin):]
}
if strings.HasPrefix(absURL, localOrigin) {
return rawURL
}
return "/generic_proxy?proxy_url=" + neturl.QueryEscape(absURL)
}
rewriteAttrs := func(s string) string {
s = htmlURLAttrDoubleRe.ReplaceAllStringFunc(s, func(match string) string {
groups := htmlURLAttrDoubleRe.FindStringSubmatch(match)
if len(groups) < 3 {
return match
}
return groups[1] + `"` + rewriteURL(groups[2]) + `"`
})
s = htmlURLAttrSingleRe.ReplaceAllStringFunc(s, func(match string) string {
groups := htmlURLAttrSingleRe.FindStringSubmatch(match)
if len(groups) < 3 {
return match
}
return groups[1] + `'` + rewriteURL(groups[2]) + `'`
})
return s
}
type span struct{ a, b int }
var spans []span
for _, m := range scriptBlockRe.FindAllStringIndex(html, -1) {
spans = append(spans, span{m[0], m[1]})
}
for _, m := range styleBlockRe.FindAllStringIndex(html, -1) {
spans = append(spans, span{m[0], m[1]})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].a < spans[j].a })
var b strings.Builder
last := 0
for _, s := range spans {
if s.a < last {
continue
}
b.WriteString(rewriteAttrs(html[last:s.a]))
b.WriteString(html[s.a:s.b])
last = s.b
}
b.WriteString(rewriteAttrs(html[last:]))
return b.String()
}
func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
localOrigin := localCaptchaOrigin()
upstreamOrigin := targetOrigin(targetURL)
html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
html = rewriteHTMLAttrsServerSide(html, targetURL)
script := fmt.Sprintf(`
<script>
@ -207,14 +326,45 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
function handleSuccessToken(token) {
if (!token) return;
console.log('Captcha solved, sending token to proxy...');
var body = 'token=' + encodeURIComponent(token);
// sendBeacon is the most reliable on mobile Safari:
// it's fire-and-forget and works even if the page navigates away.
if (navigator && navigator.sendBeacon) {
var blob = new Blob([body], {type: 'application/x-www-form-urlencoded'});
var sent = navigator.sendBeacon('/local-captcha-result', blob);
if (sent) {
console.log('Token sent via sendBeacon');
showDone();
return;
}
}
fetch('/local-captcha-result', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'token=' + encodeURIComponent(token)
}).then(function() {
document.body.innerHTML = '<h2 style="text-align:center;margin-top:20vh">Done! You can close the page.</h2>';
setTimeout(function() { window.close(); }, 300);
}).catch(function() {});
body: body
}).then(function(r) {
console.log('Proxy acknowledged token');
showDone();
}).catch(function(e) {
console.error('Fetch failed, trying form submit...', e);
var form = document.createElement('form');
form.method = 'POST';
form.action = '/local-captcha-result';
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'token';
input.value = token;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
}
function showDone() {
try { window.close(); } catch (e) {}
}
var origOpen = XMLHttpRequest.prototype.open;
@ -325,7 +475,11 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
</script>
`, localOrigin, upstreamOrigin)
// Inject as early as possible — at the opening <head> tag — so XHR/fetch
// overrides are active before any inline <script> in <head> runs.
switch {
case strings.Contains(html, "<head>"):
return strings.Replace(html, "<head>", "<head>"+script, 1)
case strings.Contains(html, "</head>"):
return strings.Replace(html, "</head>", script+"</head>", 1)
case strings.Contains(html, "</body>"):
@ -335,19 +489,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 {
@ -375,7 +527,7 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error {
return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; "))
}
// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token.
// runCaptchaServerAndWait triggers the browser, then waits for a solution token.
func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) {
srv := &http.Server{Handler: handler}
@ -389,20 +541,23 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
fmt.Println("==============================================")
fmt.Println()
log.Printf("[%s] Opening browser...", logPrefix)
openBrowser(captchaURL)
key := <-keyCh
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
return "", err
// Best-effort shutdown: token is already received, return it even if
// Shutdown times out (e.g. ishConn.SetDeadline is no-op on iSH and
// active connections can't be force-closed).
shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("%s: shutdown warning (token already received): %v", logPrefix, err)
}
return key, nil
}
// notifyKey pushes the key string to the given channel without blocking
func notifyKey(keyCh chan<- string, key string) {
if key != "" {
select {
@ -440,10 +595,87 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Done!</h2></body></html>`)
})
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha-image")
}
// loggingTransport intercepts captchaNotRobot.componentDone / .check requests
// from the WebView, captures the (User-Agent, Sec-CH-UA*, device, browser_fp)
// tuple, and persists it as SavedProfile so subsequent auto-solve attempts
// can replay the same fingerprint.
type loggingTransport struct {
rt http.RoundTripper
debug bool
}
func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) {
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
isCaptchaRequest := req.Body != nil && (strings.Contains(req.URL.Path, "captchaNotRobot.check") || strings.Contains(req.URL.Path, "captchaNotRobot.componentDone"))
if isCaptchaRequest {
b, err := io.ReadAll(req.Body)
if err != nil {
log.Printf("[Captcha Proxy] Failed to read request body: %v", err)
b = nil
}
req.Body = io.NopCloser(bytes.NewReader(b))
if t.debug {
log.Printf("[Captcha Proxy] Real browser sent %s data: %s", req.URL.Path, redactBodyForLog(string(b)))
for k, v := range req.Header {
log.Printf("[Captcha Proxy] Header (%s): %s = %s", req.URL.Path, k, redactHeaderForLog(k, strings.Join(v, ", ")))
}
}
parsedBody, perr := neturl.ParseQuery(string(b))
if perr != nil {
log.Printf("[Captcha Proxy] Failed to parse request body: %v", perr)
}
device := parsedBody.Get("device")
browserFp := parsedBody.Get("browser_fp")
if device != "" && browserFp != "" {
sp := SavedProfile{
Profile: Profile{
UserAgent: req.Header.Get("User-Agent"),
SecChUa: req.Header.Get("Sec-Ch-Ua"),
SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"),
SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"),
},
DeviceJSON: device,
BrowserFp: browserFp,
}
if err := SaveProfileToDisk(sp); err != nil {
log.Printf("[Captcha Proxy] Failed to save browser profile: %v", err)
} else {
log.Printf("[Captcha Proxy] Successfully intercepted and saved real browser profile!")
}
}
}
return t.rt.RoundTrip(req)
}
// genericProxyAllowedSuffixes are upstream host suffixes the WebView is
// permitted to fetch via /generic_proxy. Anything else is rejected so the
// loopback proxy cannot be abused as an open SSRF gadget.
var genericProxyAllowedSuffixes = []string{
"vk.com", "vk.ru", "vkuser.net", "vk-cdn.net",
"userapi.com", "okcdn.ru",
"mc.yandex.ru",
}
func isAllowedGenericProxyHost(host string) bool {
host = strings.ToLower(host)
if i := strings.Index(host, ":"); i >= 0 {
host = host[:i]
}
for _, suffix := range genericProxyAllowedSuffixes {
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
}
return false
}
func solveCaptchaViaProxy(redirectURI string) (string, error) {
keyCh := make(chan string, 1)
targetURL, err := neturl.Parse(redirectURI)
@ -451,7 +683,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := newCaptchaProxyTransport(dialer)
transport := &loggingTransport{rt: newCaptchaProxyTransport(), debug: isDebug}
proxy := &httputil.ReverseProxy{
Transport: transport,
@ -469,7 +701,6 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" {
log.Printf("[Captcha Proxy] Redirecting to: %s", loc)
if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
res.Header.Set("Location", rewritten)
} else {
@ -480,7 +711,9 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding")
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
if isDebug {
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
}
shouldInspectBody := strings.Contains(contentType, "text/html") ||
strings.Contains(contentType, "application/xhtml+xml") ||
@ -545,8 +778,15 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
mux := http.NewServeMux()
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form
token := r.FormValue("token")
if token != "" {
log.Printf("[Captcha] Received success token from browser (%d bytes)", len(token))
notifyKey(keyCh, token)
} else {
log.Printf("[Captcha] Received empty token from browser")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprint(w, "ok")
})
@ -557,6 +797,15 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
http.Error(w, "Bad URL", http.StatusBadRequest)
return
}
if targetParsed.Scheme != "http" && targetParsed.Scheme != "https" {
http.Error(w, "Unsupported scheme", http.StatusBadRequest)
return
}
if !strings.EqualFold(targetParsed.Host, targetURL.Host) && !isAllowedGenericProxyHost(targetParsed.Host) {
log.Printf("[Captcha Proxy] /generic_proxy rejected host=%s", targetParsed.Host)
http.Error(w, "Host not allowed", http.StatusForbidden)
return
}
genericReverse := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(req *httputil.ProxyRequest) {
@ -564,21 +813,53 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
req.Out.URL.RawQuery = targetParsed.RawQuery
rewriteProxyRequest(req.Out, targetParsed)
},
ModifyResponse: func(res *http.Response) error {
for _, h := range []string{
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"X-Content-Security-Policy",
"X-WebKit-CSP",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Embedder-Policy",
"Cross-Origin-Resource-Policy",
"X-Frame-Options",
"Strict-Transport-Security",
} {
res.Header.Del(h)
}
res.Header.Set("Access-Control-Allow-Origin", "*")
// captchaNotRobot.check goes to api.vk.ru (different host from
// the main proxy upstream vk.com), so it's routed via
// /generic_proxy. Extract success_token here so the server-side
// path works on iOS even if the JS callback never fires.
if strings.Contains(targetAuthURL, "captchaNotRobot.check") {
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr == nil {
_ = res.Body.Close()
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
res.ContentLength = int64(len(bodyBytes))
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
notifyKey(keyCh, extractSuccessToken(bodyBytes))
}
}
return nil
},
}
genericReverse.ServeHTTP(w, r)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.String())
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL))
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
return
}
proxy.ServeHTTP(w, r)
})
return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error")
return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "captcha-proxy")
}
func openBrowser(url string) {
@ -592,7 +873,12 @@ func openBrowser(url string) {
func browserOpenCommands(goos string, url string) []browserCommand {
switch goos {
case "windows":
return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}}
// rundll32 url.dll,FileProtocolHandler is more reliable than 'cmd /c start'
// because it bypasses cmd.exe and avoids issues with '&' and other special chars.
return []browserCommand{
{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", url}},
{name: "cmd", args: []string{"/c", "start", "", url}},
}
case "darwin":
return []browserCommand{{name: "open", args: []string{url}}}
case "linux":

67
client/profiles.go

@ -1,7 +1,11 @@
package main
import (
"encoding/json"
"math/rand"
"os"
"path/filepath"
"sync"
)
type Profile struct {
@ -11,7 +15,68 @@ type Profile struct {
SecChUaPlatform string
}
// profiles contain paired User-Agent and Client Hints strings to harden bot detection.
// SavedProfile is the captured browser fingerprint persisted after a manual
// captcha session. Reused for subsequent auto-solve attempts so VK sees a
// consistent (browser_fp, device, UA) triple rather than a freshly-generated one.
type SavedProfile struct {
Profile
DeviceJSON string
BrowserFp string
}
const profileFileName = "vk_profile.json"
var (
profilePathOnce sync.Once
profilePathVal string
)
// profileFilePath returns a writeable absolute path for the cached browser
// profile. Order: $VK_PROFILE_PATH, os.UserCacheDir(), os.TempDir(), CWD.
// CWD is last because on Android it's the read-only APK lib dir.
func profileFilePath() string {
profilePathOnce.Do(func() {
if p := os.Getenv("VK_PROFILE_PATH"); p != "" {
profilePathVal = p
return
}
if dir, err := os.UserCacheDir(); err == nil {
sub := filepath.Join(dir, "vk-turn-proxy")
if mkErr := os.MkdirAll(sub, 0o755); mkErr == nil {
profilePathVal = filepath.Join(sub, profileFileName)
return
}
}
if tmp := os.TempDir(); tmp != "" {
profilePathVal = filepath.Join(tmp, profileFileName)
return
}
profilePathVal = profileFileName
})
return profilePathVal
}
func LoadProfileFromDisk() (*SavedProfile, error) {
data, err := os.ReadFile(profileFilePath())
if err != nil {
return nil, err
}
var sp SavedProfile
if err := json.Unmarshal(data, &sp); err != nil {
return nil, err
}
return &sp, nil
}
func SaveProfileToDisk(sp SavedProfile) error {
data, err := json.MarshalIndent(sp, "", " ")
if err != nil {
return err
}
return os.WriteFile(profileFilePath(), data, 0o644)
}
// profile contains paired User-Agent and Client Hints strings to harden bot detection.
var profile = []Profile{
// Windows Chrome
{

317
client/slider_captcha.go

@ -3,14 +3,17 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/jpeg" // register JPEG decoder
"io"
"log"
"math/rand"
neturl "net/url"
"regexp"
"sort"
@ -23,7 +26,6 @@ import (
)
const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider"
defaultSliderAttempts = 4
)
@ -36,6 +38,17 @@ type captchaNotRobotSession struct {
client tlsclient.HttpClient
profile Profile
browserFp string
adFp string
debugInfo string
savedProfile *SavedProfile
}
func generateAdFp() string {
b := make([]byte, 16)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return base64.RawURLEncoding.EncodeToString(b)[:21]
}
type captchaSettingsResponse struct {
@ -68,14 +81,16 @@ type captchaBootstrap struct {
Settings *captchaSettingsResponse
}
func newCaptchaNotRobotSession(
ctx context.Context,
sessionToken string,
hash string,
streamID int,
client tlsclient.HttpClient,
profile Profile,
) *captchaNotRobotSession {
func newCaptchaNotRobotSession(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile, savedProfile *SavedProfile) *captchaNotRobotSession {
browserFp := generateBrowserFp(profile)
if savedProfile != nil {
browserFp = savedProfile.BrowserFp
}
// Per-session debug_info — a hardcoded hash becomes a stable fingerprint
// VK uses to flag the bot path (status=BOT). Mirrors callCaptchaNotRobot.
debugInfoBytes := sha256.Sum256([]byte(profile.UserAgent + sessionToken + strconv.FormatInt(time.Now().UnixNano(), 10)))
return &captchaNotRobotSession{
ctx: ctx,
sessionToken: sessionToken,
@ -83,7 +98,10 @@ func newCaptchaNotRobotSession(
streamID: streamID,
client: client,
profile: profile,
browserFp: generateBrowserFp(profile),
browserFp: browserFp,
adFp: generateAdFp(),
debugInfo: hex.EncodeToString(debugInfoBytes[:]),
savedProfile: savedProfile,
}
}
@ -91,7 +109,7 @@ func (s *captchaNotRobotSession) baseValues() neturl.Values {
values := neturl.Values{}
values.Set("session_token", s.sessionToken)
values.Set("domain", "vk.com")
values.Set("adFp", "")
values.Set("adFp", s.adFp)
values.Set("access_token", "")
return values
}
@ -104,6 +122,15 @@ func (s *captchaNotRobotSession) request(method string, values neturl.Values) (m
return nil, err
}
applyBrowserProfileFhttp(req, s.profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://api.vk.ru")
req.Header.Set("Referer", fmt.Sprintf("https://api.vk.ru/not_robot_captcha?domain=vk.com&session_token=%s&variant=popup&blank=1", s.sessionToken))
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
httpResp, err := s.client.Do(req)
if err != nil {
return nil, err
@ -135,7 +162,12 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues()
values.Set("browser_fp", s.browserFp)
values.Set("device", buildCaptchaDeviceJSON(s.profile))
deviceJSON := buildCaptchaDeviceJSON(s.profile)
if s.savedProfile != nil {
deviceJSON = s.savedProfile.DeviceJSON
}
values.Set("device", deviceJSON)
resp, err := s.request("captchaNotRobot.componentDone", values)
if err != nil {
@ -144,8 +176,8 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
respObj, ok := resp["response"].(map[string]interface{})
if ok {
if status, _ := respObj["status"].(string); status != "" && status != "OK" {
return fmt.Errorf("componentDone status: %s", status)
if statusVal, ok := respObj["status"].(string); ok && statusVal != "" && statusVal != "OK" {
return fmt.Errorf("componentDone status: %s", statusVal)
}
}
@ -153,7 +185,7 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
}
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}")))
return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}")))
}
func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
@ -169,28 +201,79 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s
return parseSliderCaptchaContentResponse(resp)
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
// requestSliderContentWithFallback tries getContent with the provided
// captcha_settings, then without it. VK sometimes reports show_type=checkbox
// in settings but actually serves slider content, so we probe both variants.
func (s *captchaNotRobotSession) requestSliderContentWithFallback(sliderSettings string, streamID int) (*sliderCaptchaContent, error) {
type attempt struct {
settings string
desc string
}
var attempts []attempt
if sliderSettings != "" {
attempts = []attempt{
{settings: sliderSettings, desc: "with captcha_settings"},
{settings: "", desc: "without captcha_settings"},
}
} else {
attempts = []attempt{
{settings: "", desc: "without captcha_settings"},
}
}
var lastErr error
for _, a := range attempts {
log.Printf("[STREAM %d] [Captcha] Requesting slider content (%s)...", streamID, a.desc)
content, err := s.requestSliderContent(a.settings)
if err == nil {
return content, nil
}
log.Printf("[STREAM %d] [Captcha] getContent failed (%s): %v", streamID, a.desc, err)
lastErr = err
}
return nil, lastErr
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, _ int, _ int) (*captchaCheckResult, error) {
answer, err := encodeSliderAnswer(activeSteps)
if err != nil {
return nil, err
}
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
return s.requestCheck("[]", answer)
}
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
values := s.baseValues()
// Per-session jitter on RTT/downlink — static arrays were a fingerprint.
rttSamples := 4 + rand.Intn(4)
rttBase := 40 + rand.Intn(120)
rttVals := make([]string, rttSamples)
for i := range rttVals {
rttVals[i] = strconv.Itoa(rttBase + rand.Intn(40) - 20)
}
connectionRtt := "[" + strings.Join(rttVals, ",") + "]"
dlSamples := 4 + rand.Intn(4)
dlBase := 2.0 + rand.Float64()*8.0
dlVals := make([]string, dlSamples)
for i := range dlVals {
dlVals[i] = strconv.FormatFloat(dlBase+(rand.Float64()-0.5)*0.4, 'f', 2, 64)
}
connectionDownlink := "[" + strings.Join(dlVals, ",") + "]"
values.Set("accelerometer", "[]")
values.Set("gyroscope", "[]")
values.Set("motion", "[]")
values.Set("cursor", cursor)
values.Set("taps", "[]")
values.Set("connectionRtt", "[]")
values.Set("connectionDownlink", "[]")
values.Set("connectionRtt", connectionRtt)
values.Set("connectionDownlink", connectionDownlink)
values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash)
values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo)
values.Set("debug_info", s.debugInfo)
resp, err := s.request("captchaNotRobot.check", values)
if err != nil {
@ -214,8 +297,9 @@ func callCaptchaNotRobotWithSliderPOC(
client tlsclient.HttpClient,
profile Profile,
initialSettings *captchaSettingsResponse,
savedProfile *SavedProfile,
) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile, savedProfile)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings()
@ -265,24 +349,19 @@ func callCaptchaNotRobotWithSliderPOC(
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID)
}
sliderContent, err := session.requestSliderContent(sliderSettings)
// After check returns BOT, a real browser renders the slider widget and calls
// componentDone again to signal "slider component is now loaded". Without this,
// VK refuses getContent with ERROR because it expects the widget lifecycle.
log.Printf("[STREAM %d] [Captcha] Re-registering slider component before getContent...", streamID)
time.Sleep(300 * time.Millisecond)
if err := session.requestComponentDone(); err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: slider componentDone failed: %v", streamID, err)
}
time.Sleep(200 * time.Millisecond)
sliderContent, err := session.requestSliderContentWithFallback(sliderSettings, streamID)
if err != nil {
log.Printf(
"[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...",
streamID,
err,
)
// Fallback: maybe it's just a checkbox that needs a human-like check
time.Sleep(300 * time.Millisecond)
finalCheck, err2 := session.requestCheckboxCheck()
if err2 == nil && finalCheck.Status == "OK" {
if finalCheck.SuccessToken == "" {
return "", fmt.Errorf("success_token not found in fallback check")
}
log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID)
session.requestEndSession()
return finalCheck.SuccessToken, nil
}
log.Printf("[STREAM %d] [Captcha] All slider getContent attempts failed: %v", streamID, err)
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
}
@ -300,12 +379,7 @@ func callCaptchaNotRobotWithSliderPOC(
)
successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) {
log.Printf(
"[STREAM %d] [Captcha] Slider guess position=%d score=%d",
streamID,
candidate.Index,
candidate.Score,
)
log.Printf("[STREAM %d] [Captcha] Slider guess position=%d score=%d", streamID, candidate.Index, candidate.Score)
return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates))
})
if err != nil {
@ -317,8 +391,9 @@ func callCaptchaNotRobotWithSliderPOC(
}
func buildCaptchaDeviceJSON(profile Profile) string {
// Fallback device JSON if no saved profile is available.
return fmt.Sprintf(
`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`,
`{"screenWidth":1536,"screenHeight":864,"screenAvailWidth":1536,"screenAvailHeight":816,"innerWidth":1536,"innerHeight":730,"devicePixelRatio":1.25,"language":"ru-RU","languages":["ru-RU","ru","en-US","en"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"prompt","userAgent":"%s"}`,
profile.UserAgent,
)
}
@ -529,6 +604,15 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt
status, _ := respObj["status"].(string)
if status != "OK" {
// Log all fields from the response to help diagnose why VK rejected getContent.
var debugFields []string
for k, v := range respObj {
if k != "image" {
debugFields = append(debugFields, fmt.Sprintf("%s=%v", k, v))
}
}
sort.Strings(debugFields)
log.Printf("[Captcha] getContent ERROR response fields: %s", strings.Join(debugFields, " "))
return nil, fmt.Errorf("slider getContent status: %s", status)
}
@ -731,14 +815,75 @@ func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderC
}
func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
rendered, err := renderSliderCandidate(img, gridSize, mapping)
if err != nil {
return 0, err
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
dstLeftIndex := row*gridSize + col
dstRightIndex := row*gridSize + col + 1
srcLeftIndex := mapping[dstLeftIndex]
srcRightIndex := mapping[dstRightIndex]
dstLeftRect := sliderTileRect(bounds, gridSize, dstLeftIndex)
dstRightRect := sliderTileRect(bounds, gridSize, dstRightIndex)
srcLeftRect := sliderTileRect(bounds, gridSize, srcLeftIndex)
srcRightRect := sliderTileRect(bounds, gridSize, srcRightIndex)
height := minInt(dstLeftRect.Dy(), dstRightRect.Dy())
leftSrcXRel := (dstLeftRect.Dx() - 1) * srcLeftRect.Dx() / dstLeftRect.Dx()
sxLeft := srcLeftRect.Min.X + leftSrcXRel
sxRight := srcRightRect.Min.X
for offset := 0; offset < height; offset++ {
syLeft := srcLeftRect.Min.Y + offset*srcLeftRect.Dy()/dstLeftRect.Dy()
syRight := srcRightRect.Min.Y + offset*srcRightRect.Dy()/dstRightRect.Dy()
score += pixelDiff(
img.At(sxLeft, syLeft),
img.At(sxRight, syRight),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
dstTopIndex := row*gridSize + col
dstBottomIndex := (row+1)*gridSize + col
srcTopIndex := mapping[dstTopIndex]
srcBottomIndex := mapping[dstBottomIndex]
dstTopRect := sliderTileRect(bounds, gridSize, dstTopIndex)
dstBottomRect := sliderTileRect(bounds, gridSize, dstBottomIndex)
srcTopRect := sliderTileRect(bounds, gridSize, srcTopIndex)
srcBottomRect := sliderTileRect(bounds, gridSize, srcBottomIndex)
width := minInt(dstTopRect.Dx(), dstBottomRect.Dx())
topSrcYRel := (dstTopRect.Dy() - 1) * srcTopRect.Dy() / dstTopRect.Dy()
syTop := srcTopRect.Min.Y + topSrcYRel
syBottom := srcBottomRect.Min.Y
for offset := 0; offset < width; offset++ {
sxTop := srcTopRect.Min.X + offset*srcTopRect.Dx()/dstTopRect.Dx()
sxBottom := srcBottomRect.Min.X + offset*srcBottomRect.Dx()/dstBottomRect.Dx()
score += pixelDiff(
img.At(sxTop, syTop),
img.At(sxBottom, syBottom),
)
}
}
}
return scoreRenderedSliderImage(rendered, gridSize), nil
return score, nil
}
// renderSliderCandidate produces a fully reassembled image for a given mapping.
// Kept for the test suite even though scoreSliderCandidate now samples pixels
// directly without materialising the rendered image.
func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
if gridSize <= 0 {
return nil, fmt.Errorf("invalid grid size: %d", gridSize)
@ -760,41 +905,6 @@ func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image
return rendered, nil
}
func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
height := minInt(leftRect.Dy(), rightRect.Dy())
for offset := 0; offset < height; offset++ {
score += pixelDiff(
img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
img.At(rightRect.Min.X, rightRect.Min.Y+offset),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
width := minInt(topRect.Dx(), bottomRect.Dx())
for offset := 0; offset < width; offset++ {
score += pixelDiff(
img.At(topRect.Min.X+offset, topRect.Max.Y-1),
img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
)
}
}
}
return score
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
@ -840,43 +950,6 @@ func absDiff(left uint32, right uint32) int64 {
return int64(right - left)
}
func generateSliderCursor(candidateIndex int, candidateCount int) string {
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
}
func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string {
if candidateCount <= 0 {
return "[]"
}
type cursorPoint struct {
X int `json:"x"`
Y int `json:"y"`
T int64 `json:"t"`
}
startX := 140
endX := startX + 620*candidateIndex/candidateCount
startY := 430
points := make([]cursorPoint, 0, 12)
for step := 0; step < 12; step++ {
x := startX + (endX-startX)*step/11
y := startY + ((step % 3) - 1)
points = append(points, cursorPoint{
X: x,
Y: y,
T: startTime + int64(step*18),
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}
func trySliderCaptchaCandidates(
candidates []sliderCandidate,
maxAttempts int,

7
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
)

12
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=

Loading…
Cancel
Save