From 5bf6dd03995440e6cede3e2452ae6d08c286a3a7 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Tue, 19 May 2026 09:48:55 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20VK-=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B2=20client/internal/vkauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 2 плана рефакторинга. Выносит слой VK credentials/captcha/throttle/ lockout из client/main.go в отдельный пакет с инжектируемым хуком token-chain для тестов. Интерфейс: - vkauth.Client{GetCredentials, HandleAuthError, ResetErrors, LockoutUntilUnix} - vkauth.IsAuthError (package-level) - vkauth.DefaultAutoSolve / AutoSolveFunc / ManualSolveFunc Wire-формат и флаги CLI не изменены. client/main.go: 1967 → 1380. --- client/internal/vkauth/auto_solver.go | 46 ++ client/internal/vkauth/cache.go | 82 +++ client/internal/vkauth/client.go | 238 +++++++ client/internal/vkauth/client_test.go | 211 ++++++ client/internal/vkauth/solver.go | 64 ++ .../vkauth/solver_test.go} | 18 +- client/internal/vkauth/token.go | 304 ++++++++ client/internal/vkauth/types.go | 44 ++ client/main.go | 661 +----------------- 9 files changed, 1035 insertions(+), 633 deletions(-) create mode 100644 client/internal/vkauth/auto_solver.go create mode 100644 client/internal/vkauth/cache.go create mode 100644 client/internal/vkauth/client.go create mode 100644 client/internal/vkauth/client_test.go create mode 100644 client/internal/vkauth/solver.go rename client/{main_test.go => internal/vkauth/solver_test.go} (61%) create mode 100644 client/internal/vkauth/token.go create mode 100644 client/internal/vkauth/types.go diff --git a/client/internal/vkauth/auto_solver.go b/client/internal/vkauth/auto_solver.go new file mode 100644 index 0000000..e7fcdf8 --- /dev/null +++ b/client/internal/vkauth/auto_solver.go @@ -0,0 +1,46 @@ +package vkauth + +import ( + "context" + "fmt" + "log" + + "github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile" + "github.com/cacggghp/vk-turn-proxy/client/internal/captcha" + + tlsclient "github.com/bogdanfinn/tls-client" +) + +// DefaultAutoSolve drives the in-page widget flow. It loads a saved real +// browser profile when available so the captcha origin sees consistent +// fingerprints across the page load and the solve POST. +func DefaultAutoSolve( + ctx context.Context, + captchaErr *captcha.Error, + streamID int, + client tlsclient.HttpClient, + profile browserprofile.Profile, +) (string, error) { + log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID) + + if captchaErr.SessionToken == "" { + return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") + } + if captchaErr.RedirectURI == "" { + return "", fmt.Errorf("no redirect_uri for auto-solve") + } + + var savedProfile *browserprofile.Saved + if sp, err := browserprofile.Load(); err == nil { + log.Printf("[STREAM %d] [Captcha] Using saved real browser profile", streamID) + savedProfile = sp + profile = sp.Profile + } + + successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, savedProfile) + if err != nil { + return "", err + } + log.Printf("[STREAM %d] [Captcha] solver succeeded", streamID) + return successToken, nil +} diff --git a/client/internal/vkauth/cache.go b/client/internal/vkauth/cache.go new file mode 100644 index 0000000..f984135 --- /dev/null +++ b/client/internal/vkauth/cache.go @@ -0,0 +1,82 @@ +package vkauth + +import ( + "strings" + "sync" + "sync/atomic" +) + +// StreamCredentialsCache holds the resolved TURN creds for a group of streams +// plus auth-error tracking for invalidation decisions. +type StreamCredentialsCache struct { + creds TurnCredentials + mutex sync.RWMutex + errorCount atomic.Int32 + lastErrorTime atomic.Int64 +} + +// Store maps cache-id (streamID / streamsPerCache) -> StreamCredentialsCache. +type Store struct { + mu sync.RWMutex + caches map[int]*StreamCredentialsCache + streamsPerCache int +} + +func NewStore(streamsPerCache int) *Store { + if streamsPerCache <= 0 { + streamsPerCache = DefaultStreamsPerCache + } + return &Store{ + caches: make(map[int]*StreamCredentialsCache), + streamsPerCache: streamsPerCache, + } +} + +func (s *Store) CacheID(streamID int) int { + return streamID / s.streamsPerCache +} + +func (s *Store) Get(streamID int) *StreamCredentialsCache { + cacheID := s.CacheID(streamID) + + s.mu.RLock() + cache, exists := s.caches[cacheID] + s.mu.RUnlock() + if exists { + return cache + } + + s.mu.Lock() + defer s.mu.Unlock() + + if cache, exists = s.caches[cacheID]; exists { + return cache + } + cache = &StreamCredentialsCache{} + s.caches[cacheID] = cache + return cache +} + +// Invalidate clears the creds for the given stream's cache and resets error state. +func (c *StreamCredentialsCache) Invalidate() { + c.mutex.Lock() + c.creds = TurnCredentials{} + c.mutex.Unlock() + + c.errorCount.Store(0) + c.lastErrorTime.Store(0) +} + +// IsAuthError matches the historical heuristic used by the TURN client: a TURN +// allocate failure mentioning auth/401/stale-nonce should trigger cache invalidate. +func IsAuthError(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "401") || + strings.Contains(s, "Unauthorized") || + strings.Contains(s, "authentication") || + strings.Contains(s, "invalid credential") || + strings.Contains(s, "stale nonce") +} diff --git a/client/internal/vkauth/client.go b/client/internal/vkauth/client.go new file mode 100644 index 0000000..ff9d610 --- /dev/null +++ b/client/internal/vkauth/client.go @@ -0,0 +1,238 @@ +package vkauth + +import ( + "context" + "fmt" + "log" + "math/rand" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + tlsclient "github.com/bogdanfinn/tls-client" +) + +// Config configures a Client. Zero values pick safe defaults except for Dialer +// (must be set explicitly to preserve the existing custom-DNS behavior). +type Config struct { + // Credentials to try in order. nil/empty -> DefaultCredentials. + Credentials []VKCredentials + + // Dialer used by the HTTP transport for VK API requests. + Dialer net.Dialer + + // ManualOnly forces the manual captcha path on the first attempt. + ManualOnly bool + + // StreamsPerCache is the divisor for streamID -> cacheID. <=0 -> default. + StreamsPerCache int + + // StreamsAlive returns the count of currently-connected streams; used to + // decide whether an exhausted captcha is fatal vs throttleable. nil -> 1. + StreamsAlive func() int32 + + // AutoSolver / ManualSolver are pluggable captcha solvers. nil values + // disable that path (the flow will fall through to the next attempt). + AutoSolver AutoSolveFunc + ManualSolver ManualSolveFunc + + // Debugf is the gated debug log sink. nil -> no-op. + Debugf func(format string, v ...any) +} + +// Client is the VK auth + creds-cache facade used by callers. It owns the +// per-stream-group cache, the global request throttle, the captcha lockout +// timer, and the auth-error counter used to invalidate stale TURN creds. +type Client struct { + credentials []VKCredentials + dialer net.Dialer + manualOnly bool + streamsFn func() int32 + autoSolver AutoSolveFunc + manualSolve ManualSolveFunc + debugf func(format string, v ...any) + + store *Store + + lockout atomic.Int64 + + fetchMu sync.Mutex + lastFetchTime time.Time + + // tokenChain is the per-creds 4-step fetcher. Production wires this to + // (*Client).getTokenChain; tests inject a fake. + tokenChain tokenChainFn + + // minFetchInterval gates back-to-back VK requests. Tests can lower this. + minFetchIntervalFn func() time.Duration +} + +type tokenChainFn func(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) + +// New builds a Client from cfg. +func New(cfg Config) *Client { + c := &Client{ + credentials: cfg.Credentials, + dialer: cfg.Dialer, + manualOnly: cfg.ManualOnly, + streamsFn: cfg.StreamsAlive, + autoSolver: cfg.AutoSolver, + manualSolve: cfg.ManualSolver, + debugf: cfg.Debugf, + store: NewStore(cfg.StreamsPerCache), + } + if len(c.credentials) == 0 { + c.credentials = DefaultCredentials + } + if c.debugf == nil { + c.debugf = func(string, ...any) {} + } + if c.streamsFn == nil { + c.streamsFn = func() int32 { return 1 } + } + c.tokenChain = c.getTokenChain + c.minFetchIntervalFn = func() time.Duration { + return 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond + } + return c +} + +// GetCredentials returns (username, password, server-addr) suitable for a TURN +// allocate, fetching from VK (with throttle + cache) only when needed. +func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) (string, string, string, error) { + cache := c.store.Get(streamID) + cacheID := c.store.CacheID(streamID) + + cache.mutex.RLock() + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { + expires := time.Until(cache.creds.ExpiresAt) + u, p := cache.creds.Username, cache.creds.Password + addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)] + cache.mutex.RUnlock() + c.debugf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)", streamID, cacheID, expires, addr) + return u, p, addr, nil + } + cache.mutex.RUnlock() + + cache.mutex.Lock() + defer cache.mutex.Unlock() + + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { + addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)] + return cache.creds.Username, cache.creds.Password, addr, nil + } + + user, pass, addrs, err := c.fetchSerialized(ctx, link, streamID) + if err != nil { + return "", "", "", err + } + + cache.creds = TurnCredentials{ + Username: user, + Password: pass, + ServerAddrs: addrs, + ExpiresAt: time.Now().Add(CredentialLifetime - CacheSafetyMargin), + Link: link, + } + addr := addrs[streamID%len(addrs)] + return user, pass, addr, nil +} + +// HandleAuthError increments the auth-error counter for the stream's cache and +// invalidates when the threshold is reached within the rolling window. +// Returns true when the cache was invalidated. +func (c *Client) HandleAuthError(streamID int) bool { + cache := c.store.Get(streamID) + cacheID := c.store.CacheID(streamID) + now := time.Now().Unix() + + if now-cache.lastErrorTime.Load() > int64(ErrorWindow.Seconds()) { + cache.errorCount.Store(0) + } + count := cache.errorCount.Add(1) + cache.lastErrorTime.Store(now) + + log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, MaxCacheErrors) + + if count >= MaxCacheErrors { + log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID) + cache.Invalidate() + log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID) + return true + } + return false +} + +// ResetErrors zeroes the auth-error counter (call on successful allocate). +func (c *Client) ResetErrors(streamID int) { + c.store.Get(streamID).errorCount.Store(0) +} + +// LockoutUntilUnix returns the unix-second deadline the global captcha lockout +// is set to, or 0 when no lockout is active. +func (c *Client) LockoutUntilUnix() int64 { + return c.lockout.Load() +} + +// engageLockout arms the global captcha lockout for d from now. +func (c *Client) engageLockout(d time.Duration) { + c.lockout.Store(time.Now().Add(d).Unix()) +} + +// fetchSerialized enforces the 3s + jitter inter-request gap and then performs +// the try-all-credentials fetch. +func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) { + c.fetchMu.Lock() + defer c.fetchMu.Unlock() + + minInterval := c.minFetchIntervalFn() + elapsed := time.Since(c.lastFetchTime) + if !c.lastFetchTime.IsZero() && elapsed < minInterval { + wait := minInterval - elapsed + log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond)) + select { + case <-ctx.Done(): + return "", "", nil, ctx.Err() + case <-time.After(wait): + } + } + defer func() { c.lastFetchTime = time.Now() }() + return c.fetch(ctx, link, streamID) +} + +// fetch loops over c.credentials, returning on first success or terminal error. +func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, string, []string, error) { + if time.Now().Unix() < c.lockout.Load() { + return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") + } + + var lastErr error + jar := tlsclient.NewCookieJar() + for _, creds := range c.credentials { + log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID) + + user, pass, addrs, err := c.tokenChain(ctx, link, streamID, creds, jar) + if err == nil { + log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID) + return user, pass, addrs, nil + } + lastErr = err + log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err) + + es := err.Error() + if strings.Contains(es, ErrCaptchaWaitRequired) || strings.Contains(es, "FATAL_CAPTCHA") { + return "", "", nil, err + } + if strings.Contains(es, "error_code:29") || strings.Contains(es, "error_code: 29") || strings.Contains(es, "Rate limit") { + log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID) + } + } + return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr) +} + +func vkDelayRandom(minMs, maxMs int) { + ms := minMs + rand.Intn(maxMs-minMs+1) + time.Sleep(time.Duration(ms) * time.Millisecond) +} diff --git a/client/internal/vkauth/client_test.go b/client/internal/vkauth/client_test.go new file mode 100644 index 0000000..37d9345 --- /dev/null +++ b/client/internal/vkauth/client_test.go @@ -0,0 +1,211 @@ +package vkauth + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + tlsclient "github.com/bogdanfinn/tls-client" +) + +// newTestClient builds a Client with a zero-interval throttle and the supplied +// fake token-chain fetcher. +func newTestClient(t *testing.T, fake tokenChainFn, opts ...func(*Client)) *Client { + t.Helper() + c := New(Config{ + StreamsPerCache: 10, + Credentials: []VKCredentials{{ClientID: "a"}, {ClientID: "b"}, {ClientID: "c"}}, + }) + c.tokenChain = fake + c.minFetchIntervalFn = func() time.Duration { return 0 } + for _, o := range opts { + o(c) + } + return c +} + +func TestIsAuthError(t *testing.T) { + t.Parallel() + + cases := map[string]bool{ + "401 Unauthorized": true, + "alloc failed: 401": true, + "stale nonce": true, + "invalid credential": true, + "authentication required": true, + "connection refused": false, + "": false, + } + for msg, want := range cases { + got := IsAuthError(errors.New(msg)) + if got != want { + t.Errorf("IsAuthError(%q) = %v, want %v", msg, got, want) + } + } + if IsAuthError(nil) { + t.Error("IsAuthError(nil) = true; want false") + } +} + +func TestStoreCacheGrouping(t *testing.T) { + t.Parallel() + + s := NewStore(10) + if s.CacheID(0) != 0 || s.CacheID(9) != 0 || s.CacheID(10) != 1 { + t.Fatalf("unexpected cache grouping: 0→%d 9→%d 10→%d", s.CacheID(0), s.CacheID(9), s.CacheID(10)) + } + a := s.Get(3) + b := s.Get(7) + c := s.Get(13) + if a != b { + t.Error("streams 3 and 7 should share cache (group 0)") + } + if a == c { + t.Error("streams 3 and 13 should not share cache (groups 0 vs 1)") + } +} + +func TestHandleAuthErrorInvalidatesAtThreshold(t *testing.T) { + t.Parallel() + + c := newTestClient(t, func(_ context.Context, _ string, _ int, _ VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + return "u", "p", []string{"a:1"}, nil + }) + + for i := range MaxCacheErrors - 1 { + if c.HandleAuthError(0) { + t.Fatalf("invalidated too early at i=%d", i) + } + } + if !c.HandleAuthError(0) { + t.Fatal("expected invalidate at threshold") + } + + cache := c.store.Get(0) + if cache.errorCount.Load() != 0 { + t.Errorf("error count not reset after invalidate: %d", cache.errorCount.Load()) + } + if cache.creds.Username != "" { + t.Errorf("creds not cleared after invalidate: %q", cache.creds.Username) + } +} + +func TestFetchFallsBackThroughCredentialsList(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + c := newTestClient(t, func(_ context.Context, _ string, _ int, creds VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + calls.Add(1) + if creds.ClientID != "c" { + return "", "", nil, fmt.Errorf("Rate limit hit for %s", creds.ClientID) + } + return "user-c", "pass-c", []string{"server-c:443"}, nil + }) + + u, p, addr, err := c.GetCredentials(context.Background(), "L", 0) + if err != nil { + t.Fatalf("GetCredentials: %v", err) + } + if u != "user-c" || p != "pass-c" || addr != "server-c:443" { + t.Fatalf("unexpected creds: u=%q p=%q addr=%q", u, p, addr) + } + if calls.Load() != 3 { + t.Errorf("expected to walk all 3 creds, called %d times", calls.Load()) + } +} + +func TestFetchShortCircuitsOnCaptchaWait(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + c := newTestClient(t, func(_ context.Context, _ string, _ int, _ VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + calls.Add(1) + return "", "", nil, errors.New(ErrCaptchaWaitRequired) + }) + + _, _, _, err := c.GetCredentials(context.Background(), "L", 0) + if err == nil || !strings.Contains(err.Error(), ErrCaptchaWaitRequired) { + t.Fatalf("expected CAPTCHA_WAIT_REQUIRED, got %v", err) + } + if calls.Load() != 1 { + t.Errorf("expected short-circuit on captcha (1 call), got %d", calls.Load()) + } +} + +func TestGetCredentialsCacheHit(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + c := newTestClient(t, func(_ context.Context, _ string, _ int, _ VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + calls.Add(1) + return "u", "p", []string{"a:1", "b:2"}, nil + }) + + // First call populates the cache. + if _, _, _, err := c.GetCredentials(context.Background(), "L", 0); err != nil { + t.Fatal(err) + } + // Second call (same stream group) must hit the cache. + if _, _, _, err := c.GetCredentials(context.Background(), "L", 1); err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Errorf("expected single fetch, got %d", calls.Load()) + } + + // Sibling-stream addr round-robin: stream 0 -> addr[0], stream 1 -> addr[1]. + _, _, addr0, err := c.GetCredentials(context.Background(), "L", 0) + if err != nil { + t.Fatal(err) + } + _, _, addr1, err := c.GetCredentials(context.Background(), "L", 1) + if err != nil { + t.Fatal(err) + } + if addr0 == addr1 { + t.Errorf("expected round-robin addrs, both = %q", addr0) + } +} + +func TestLockoutBlocksFetch(t *testing.T) { + t.Parallel() + + c := newTestClient(t, func(_ context.Context, _ string, _ int, _ VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + t.Fatal("tokenChain must not be called while lockout is active") + return "", "", nil, nil + }) + c.engageLockout(time.Minute) + if c.LockoutUntilUnix() <= time.Now().Unix() { + t.Fatal("lockout deadline not in future") + } + + _, _, _, err := c.GetCredentials(context.Background(), "L", 0) + if err == nil || !strings.Contains(err.Error(), "global lockout active") { + t.Fatalf("expected lockout error, got %v", err) + } +} + +func TestThrottleHonorsContextCancel(t *testing.T) { + t.Parallel() + + c := newTestClient(t, func(_ context.Context, _ string, _ int, _ VKCredentials, _ tlsclient.CookieJar) (string, string, []string, error) { + return "u", "p", []string{"a:1"}, nil + }) + // Force throttle to wait long enough that ctx-cancel wins. + c.minFetchIntervalFn = func() time.Duration { return time.Hour } + // Prime lastFetchTime so the next call will throttle. + if _, _, _, err := c.GetCredentials(context.Background(), "L1", 0); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, _, err := c.GetCredentials(ctx, "L2", 100) // different cache group -> miss cache + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} diff --git a/client/internal/vkauth/solver.go b/client/internal/vkauth/solver.go new file mode 100644 index 0000000..6c64073 --- /dev/null +++ b/client/internal/vkauth/solver.go @@ -0,0 +1,64 @@ +package vkauth + +import ( + "context" + "net" + + "github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile" + "github.com/cacggghp/vk-turn-proxy/client/internal/captcha" + + tlsclient "github.com/bogdanfinn/tls-client" +) + +// CaptchaSolveMode selects between auto-solving and manual-browser fallback. +type CaptchaSolveMode int + +const ( + CaptchaSolveModeAuto CaptchaSolveMode = iota + CaptchaSolveModeManual +) + +// CaptchaSolveModeForAttempt picks the solver to use on a given retry attempt. +// Returns (mode, true) when a mode is available, (_, false) when exhausted. +func CaptchaSolveModeForAttempt(attempt int, manualOnly bool) (CaptchaSolveMode, bool) { + if manualOnly { + return CaptchaSolveModeManual, attempt == 0 + } + switch attempt { + case 0: + return CaptchaSolveModeAuto, true + case 1: + return CaptchaSolveModeManual, true + } + return 0, false +} + +func CaptchaSolveModeLabel(mode CaptchaSolveMode) string { + switch mode { + case CaptchaSolveModeAuto: + return "auto captcha" + case CaptchaSolveModeManual: + return "manual captcha" + default: + return "captcha" + } +} + +// AutoSolveFunc returns a success_token for the captcha when solvable via the +// in-page widget flow. Implementations must respect ctx cancellation. +type AutoSolveFunc func( + ctx context.Context, + captchaErr *captcha.Error, + streamID int, + http tlsclient.HttpClient, + profile browserprofile.Profile, +) (token string, err error) + +// ManualSolveFunc opens the local browser-based fallback. Returns either a +// success_token (token != "") or a captcha_key, depending on which path the +// VK error exposed. +type ManualSolveFunc func( + ctx context.Context, + captchaErr *captcha.Error, + dialer net.Dialer, +) (token, key string, err error) diff --git a/client/main_test.go b/client/internal/vkauth/solver_test.go similarity index 61% rename from client/main_test.go rename to client/internal/vkauth/solver_test.go index fd5d603..98b37b5 100644 --- a/client/main_test.go +++ b/client/internal/vkauth/solver_test.go @@ -1,4 +1,4 @@ -package main +package vkauth import "testing" @@ -8,17 +8,17 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) { t.Run("default flow", func(t *testing.T) { t.Parallel() - mode, ok := captchaSolveModeForAttempt(0, false) - if !ok || mode != captchaSolveModeAuto { + mode, ok := CaptchaSolveModeForAttempt(0, false) + if !ok || mode != CaptchaSolveModeAuto { t.Fatalf("expected first attempt to use auto captcha, got mode=%v ok=%v", mode, ok) } - mode, ok = captchaSolveModeForAttempt(1, false) - if !ok || mode != captchaSolveModeManual { + mode, ok = CaptchaSolveModeForAttempt(1, false) + if !ok || mode != CaptchaSolveModeManual { t.Fatalf("expected second attempt to use manual captcha, got mode=%v ok=%v", mode, ok) } - if _, ok = captchaSolveModeForAttempt(2, false); ok { + if _, ok = CaptchaSolveModeForAttempt(2, false); ok { t.Fatal("expected only two attempts in default flow") } }) @@ -26,12 +26,12 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) { t.Run("manual only flow", func(t *testing.T) { t.Parallel() - mode, ok := captchaSolveModeForAttempt(0, true) - if !ok || mode != captchaSolveModeManual { + mode, ok := CaptchaSolveModeForAttempt(0, true) + if !ok || mode != CaptchaSolveModeManual { t.Fatalf("expected manual mode on first attempt, got mode=%v ok=%v", mode, ok) } - if _, ok = captchaSolveModeForAttempt(1, true); ok { + if _, ok = CaptchaSolveModeForAttempt(1, true); ok { t.Fatal("expected only one manual captcha attempt when manual mode is forced") } }) diff --git a/client/internal/vkauth/token.go b/client/internal/vkauth/token.go new file mode 100644 index 0000000..5641300 --- /dev/null +++ b/client/internal/vkauth/token.go @@ -0,0 +1,304 @@ +package vkauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + neturl "net/url" + "strings" + "time" + + "github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile" + "github.com/cacggghp/vk-turn-proxy/client/internal/captcha" + "github.com/cacggghp/vk-turn-proxy/client/internal/namegen" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + "github.com/google/uuid" +) + +// getTokenChain walks the 4-step VK token exchange for one client_id/secret +// pair and returns the resulting TURN allocate triple. Captcha errors trigger +// the configured auto/manual solver chain. +func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) { + profile := browserprofile.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"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + } + + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), + tlsclient.WithTimeoutSeconds(20), + tlsclient.WithClientProfile(profiles.Chrome_146), + tlsclient.WithCookieJar(jar), + tlsclient.WithDialer(c.dialer), + ) + if err != nil { + return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err) + } + + name := namegen.Generate() + escapedName := neturl.QueryEscape(name) + + log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent) + + doRequest := func(data string, url string) (resp map[string]any, err error) { + parsedURL, err := neturl.Parse(url) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) + if err != nil { + return nil, err + } + req.Host = domain + browserprofile.ApplyFhttp(req, profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://vk.ru") + req.Header.Set("Referer", "https://vk.ru/") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Priority", "u=1, i") + + httpResp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := httpResp.Body.Close(); closeErr != nil { + log.Printf("close response body: %s", closeErr) + } + }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp, nil + } + + // Token 1: anonymous app token. + data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID) + resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token") + if err != nil { + return "", "", nil, err + } + dataMap, ok := resp["data"].(map[string]any) + if !ok { + return "", "", nil, fmt.Errorf("unexpected anon token response: %v", resp) + } + token1, ok := dataMap["access_token"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing access_token in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Token 1 -> getCallPreview (warmup, non-fatal). + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) + if _, prevErr := doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID); prevErr != nil { + log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, prevErr) + } + + vkDelayRandom(200, 400) + + // Token 2: anonymous call token (captcha may trigger here). + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1) + urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID) + + var token2 string + for attempt := 0; ; attempt++ { + resp, err = doRequest(data, urlAddr) + if err != nil { + return "", "", nil, err + } + + if errObj, hasErr := resp["error"].(map[string]any); hasErr { + captchaErr := captcha.ParseError(errObj) + if captchaErr != nil && captchaErr.IsCaptcha() { + solveMode, hasSolveMode := CaptchaSolveModeForAttempt(attempt, c.manualOnly) + if !hasSolveMode { + log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1) + c.engageLockout(60 * time.Second) + if c.streamsFn() == 0 { + log.Printf("[STREAM %d] [FATAL] 0 connected streams and captcha solve modes exhausted.", streamID) + return "", "", nil, errors.New(ErrFatalCaptchaNoStreams) + } + return "", "", nil, errors.New(ErrCaptchaWaitRequired) + } + + var successToken string + var captchaKey string + var solveErr error + + switch solveMode { + case CaptchaSolveModeAuto: + solveFn := c.autoSolver + if solveFn == nil { + solveFn = DefaultAutoSolve + } + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { + successToken, solveErr = solveFn(ctx, captchaErr, streamID, client, profile) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr) + } + } else { + solveErr = fmt.Errorf("missing fields for auto solve") + } + case CaptchaSolveModeManual: + if c.manualSolve == nil { + solveErr = fmt.Errorf("manual captcha solver not configured") + break + } + log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID) + // Background ctx so a short parent deadline doesn't cut user solve time. + manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute) + + type manualRes struct { + token string + key string + err error + } + resCh := make(chan manualRes, 1) + go func() { + t, k, e := c.manualSolve(manualCtx, captchaErr, c.dialer) + resCh <- manualRes{t, k, e} + }() + + select { + case res := <-resCh: + successToken = res.token + captchaKey = res.key + solveErr = res.err + if successToken != "" || captchaKey != "" { + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr) + solveErr = nil + } + log.Printf("[STREAM %d] [Captcha] Successfully got token from browser", streamID) + } else if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] manual solver returned error: %v", streamID, solveErr) + } + case <-manualCtx.Done(): + if manualCtx.Err() == context.DeadlineExceeded { + solveErr = fmt.Errorf("manual captcha timed out after 3m") + } else { + solveErr = fmt.Errorf("manual captcha interrupted: %w", manualCtx.Err()) + } + } + manualCancel() + } + + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, CaptchaSolveModeLabel(solveMode), attempt+1, solveErr) + nextSolveMode, hasNextSolveMode := CaptchaSolveModeForAttempt(attempt+1, c.manualOnly) + if hasNextSolveMode { + log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, CaptchaSolveModeLabel(nextSolveMode)) + continue + } + c.engageLockout(60 * time.Second) + if c.streamsFn() == 0 { + log.Printf("[STREAM %d] [FATAL] 0 connected streams and manual captcha failed/timed out.", streamID) + return "", "", nil, errors.New(ErrFatalCaptchaNoStreams) + } + return "", "", nil, errors.New(ErrCaptchaWaitRequired) + } + + if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" { + captchaErr.CaptchaAttempt = "1" + } + if captchaKey != "" { + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s", + link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1) + } else { + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s", + link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1) + } + continue + } + return "", "", nil, fmt.Errorf("VK API error: %v", errObj) + } + + respMap, okLoop := resp["response"].(map[string]any) + if !okLoop { + return "", "", nil, fmt.Errorf("unexpected getAnonymousToken response: %v", resp) + } + token2, okLoop = respMap["token"].(string) + if !okLoop { + return "", "", nil, fmt.Errorf("missing token in response: %v", resp) + } + break + } + + vkDelayRandom(100, 150) + + // Token 3: ok.ru session_key. + sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) + data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData)) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", nil, err + } + token3, ok := resp["session_key"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing session_key in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Token 4 -> TURN creds. + data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", nil, err + } + tsRaw, ok := resp["turn_server"].(map[string]any) + if !ok { + return "", "", nil, fmt.Errorf("missing turn_server in response: %v", resp) + } + user, ok := tsRaw["username"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing username in turn_server") + } + pass, ok := tsRaw["credential"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing credential in turn_server") + } + urlsRaw, ok := tsRaw["urls"].([]any) + if !ok || len(urlsRaw) == 0 { + return "", "", nil, fmt.Errorf("missing or empty urls in turn_server") + } + + log.Printf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw)) + for i, u := range urlsRaw { + log.Printf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u) + } + + var addresses []string + for _, u := range urlsRaw { + urlStr, ok := u.(string) + if !ok { + continue + } + clean := strings.Split(urlStr, "?")[0] + address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + addresses = append(addresses, address) + } + if len(addresses) == 0 { + return "", "", nil, fmt.Errorf("no valid TURN addresses found") + } + return user, pass, addresses, nil +} diff --git a/client/internal/vkauth/types.go b/client/internal/vkauth/types.go new file mode 100644 index 0000000..4c31dfe --- /dev/null +++ b/client/internal/vkauth/types.go @@ -0,0 +1,44 @@ +package vkauth + +import "time" + +// VKCredentials is one app_id/app_secret pair used to obtain anonymous tokens. +type VKCredentials struct { + ClientID string + ClientSecret string +} + +// TurnCredentials is the resolved TURN allocation for a stream group. +type TurnCredentials struct { + Username string + Password string + ServerAddrs []string + ExpiresAt time.Time + Link string +} + +// DefaultCredentials mirrors the public VK SDK app IDs the client tries in order. +var DefaultCredentials = []VKCredentials{ + {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID + {ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID + {ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID + {ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID + {ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP +} + +const ( + CredentialLifetime = 10 * time.Minute + CacheSafetyMargin = 60 * time.Second + MaxCacheErrors = 3 + ErrorWindow = 10 * time.Second + + DefaultStreamsPerCache = 10 +) + +// Sentinel error message fragments produced by the auth flow. Consumers match +// on substring to preserve wire compatibility with the existing caller logic. +const ( + ErrCaptchaWaitRequired = "CAPTCHA_WAIT_REQUIRED" + ErrFatalCaptchaNoStreams = "FATAL_CAPTCHA_FAILED_NO_STREAMS" + ErrLockoutActiveSuffix = "global lockout active" +) diff --git a/client/main.go b/client/main.go index c315a76..511a13b 100644 --- a/client/main.go +++ b/client/main.go @@ -4,16 +4,13 @@ package main import ( - "bytes" "context" - "encoding/json" "flag" "fmt" "io" "log" "math/rand" "net" - neturl "net/url" "os" "os/signal" "strconv" @@ -23,21 +20,15 @@ import ( "syscall" "time" - fhttp "github.com/bogdanfinn/fhttp" - tlsclient "github.com/bogdanfinn/tls-client" - "github.com/bogdanfinn/tls-client/profiles" - - "github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile" "github.com/cacggghp/vk-turn-proxy/client/internal/captcha" "github.com/cacggghp/vk-turn-proxy/client/internal/dnsdial" - "github.com/cacggghp/vk-turn-proxy/client/internal/namegen" + "github.com/cacggghp/vk-turn-proxy/client/internal/vkauth" "github.com/cacggghp/vk-turn-proxy/internal/bond" "github.com/cacggghp/vk-turn-proxy/internal/netadapt" "github.com/cacggghp/vk-turn-proxy/internal/stats" "github.com/cacggghp/vk-turn-proxy/internal/wrap" "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/cbeuw/connutil" - "github.com/google/uuid" "github.com/pion/dtls/v3" "github.com/pion/dtls/v3/pkg/crypto/selfsign" "github.com/pion/logging" @@ -49,54 +40,24 @@ type getCredsFunc func(ctx context.Context, link string, streamID int) (string, // Global state trackers var ( - activeLocalPeer atomic.Value - globalCaptchaLockout atomic.Int64 - connectedStreams atomic.Int32 - globalAppCancel context.CancelFunc - handshakeSem = make(chan struct{}, 3) - isDebug bool - manualCaptcha bool + activeLocalPeer atomic.Value + connectedStreams atomic.Int32 + globalAppCancel context.CancelFunc + handshakeSem = make(chan struct{}, 3) + isDebug bool ) var appDialer net.Dialer +// vkAuth is the lazily-initialized VK auth client. Set once in main(). +var vkAuth *vkauth.Client + func debugf(format string, v ...any) { if isDebug { log.Printf(format, v...) } } -type captchaSolveMode int - -const ( - captchaSolveModeAuto captchaSolveMode = iota - captchaSolveModeManual -) - -func captchaSolveModeForAttempt(attempt int, manualOnly bool) (captchaSolveMode, bool) { - if manualOnly { - return captchaSolveModeManual, attempt == 0 - } - switch attempt { - case 0: - return captchaSolveModeAuto, true - case 1: - return captchaSolveModeManual, true - } - return 0, false -} - -func captchaSolveModeLabel(mode captchaSolveMode) string { - switch mode { - case captchaSolveModeAuto: - return "auto captcha" - case captchaSolveModeManual: - return "manual captcha" - default: - return "captcha" - } -} - type UDPPacket struct { Data []byte N int @@ -106,573 +67,20 @@ var packetPool = sync.Pool{ New: func() any { return &UDPPacket{Data: make([]byte, 2048)} }, } -// region Helper: HTTP Headers Injection - -func getCustomNetDialer() net.Dialer { - return appDialer +// manualCaptchaSolver bridges the vkauth.ManualSolveFunc contract to the +// local captcha bouncer (see manual_captcha.go). +func manualCaptchaSolver(_ context.Context, e *captcha.Error, d net.Dialer) (string, string, error) { + if e.RedirectURI != "" { + t, err := solveCaptchaViaProxy(e.RedirectURI, d) + return t, "", err + } + if e.CaptchaImg != "" { + k, err := solveCaptchaViaHTTP(e.CaptchaImg) + return "", k, err + } + return "", "", fmt.Errorf("no redirect_uri or captcha_img") } -// endregion - -// region Automatic Captcha Solver & Authentication - -func solveVkCaptcha(ctx context.Context, captchaErr *captcha.Error, streamID int, client tlsclient.HttpClient, profile browserprofile.Profile) (string, error) { - log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID) - - if captchaErr.SessionToken == "" { - return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") - } - if captchaErr.RedirectURI == "" { - return "", fmt.Errorf("no redirect_uri for auto-solve") - } - - var savedProfile *browserprofile.Saved - if sp, err := browserprofile.Load(); err == nil { - log.Printf("[STREAM %d] [Captcha] Using saved real browser profile", streamID) - savedProfile = sp - profile = sp.Profile - } - - successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, savedProfile) - if err != nil { - return "", err - } - log.Printf("[STREAM %d] [Captcha] solver succeeded", streamID) - return successToken, nil -} - -// endregion - -// region VK Credentials Layer - -type VKCredentials struct { - ClientID string - ClientSecret string -} - -var vkCredentialsList = []VKCredentials{ - {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID - {ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID - {ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID - {ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID - {ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP -} - -type TurnCredentials struct { - Username string - Password string - ServerAddrs []string - ExpiresAt time.Time - Link string -} - -type StreamCredentialsCache struct { - creds TurnCredentials - mutex sync.RWMutex - errorCount atomic.Int32 - lastErrorTime atomic.Int64 -} - -const ( - credentialLifetime = 10 * time.Minute - cacheSafetyMargin = 60 * time.Second - maxCacheErrors = 3 - errorWindow = 10 * time.Second -) - -var streamsPerCache = 10 - -func getCacheID(streamID int) int { - return streamID / streamsPerCache -} - -func vkDelayRandom(minMs, maxMs int) { - ms := minMs + rand.Intn(maxMs-minMs+1) - time.Sleep(time.Duration(ms) * time.Millisecond) -} - -var credentialsStore = struct { - mu sync.RWMutex - caches map[int]*StreamCredentialsCache -}{ - caches: make(map[int]*StreamCredentialsCache), -} - -func getStreamCache(streamID int) *StreamCredentialsCache { - cacheID := getCacheID(streamID) - - credentialsStore.mu.RLock() - cache, exists := credentialsStore.caches[cacheID] - credentialsStore.mu.RUnlock() - - if exists { - return cache - } - - credentialsStore.mu.Lock() - defer credentialsStore.mu.Unlock() - - if cache, exists = credentialsStore.caches[cacheID]; exists { - return cache - } - - cache = &StreamCredentialsCache{} - credentialsStore.caches[cacheID] = cache - return cache -} - -func isAuthError(err error) bool { - if err == nil { - return false - } - errStr := err.Error() - return strings.Contains(errStr, "401") || - strings.Contains(errStr, "Unauthorized") || - strings.Contains(errStr, "authentication") || - strings.Contains(errStr, "invalid credential") || - strings.Contains(errStr, "stale nonce") -} - -func handleAuthError(streamID int) bool { - cache := getStreamCache(streamID) - cacheID := getCacheID(streamID) - - now := time.Now().Unix() - - if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) { - cache.errorCount.Store(0) - } - - count := cache.errorCount.Add(1) - cache.lastErrorTime.Store(now) - - log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors) - - if count >= maxCacheErrors { - log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID) - cache.invalidate(streamID) - return true - } - return false -} - -func (c *StreamCredentialsCache) invalidate(streamID int) { - c.mutex.Lock() - c.creds = TurnCredentials{} - c.mutex.Unlock() - - c.errorCount.Store(0) - c.lastErrorTime.Store(0) - - log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID) -} - -func getVkCredsCached(ctx context.Context, link string, streamID int, dialer net.Dialer) (string, string, string, error) { - cache := getStreamCache(streamID) - cacheID := getCacheID(streamID) - - cache.mutex.RLock() - if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { - expires := time.Until(cache.creds.ExpiresAt) - u, p := cache.creds.Username, cache.creds.Password - // Round-robin selection based on streamID - addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)] - cache.mutex.RUnlock() - if isDebug { - log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)", streamID, cacheID, expires, addr) - } - return u, p, addr, nil - } - cache.mutex.RUnlock() - - cache.mutex.Lock() - defer cache.mutex.Unlock() - - // Double-check inside lock - if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { - addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)] - return cache.creds.Username, cache.creds.Password, addr, nil - } - - user, pass, addrs, err := fetchVkCredsSerialized(ctx, link, streamID, dialer) - if err != nil { - return "", "", "", err - } - - cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddrs: addrs, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link} - addr := addrs[streamID%len(addrs)] - return user, pass, addr, nil -} - -var ( - vkRequestMu sync.Mutex - globalLastVkFetchTime time.Time -) - -func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer net.Dialer) (string, string, []string, error) { - vkRequestMu.Lock() - defer vkRequestMu.Unlock() - - // Ensure a minimum cooldown between credential requests to avoid VK rate limits - minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond - elapsed := time.Since(globalLastVkFetchTime) - - if !globalLastVkFetchTime.IsZero() && elapsed < minInterval { - wait := minInterval - elapsed - log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond)) - select { - case <-ctx.Done(): - return "", "", nil, ctx.Err() - case <-time.After(wait): - } - } - - defer func() { - globalLastVkFetchTime = time.Now() - }() - - return fetchVkCreds(ctx, link, streamID, dialer) -} - -func fetchVkCreds(ctx context.Context, link string, streamID int, dialer net.Dialer) (string, string, []string, error) { - // Check Global Lockout to prevent API bans - if time.Now().Unix() < globalCaptchaLockout.Load() { - return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") - } - - var lastErr error - jar := tlsclient.NewCookieJar() - - for _, creds := range vkCredentialsList { - log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID) - - user, pass, addrs, err := getTokenChain(ctx, link, streamID, creds, dialer, jar) - - if err == nil { - log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID) - return user, pass, addrs, nil - } - - lastErr = err - log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err) - - // Hard abort on captcha/fatal conditions instead of trying next creds - if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") { - return "", "", nil, err - } - - if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") { - log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID) - } - } - - return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr) -} - -func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer net.Dialer, jar tlsclient.CookieJar) (string, string, []string, error) { - profile := browserprofile.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"`, - SecChUaMobile: "?0", - SecChUaPlatform: `"Windows"`, - } - - client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), - tlsclient.WithTimeoutSeconds(20), - tlsclient.WithClientProfile(profiles.Chrome_146), - tlsclient.WithCookieJar(jar), - tlsclient.WithDialer(getCustomNetDialer()), - ) - if err != nil { - return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err) - } - - name := namegen.Generate() - escapedName := neturl.QueryEscape(name) - - log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent) - - doRequest := func(data string, url string) (resp map[string]any, err error) { - parsedURL, err := neturl.Parse(url) - if err != nil { - return nil, fmt.Errorf("parse request URL: %w", err) - } - domain := parsedURL.Hostname() - - req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) - if err != nil { - return nil, err - } - - req.Host = domain - browserprofile.ApplyFhttp(req, profile) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "*/*") - req.Header.Set("Origin", "https://vk.ru") - req.Header.Set("Referer", "https://vk.ru/") - req.Header.Set("Sec-Fetch-Site", "same-site") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Priority", "u=1, i") - - httpResp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { - if closeErr := httpResp.Body.Close(); closeErr != nil { - log.Printf("close response body: %s", closeErr) - } - }() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &resp) - if err != nil { - return nil, err - } - return resp, nil - } - - // Token 1 - data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID) - resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token") - if err != nil { - return "", "", nil, err - } - dataMap, ok := resp["data"].(map[string]any) - if !ok { - return "", "", nil, fmt.Errorf("unexpected anon token response: %v", resp) - } - token1, ok := dataMap["access_token"].(string) - if !ok { - return "", "", nil, fmt.Errorf("missing access_token in response: %v", resp) - } - - vkDelayRandom(100, 150) - - // Token 1 -> getCallPreview - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) - _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) - if err != nil { - log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err) - } - - vkDelayRandom(200, 400) - - // Token 2 - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1) - urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID) - - var token2 string - for attempt := 0; ; attempt++ { - resp, err = doRequest(data, urlAddr) - if err != nil { - return "", "", nil, err - } - - if errObj, hasErr := resp["error"].(map[string]any); hasErr { - captchaErr := captcha.ParseError(errObj) - if captchaErr != nil && captchaErr.IsCaptcha() { - solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha) - if !hasSolveMode { - log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1) - - // Engage global lockout to protect API - globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) - - if connectedStreams.Load() == 0 { - log.Printf("[STREAM %d] [FATAL] 0 connected streams and captcha solve modes exhausted.", streamID) - return "", "", nil, fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS") - } - - return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED") - } - - var successToken string - var captchaKey string - var solveErr error - - switch solveMode { - case captchaSolveModeAuto: - if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { - successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile) - if solveErr != nil { - log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr) - } - } else { - solveErr = fmt.Errorf("missing fields for auto solve") - } - case captchaSolveModeManual: - log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID) - // Use context.Background() so that a short deadline on the parent ctx - // (e.g. the overall auth timeout) doesn't cut the user's solve time short. - manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute) - - type manualRes struct { - token string - key string - err error - } - resCh := make(chan manualRes, 1) - - go func() { - var t, k string - var e error - if captchaErr.RedirectURI != "" { - t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer) - } else if captchaErr.CaptchaImg != "" { - k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg) - } else { - e = fmt.Errorf("no redirect_uri or captcha_img") - } - resCh <- manualRes{t, k, e} - }() - - select { - case res := <-resCh: - successToken = res.token - captchaKey = res.key - solveErr = res.err - // Token may be present even when err != nil (e.g. srv.Shutdown - // timed out on iSH after the token was already received). - // Treat a non-empty token as success regardless of the error. - if successToken != "" || captchaKey != "" { - if solveErr != nil { - log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr) - solveErr = nil - } - log.Printf("[STREAM %d] [Captcha] Successfully got token from browser", streamID) - } else if solveErr != nil { - log.Printf("[STREAM %d] [Captcha] solveCaptchaViaProxy returned error: %v", streamID, solveErr) - } - case <-manualCtx.Done(): - if manualCtx.Err() == context.DeadlineExceeded { - solveErr = fmt.Errorf("manual captcha timed out after 3m") - } else { - solveErr = fmt.Errorf("manual captcha interrupted: %w", manualCtx.Err()) - } - } - manualCancel() - } - - // If solving failed (auto or manual) or timed out - if solveErr != nil { - log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr) - - nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha) - if hasNextSolveMode { - log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode)) - continue - } - - // Engage global lockout to protect API - globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) - - // If we have 0 streams alive, this is fatal - if connectedStreams.Load() == 0 { - log.Printf("[STREAM %d] [FATAL] 0 connected streams and manual captcha failed/timed out.", streamID) - return "", "", nil, fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS") - } - - return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED") - } - - if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" { - captchaErr.CaptchaAttempt = "1" - } - - if captchaKey != "" { - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s", - link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1) - } else { - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s", - link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1) - } - continue - } - return "", "", nil, fmt.Errorf("VK API error: %v", errObj) - } - - respMap, okLoop := resp["response"].(map[string]any) - if !okLoop { - return "", "", nil, fmt.Errorf("unexpected getAnonymousToken response: %v", resp) - } - token2, okLoop = respMap["token"].(string) - if !okLoop { - return "", "", nil, fmt.Errorf("missing token in response: %v", resp) - } - break - } - - vkDelayRandom(100, 150) - - // Token 3 - sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) - data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData)) - resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") - if err != nil { - return "", "", nil, err - } - token3, ok := resp["session_key"].(string) - if !ok { - return "", "", nil, fmt.Errorf("missing session_key in response: %v", resp) - } - - vkDelayRandom(100, 150) - - // Token 4 -> TURN Creds - data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3) - resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") - if err != nil { - return "", "", nil, err - } - - tsRaw, ok := resp["turn_server"].(map[string]any) - if !ok { - return "", "", nil, fmt.Errorf("missing turn_server in response: %v", resp) - } - user, ok := tsRaw["username"].(string) - if !ok { - return "", "", nil, fmt.Errorf("missing username in turn_server") - } - pass, ok := tsRaw["credential"].(string) - if !ok { - return "", "", nil, fmt.Errorf("missing credential in turn_server") - } - urlsRaw, ok := tsRaw["urls"].([]any) - if !ok || len(urlsRaw) == 0 { - return "", "", nil, fmt.Errorf("missing or empty urls in turn_server") - } - - log.Printf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw)) - for i, u := range urlsRaw { - log.Printf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u) - } - - var addresses []string - for _, u := range urlsRaw { - urlStr, ok := u.(string) - if !ok { - continue - } - clean := strings.Split(urlStr, "?")[0] - address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") - addresses = append(addresses, address) - } - - if len(addresses) == 0 { - return "", "", nil, fmt.Errorf("no valid TURN addresses found") - } - - return user, pass, addresses, nil -} - -// endregion - func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) { certificate, err := selfsign.GenerateSelfSigned() if err != nil { @@ -895,15 +303,15 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD relayConn, err1 := client.Allocate() if err1 != nil { - if isAuthError(err1) { - handleAuthError(streamID) + if vkauth.IsAuthError(err1) { + vkAuth.HandleAuthError(streamID) } err = fmt.Errorf("failed to allocate: %s", err1) return } // Reset error count on successful allocation - getStreamCache(streamID).errorCount.Store(0) + vkAuth.ResetErrors(streamID) // Safely track active streams globally connectedStreams.Add(1) @@ -1032,7 +440,7 @@ func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConn ne default: err := oneDtlsConnection(ctx, peer, listenConn, inboundChan, connchan, okchan, streamID) if err != nil { - if time.Now().Unix() < globalCaptchaLockout.Load() && strings.Contains(err.Error(), "context deadline exceeded") { + if time.Now().Unix() < vkAuth.LockoutUntilUnix() && strings.Contains(err.Error(), "context deadline exceeded") { continue } select { @@ -1076,7 +484,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne case <-time.After(60 * time.Second): } } else { - lockoutEnd := globalCaptchaLockout.Load() + lockoutEnd := vkAuth.LockoutUntilUnix() sleepDuration := time.Until(time.Unix(lockoutEnd, 0)) if sleepDuration < 0 { sleepDuration = 5 * time.Second @@ -1126,7 +534,7 @@ func main() { wrapMode := flag.Bool("wrap", false, "WRAP mode: ChaCha20-XOR obfuscate DTLS packets before they reach TURN ChannelData") wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)") genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit") - streamsPerCredFlag := flag.Int("streams-per-cred", streamsPerCache, "number of TURN streams sharing one VK credential cache") + streamsPerCredFlag := flag.Int("streams-per-cred", vkauth.DefaultStreamsPerCache, "number of TURN streams sharing one VK credential cache") 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", dnsdial.DNSModeAuto, "DNS resolution mode: udp | doh | auto (auto tries UDP/53 first, sticky-fallback to DoH on total failure)") @@ -1176,17 +584,22 @@ func main() { if *streamsPerCredFlag <= 0 { log.Panicf("-streams-per-cred must be positive") } - streamsPerCache = *streamsPerCredFlag isDebug = *debugFlag - manualCaptcha = *manualCaptchaFlag + + vkAuth = vkauth.New(vkauth.Config{ + Dialer: appDialer, + ManualOnly: *manualCaptchaFlag, + StreamsPerCache: *streamsPerCredFlag, + StreamsAlive: func() int32 { return connectedStreams.Load() }, + ManualSolver: manualCaptchaSolver, + Debugf: debugf, + }) parts := strings.Split(*vklink, "join/") link := parts[len(parts)-1] - getCreds := getCredsFunc(func(ctx context.Context, s string, streamID int) (string, string, string, error) { - return getVkCredsCached(ctx, s, streamID, appDialer) - }) + getCreds := getCredsFunc(vkAuth.GetCredentials) if *n <= 0 { *n = 10 }