refactor: вынести VK-авторизацию в client/internal/vkauth

Этап 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.
This commit is contained in:
samosvalishe
2026-05-19 09:48:55 +03:00
parent 322e8db4fa
commit 5bf6dd0399
9 changed files with 1035 additions and 633 deletions
+46
View File
@@ -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
}
+82
View File
@@ -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")
}
+238
View File
@@ -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)
}
+211
View File
@@ -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)
}
}
+64
View File
@@ -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)
@@ -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")
}
})
+304
View File
@@ -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
}
+44
View File
@@ -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"
)
+37 -624
View File
@@ -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
}