mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user