From d8c30b4ae9ed70bdab77a1ba124d2f0d2a22a2ed Mon Sep 17 00:00:00 2001 From: kiper292 Date: Fri, 17 Apr 2026 01:30:24 +0500 Subject: [PATCH] sync captcha solver with https://github.com/cacggghp/vk-turn-proxy --- client/main.go | 907 +++++++++++++++++++- client/manual_captcha.go | 66 +- client/namegen.go | 187 ++++ client/profiles.go | 82 ++ client/{vk_captcha.go => slider_captcha.go} | 493 ++--------- client/vk.go | 258 ------ client/wb.go | 65 +- go.mod | 19 + go.sum | 54 ++ 9 files changed, 1414 insertions(+), 717 deletions(-) create mode 100644 client/namegen.go create mode 100644 client/profiles.go rename client/{vk_captcha.go => slider_captcha.go} (60%) delete mode 100644 client/vk.go diff --git a/client/main.go b/client/main.go index 001e127..d971129 100644 --- a/client/main.go +++ b/client/main.go @@ -4,20 +4,35 @@ package main import ( + "bytes" "context" + "crypto/md5" + "crypto/sha256" "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" "flag" "fmt" + "io" "log" + "math/rand" "net" + neturl "net/url" "os" "os/signal" + "strconv" "strings" "sync" "sync/atomic" "syscall" "time" + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + + "github.com/bschaatsbergen/dnsdialer" "github.com/cbeuw/connutil" "github.com/google/uuid" "github.com/pion/dtls/v3" @@ -26,6 +41,851 @@ import ( "github.com/pion/turn/v5" ) +// Global state trackers +var ( + globalCaptchaLockout atomic.Int64 + isDebug bool + manualCaptcha bool + autoCaptchaSliderPOC bool + globalAppCancel context.CancelFunc +) + +type captchaSolveMode int + +const ( + captchaSolveModeAuto captchaSolveMode = iota + captchaSolveModeSliderPOC + captchaSolveModeManual +) + +func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) { + if manualOnly { + return captchaSolveModeManual, attempt == 0 + } + + switch attempt { + case 0: + return captchaSolveModeAuto, true + case 1: + if enableSliderPOC { + return captchaSolveModeSliderPOC, true + } + return captchaSolveModeManual, true + case 2: + if enableSliderPOC { + return captchaSolveModeManual, true + } + } + + return 0, false +} + +func captchaSolveModeLabel(mode captchaSolveMode) string { + switch mode { + case captchaSolveModeAuto: + return "auto captcha" + case captchaSolveModeSliderPOC: + return "auto captcha slider POC" + case captchaSolveModeManual: + return "manual captcha" + default: + return "captcha" + } +} + +// region Helper: HTTP Headers Injection + +func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) { + req.Header.Set("User-Agent", profile.UserAgent) + req.Header.Set("sec-ch-ua", profile.SecChUa) + req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile) + req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform) + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("DNT", "1") +} + +func generateBrowserFp(profile Profile) string { + data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10) + h := md5.Sum([]byte(data)) + return hex.EncodeToString(h[:]) +} + +func getCustomNetDialer() net.Dialer { + return net.Dialer{ + Timeout: 20 * time.Second, + KeepAlive: 30 * time.Second, + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} + var lastErr error + for _, dns := range dnsServers { + conn, err := d.DialContext(ctx, "udp", dns) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, + }, + } +} + +func generateFakeCursor() string { + startX := 600 + rand.Intn(400) + startY := 300 + rand.Intn(200) + startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000) + var points []string + for i := 0; i < 15+rand.Intn(10); i++ { + startX += rand.Intn(15) - 5 + startY += rand.Intn(15) + 2 + startTime += int64(rand.Intn(40) + 10) + points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime)) + } + return "[" + strings.Join(points, ",") + "]" +} + +// endregion + +// region Automatic Captcha Solver & Authentication + +type VkCaptchaError struct { + ErrorCode int + ErrorMsg string + CaptchaSid string + CaptchaImg string + RedirectURI string + IsSoundCaptchaAvailable bool + SessionToken string + CaptchaTs string + CaptchaAttempt string +} + +func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { + // Extract error_code + codeFloat, ok := errData["error_code"].(float64) + if !ok { + log.Printf("missing error_code in captcha error data") + return nil + } + code := int(codeFloat) + + // Extract redirect_uri + RedirectURI, ok := errData["redirect_uri"].(string) + if !ok { + log.Printf("missing redirect_uri in captcha error data") + return nil + } + + // Extract captcha_sid + captchaSid, ok := errData["captcha_sid"].(string) + if !ok { + // try numeric + if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { + captchaSid = fmt.Sprintf("%.0f", sidNum) + } else { + log.Printf("missing captcha_sid in captcha error data") + return nil + } + } + + // Extract captcha_img + captchaImg, ok := errData["captcha_img"].(string) + if !ok { + log.Printf("missing captcha_img in captcha error data") + return nil + } + + // Extract error_msg + errorMsg, ok := errData["error_msg"].(string) + if !ok { + log.Printf("missing error_msg in captcha error data") + return nil + } + + // Extract session token if redirect_uri present + var sessionToken string + if RedirectURI != "" { + if parsed, err := neturl.Parse(RedirectURI); err == nil { + sessionToken = parsed.Query().Get("session_token") + } else { + log.Printf("failed to parse redirect_uri: %v", err) + return nil + } + } + + // Extract is_sound_captcha_available + isSound, ok := errData["is_sound_captcha_available"].(bool) + if !ok { + isSound = false + } + + // Extract captcha_ts + var captchaTs string + if tsFloat, ok := errData["captcha_ts"].(float64); ok { + captchaTs = fmt.Sprintf("%.0f", tsFloat) + } else if tsStr, ok := errData["captcha_ts"].(string); ok { + captchaTs = tsStr + } + + // Extract captcha_attempt + var captchaAttempt string + if attFloat, ok := errData["captcha_attempt"].(float64); ok { + captchaAttempt = fmt.Sprintf("%.0f", attFloat) + } else if attStr, ok := errData["captcha_attempt"].(string); ok { + captchaAttempt = attStr + } + + // Build VkCaptchaError + return &VkCaptchaError{ + ErrorCode: code, + ErrorMsg: errorMsg, + CaptchaSid: captchaSid, + CaptchaImg: captchaImg, + RedirectURI: RedirectURI, + IsSoundCaptchaAvailable: isSound, + SessionToken: sessionToken, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + } +} + +func (e *VkCaptchaError) IsCaptchaError() bool { + return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != "" +} + +func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) { + if useSliderPOC { + log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID) + } else { + 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") + } + + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile) + if err != nil { + return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) + } + + log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty) + + hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) + log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) + + var successToken string + if useSliderPOC { + successToken, err = callCaptchaNotRobotWithSliderPOC( + ctx, + captchaErr.SessionToken, + hash, + streamID, + client, + profile, + bootstrap.Settings, + ) + } else { + successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) + } + if err != nil { + return "", fmt.Errorf("captchaNotRobot API failed: %w", err) + } + + log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID) + return successToken, nil +} + +func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) { + parsedURL, err := neturl.Parse(redirectURI) + if err != nil { + return nil, err + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return parseCaptchaBootstrapHTML(string(body)) +} + +func solvePoW(powInput string, difficulty int) string { + target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10000000; nonce++ { + data := powInput + strconv.Itoa(nonce) + hash := sha256.Sum256([]byte(data)) + hexHash := hex.EncodeToString(hash[:]) + if strings.HasPrefix(hexHash, target) { + return hexHash + } + } + return "" +} + +func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) { + vkReq := func(method string, postData string) (map[string]interface{}, error) { + reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" + parsedURL, err := neturl.Parse(reqURL) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData)) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://id.vk.ru") + req.Header.Set("Referer", "https://id.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("Sec-GPC", "1") + req.Header.Set("Priority", "u=1, i") + + httpResp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(httpResp.Body) + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp, nil + } + + baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) + + log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) + if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil { + return "", fmt.Errorf("settings failed: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) + browserFp := generateBrowserFp(profile) + deviceJSON := buildCaptchaDeviceJSON(profile) + componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON)) + + if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil { + return "", fmt.Errorf("componentDone failed: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) + cursorJSON := generateFakeCursor() + answer := base64.StdEncoding.EncodeToString([]byte("{}")) + + // Dynamically generate debug_info to avoid static fingerprint bans + debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10))) + debugInfo := hex.EncodeToString(debugInfoBytes[:]) + + connectionRtt := "[50,50,50,50,50,50,50,50,50,50]" + connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]" + + checkData := baseParams + fmt.Sprintf( + "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", + neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), + neturl.QueryEscape(cursorJSON), neturl.QueryEscape("[]"), neturl.QueryEscape(connectionRtt), + neturl.QueryEscape(connectionDownlink), + browserFp, hash, answer, debugInfo, + ) + + checkResp, err := vkReq("captchaNotRobot.check", checkData) + if err != nil { + return "", fmt.Errorf("check failed: %w", err) + } + + respObj, ok := checkResp["response"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid check response: %v", checkResp) + } + status, ok := respObj["status"].(string) + if !ok || status != "OK" { + return "", fmt.Errorf("check status: %s", status) + } + successToken, ok := respObj["success_token"].(string) + if !ok || successToken == "" { + return "", fmt.Errorf("success_token not found") + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", streamID) + _, err = vkReq("captchaNotRobot.endSession", baseParams) + if err != nil { + log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", streamID, err) + } + + return successToken, nil +} + +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 +} + +// 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 +} + +func vkDelayRandom(minMs, maxMs int) { + ms := minMs + rand.Intn(maxMs-minMs+1) + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.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) { + expires := time.Until(cache.creds.ExpiresAt) + u, p, a := cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr + cache.mutex.RUnlock() + if isDebug { + log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v)", streamID, cacheID, expires) + } + return u, p, a, 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) { + return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil + } + + user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer) + if err != nil { + return "", "", "", err + } + + cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddr: addr, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link} + return user, pass, addr, nil +} + +var ( + vkRequestMu sync.Mutex + globalLastVkFetchTime time.Time +) + +func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.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 "", "", "", 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 *dnsdialer.Dialer) (string, string, string, error) { + // Check Global Lockout to prevent API bans + if time.Now().Unix() < globalCaptchaLockout.Load() { + return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") + } + + 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, addr, 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, addr, 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 "", "", "", 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 "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr) +} + +func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, error) { + profile := Profile{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`, + 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 "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err) + } + + name := generateName() + 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]interface{}, 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 + applyBrowserProfileFhttp(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 "", "", "", err + } + dataMap, ok := resp["data"].(map[string]interface{}) + if !ok { + return "", "", "", fmt.Errorf("unexpected anon token response: %v", resp) + } + token1, ok := dataMap["access_token"].(string) + if !ok { + return "", "", "", 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 "", "", "", err + } + + if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr { + captchaErr := ParseVkCaptchaError(errObj) + if captchaErr != nil && captchaErr.IsCaptchaError() { + solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC) + 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()) + + return "", "", "", 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, false) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr) + } + } else { + solveErr = fmt.Errorf("missing fields for auto solve") + } + case captchaSolveModeSliderPOC: + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { + successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr) + } + } else { + solveErr = fmt.Errorf("missing fields for slider POC auto solve") + } + case captchaSolveModeManual: + log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID) + manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second) + + 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 + case <-manualCtx.Done(): + solveErr = fmt.Errorf("manual captcha timed out after 60s") + } + 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, autoCaptchaSliderPOC) + 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()) + + return "", "", "", 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 "", "", "", fmt.Errorf("VK API error: %v", errObj) + } + + respMap, okLoop := resp["response"].(map[string]interface{}) + if !okLoop { + return "", "", "", fmt.Errorf("unexpected getAnonymousToken response: %v", resp) + } + token2, okLoop = respMap["token"].(string) + if !okLoop { + return "", "", "", 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 "", "", "", err + } + token3, ok := resp["session_key"].(string) + if !ok { + return "", "", "", 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 "", "", "", err + } + + tsRaw, ok := resp["turn_server"].(map[string]interface{}) + if !ok { + return "", "", "", fmt.Errorf("missing turn_server in response: %v", resp) + } + user, ok := tsRaw["username"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing username in turn_server") + } + pass, ok := tsRaw["credential"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing credential in turn_server") + } + urlsRaw, ok := tsRaw["urls"].([]interface{}) + if !ok || len(urlsRaw) == 0 { + return "", "", "", fmt.Errorf("missing or empty urls in turn_server") + } + urlStr, ok := urlsRaw[0].(string) + if !ok { + return "", "", "", fmt.Errorf("turn server url is not a string") + } + + clean := strings.Split(urlStr, "?")[0] + address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + + return user, pass, address, nil +} + +// endregion + func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) { certificate, err := selfsign.GenerateSelfSigned() if err != nil { @@ -402,7 +1262,37 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne c := make(chan error) go oneTurnConnection(ctx, &tp, peer, conn2, c) if err := <-c; err != nil { - log.Printf("%s", err) + if strings.Contains(err.Error(), "FATAL_CAPTCHA") { + log.Printf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID) + if globalAppCancel != nil { + globalAppCancel() + } + return + } + if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") { + if !strings.Contains(err.Error(), "global lockout active") { + log.Printf("[STREAM %d] Backing off for 60 seconds to avoid IP ban...", streamID) + select { + case <-ctx.Done(): + return + case <-time.After(60 * time.Second): + } + } else { + lockoutEnd := globalCaptchaLockout.Load() + sleepDuration := time.Until(time.Unix(lockoutEnd, 0)) + if sleepDuration < 0 { + sleepDuration = 5 * time.Second + } + select { + case <-ctx.Done(): + return + case <-time.After(sleepDuration): + } + } + } else { + log.Printf("[STREAM %d] %s", streamID, err) + time.Sleep(2 * time.Second) + } } default: } @@ -412,6 +1302,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne func main() { //nolint:cyclop ctx, cancel := context.WithCancel(context.Background()) + globalAppCancel = cancel defer cancel() signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT) @@ -437,6 +1328,8 @@ func main() { //nolint:cyclop direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") v1 := flag.Bool("v1", false, "use v1 server protocol (no session_id and stream_id)") sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)") + debugFlag := flag.Bool("debug", false, "enable debug logging") + manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") flag.Parse() if *peerAddr == "" { log.Panicf("Need peer address!") @@ -449,9 +1342,19 @@ func main() { //nolint:cyclop log.Panicf("Need either -wb or -vk-link!") } + isDebug = *debugFlag + manualCaptcha = *manualCaptchaFlag + autoCaptchaSliderPOC = !manualCaptcha + var link string var getCreds getCredsFunc + dialer := dnsdialer.New( + dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"), + dnsdialer.WithStrategy(dnsdialer.Fallback{}), + dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour), + ) + if *wb { link = "wb" getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { @@ -461,7 +1364,7 @@ func main() { //nolint:cyclop parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { - return getCredsCached(ctx, lk, streamID, getVkCreds) + return getVkCredsCached(ctx, lk, streamID, dialer) } } diff --git a/client/manual_captcha.go b/client/manual_captcha.go index bb11d1b..826478c 100644 --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -17,6 +17,8 @@ import ( "runtime" "strings" "time" + + "github.com/bschaatsbergen/dnsdialer" ) const captchaListenPort = "8765" @@ -123,6 +125,7 @@ func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) { req.Host = targetURL.Host req.Header.Del("Accept-Encoding") + req.Header.Del("TE") // Disable transfer encoding compression for _, headerName := range []string{"Origin", "Referer"} { if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" { req.Header.Set(headerName, rewritten) @@ -332,6 +335,21 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { } } +func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: false, + } + if dialer != nil { + transport.DialContext = dialer.DialContext + } + return transport +} + func startCaptchaServer(srv *http.Server, logPrefix string) error { var listenErrs []string var listening bool @@ -357,6 +375,7 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error { return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; ")) } +// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token. func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) { srv := &http.Server{Handler: handler} @@ -383,6 +402,7 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch return key, nil } +// notifyKey pushes the key string to the given channel without blocking func notifyKey(keyCh chan<- string, key string) { if key != "" { select { @@ -423,7 +443,7 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer} return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") } -func solveCaptchaViaProxy(redirectURI string) (string, error) { +func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { keyCh := make(chan string, 1) targetURL, err := neturl.Parse(redirectURI) @@ -431,14 +451,7 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { return "", fmt.Errorf("invalid redirect URI: %v", err) } - transport := &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - ForceAttemptHTTP2: true, - } + transport := newCaptchaProxyTransport(dialer) proxy := &httputil.ReverseProxy{ Transport: transport, @@ -446,16 +459,17 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { rewriteProxyRequest(req.Out, targetURL) }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - log.Printf("captcha proxy error for %s: %v", r.URL.String(), err) + log.Printf("[Captcha Proxy] ERROR for %s %s: %v", r.Method, r.URL.String(), err) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadGateway) - _, _ = fmt.Fprintf(w, `

Captcha proxy error

%v

`, err) + _, _ = fmt.Fprintf(w, `

Captcha proxy error

%s %s

%v

`, r.Method, r.URL.String(), err) }, ModifyResponse: func(res *http.Response) error { rewriteProxyCookies(res.Header) if res.StatusCode >= 300 && res.StatusCode < 400 { if loc := res.Header.Get("Location"); loc != "" { + log.Printf("[Captcha Proxy] Redirecting to: %s", loc) if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok { res.Header.Set("Location", rewritten) } else { @@ -465,7 +479,13 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { } contentType := res.Header.Get("Content-Type") - shouldInspectBody := strings.Contains(contentType, "text/html") || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + contentEncoding := res.Header.Get("Content-Encoding") + log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding) + + shouldInspectBody := strings.Contains(contentType, "text/html") || + strings.Contains(contentType, "application/xhtml+xml") || + strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + if !shouldInspectBody { return nil } @@ -476,7 +496,9 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { if err == nil { reader = gzReader defer func() { - _ = gzReader.Close() + if err := gzReader.Close(); err != nil { + log.Printf("failed to close gzip reader: %v", err) + } }() } } @@ -503,6 +525,8 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { "Cross-Origin-Embedder-Policy", "Cross-Origin-Resource-Policy", "X-Frame-Options", + "Strict-Transport-Security", + "Alt-Svc", } { res.Header.Del(headerName) } @@ -521,7 +545,7 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { mux := http.NewServeMux() mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) { - notifyKey(keyCh, r.FormValue("token")) + notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = fmt.Fprint(w, "ok") }) @@ -545,7 +569,9 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.String()) if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" { + log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL)) http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) return } @@ -574,6 +600,18 @@ func browserOpenCommands(goos string, url string) []browserCommand { {name: "xdg-open", args: []string{url}}, {name: "gio", args: []string{"open", url}}, } + case "android": + return []browserCommand{ + {name: "termux-open-url", args: []string{url}}, + {name: "/system/bin/am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "xdg-open", args: []string{url}}, + } + case "ios": + return []browserCommand{ + {name: "open", args: []string{url}}, + {name: "uiopen", args: []string{url}}, + } } return nil } diff --git a/client/namegen.go b/client/namegen.go new file mode 100644 index 0000000..0593c9d --- /dev/null +++ b/client/namegen.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" +) + +var maleFirstNames = []string{ + "Александр", + "Алексей", + "Андрей", + "Антон", + "Арсений", + "Артур", + "Артём", + "Богдан", + "Валерий", + "Василий", + "Виктор", + "Владислав", + "Глеб", + "Григорий", + "Даниил", + "Денис", + "Дмитрий", + "Евгений", + "Егор", + "Иван", + "Игорь", + "Илья", + "Кирилл", + "Леонид", + "Максим", + "Марк", + "Матвей", + "Михаил", + "Никита", + "Николай", + "Олег", + "Павел", + "Пётр", + "Роман", + "Руслан", + "Сергей", + "Станислав", + "Тимофей", + "Фёдор", +} + +var femaleFirstNames = []string{ + "Алина", + "Алёна", + "Анастасия", + "Ангелина", + "Анна", + "Вера", + "Вероника", + "Виктория", + "Дарья", + "Ева", + "Екатерина", + "Елена", + "Елизавета", + "Ирина", + "Кира", + "Кристина", + "Ксения", + "Любовь", + "Маргарита", + "Марина", + "Мария", + "Милана", + "Надежда", + "Наталья", + "Ольга", + "Полина", + "Светлана", + "София", + "Татьяна", + "Юлия", + "Яна", +} + +var lastNames = []string{ + "Алексеев", + "Андреев", + "Антонов", + "Баранов", + "Белов", + "Белый", + "Бельский", + "Беляев", + "Борисов", + "Васильев", + "Великий", + "Волков", + "Воробьёв", + "Григорьев", + "Давыдов", + "Егоров", + "Жуков", + "Зайцев", + "Захаров", + "Иванов", + "Калинин", + "Ковалёв", + "Козлов", + "Комаров", + "Крамской", + "Кузнецов", + "Кузьмин", + "Лебедев", + "Макаров", + "Медведев", + "Михайлов", + "Морозов", + "Никитин", + "Николаев", + "Новиков", + "Орлов", + "Островский", + "Павлов", + "Петров", + "Покровский", + "Попов", + "Раевский", + "Романов", + "Семёнов", + "Сергеев", + "Смирнов", + "Соколов", + "Соловьёв", + "Степанов", + "Тарасов", + "Титов", + "Толстой", + "Трубецкой", + "Филиппов", + "Фролов", + "Фёдоров", + "Чайковский", + "Черный", + "Яковлев", +} + +// convertToFemaleSurname handles Russian suffix rules +func convertToFemaleSurname(surname string) string { + // Handle adjective-style surnames: + if strings.HasSuffix(surname, "ий") || strings.HasSuffix(surname, "ый") || strings.HasSuffix(surname, "ой") { + return surname[:len(surname)-4] + "ая" + } + + // Handle standard possessive surnames: + if strings.HasSuffix(surname, "ов") || strings.HasSuffix(surname, "ев") || + strings.HasSuffix(surname, "ин") || strings.HasSuffix(surname, "ын") || + strings.HasSuffix(surname, "ёв") { + return surname + "а" + } + + // Foreign or unchangeable + return surname +} + +func generateName() string { + // Decide gender first + isFemale := rand.Intn(2) == 0 + + var fn string + if isFemale { + fn = femaleFirstNames[rand.Intn(len(femaleFirstNames))] + } else { + fn = maleFirstNames[rand.Intn(len(maleFirstNames))] + } + + // 70% chance to have a last name + if rand.Float32() < 0.3 { + return fn + } + + ln := lastNames[rand.Intn(len(lastNames))] + if isFemale { + ln = convertToFemaleSurname(ln) + } + + return fmt.Sprintf("%s %s", fn, ln) +} diff --git a/client/profiles.go b/client/profiles.go new file mode 100644 index 0000000..01d4f0c --- /dev/null +++ b/client/profiles.go @@ -0,0 +1,82 @@ +package main + +import ( + "math/rand" +) + +type Profile struct { + UserAgent string + SecChUa string + SecChUaMobile string + SecChUaPlatform string +} + +// profiles contain paired User-Agent and Client Hints strings to harden bot detection. +var profile = []Profile{ + // Windows Chrome + { + 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: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // Windows Edge + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // macOS Chrome + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + + // Linux Chrome + { + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, + { + UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, +} + +// getRandomProfile returns a paired User-Agent and Client Hints profile. +func getRandomProfile() Profile { + return profile[rand.Intn(len(profile))] +} diff --git a/client/vk_captcha.go b/client/slider_captcha.go similarity index 60% rename from client/vk_captcha.go rename to client/slider_captcha.go index 7358cf1..b4644c4 100644 --- a/client/vk_captcha.go +++ b/client/slider_captcha.go @@ -1,25 +1,9 @@ -/* SPDX-License-Identifier: Apache-2.0 - * - * Copyright © 2026 WireGuard LLC. All Rights Reserved. - */ - package main -/* -#include -// These are JNI functions from Android, they won't work in standalone exe -// But we keep the signature for consistency with libwg-go if asked -// extern const char* requestCaptcha(const char* redirect_uri); -*/ -// import "C" // Disabled cgo for standalone compatibility unless strictly needed - import ( "bytes" "context" - "crypto/sha256" - "crypto/tls" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "image" @@ -27,286 +11,44 @@ import ( _ "image/jpeg" "io" "log" - "math/rand" - "net" - "net/http" - "net/url" + neturl "net/url" "regexp" "sort" "strconv" "strings" - "sync" - "syscall" "time" -) - -// --- Compatibility Shims for Standalone vk-turn-proxy --- - -func turnLog(format string, v ...interface{}) { - log.Printf(format, v...) -} - -// Simple host cache for standalone -var hostCache = &simpleHostCache{} - -type simpleHostCache struct{} - -func (s *simpleHostCache) Resolve(ctx context.Context, host string) (string, error) { - ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return "", err - } - if len(ips) == 0 { - return "", fmt.Errorf("no IP found for %s", host) - } - return ips[0].IP.String(), nil -} - -// No-op protectControl for standalone -func protectControl(network, address string, c syscall.RawConn) error { - return nil -} - -// --- Original libwg-go code (adapted) --- - -// VkCaptchaError represents a VK captcha error -type VkCaptchaError struct { - ErrorCode int - ErrorMsg string - CaptchaSid string - CaptchaImg string - RedirectUri string - IsSoundCaptchaAvailable bool - SessionToken string - CaptchaTs string // captcha_ts from error - CaptchaAttempt string // captcha_attempt from error -} - -// ParseVkCaptchaError parses a VK error response into VkCaptchaError -func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { - codeFloat, _ := errData["error_code"].(float64) - code := int(codeFloat) - - redirectUri, _ := errData["redirect_uri"].(string) - captchaSid, _ := errData["captcha_sid"].(string) - captchaImg, _ := errData["captcha_img"].(string) - errorMsg, _ := errData["error_msg"].(string) - - // Extract session_token from redirect_uri - var sessionToken string - if redirectUri != "" { - if parsed, err := url.Parse(redirectUri); err == nil { - sessionToken = parsed.Query().Get("session_token") - } - } - - isSound, _ := errData["is_sound_captcha_available"].(bool) - - // captcha_ts can be float64 (scientific notation) or string - var captchaTs string - if tsFloat, ok := errData["captcha_ts"].(float64); ok { - captchaTs = fmt.Sprintf("%.0f", tsFloat) - } else if tsStr, ok := errData["captcha_ts"].(string); ok { - captchaTs = tsStr - } - - // captcha_attempt is usually a float64 - var captchaAttempt string - if attFloat, ok := errData["captcha_attempt"].(float64); ok { - captchaAttempt = fmt.Sprintf("%.0f", attFloat) - } else if attStr, ok := errData["captcha_attempt"].(string); ok { - captchaAttempt = attStr - } - - return &VkCaptchaError{ - ErrorCode: code, - ErrorMsg: errorMsg, - CaptchaSid: captchaSid, - CaptchaImg: captchaImg, - RedirectUri: redirectUri, - IsSoundCaptchaAvailable: isSound, - SessionToken: sessionToken, - CaptchaTs: captchaTs, - CaptchaAttempt: captchaAttempt, - } -} - -// IsCaptchaError checks if the error data is a Not Robot Captcha error -func (e *VkCaptchaError) IsCaptchaError() bool { - return e.ErrorCode == 14 && e.RedirectUri != "" && e.SessionToken != "" -} - -// captchaMutex serializes captcha solving to avoid multiple concurrent attempts -var captchaMutex sync.Mutex - -// SolveVkCaptcha solves the VK Not Robot Captcha and returns success_token -// First tries automatic solution, falls back to manual solution if it fails -func SolveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { - // Serialize captcha solving to avoid multiple concurrent attempts - captchaMutex.Lock() - defer captchaMutex.Unlock() - - turnLog("[Captcha] Solving Not Robot Captcha...") - - // Step 1: Try automatic solution - turnLog("[Captcha] Attempting automatic solution...") - successToken, err := solveVkCaptchaAutomatic(ctx, captchaErr) - if err == nil && successToken != "" { - turnLog("[Captcha] Automatic solution SUCCESS!") - return successToken, nil - } - - turnLog("[Captcha] Automatic solution FAILED: %v", err) - - // Step 2: Fall back to manual solving - turnLog("[Captcha] Triggering manual captcha fallback...") - if captchaErr.RedirectUri != "" { - return solveCaptchaViaProxy(captchaErr.RedirectUri) - } else if captchaErr.CaptchaImg != "" { - return solveCaptchaViaHTTP(captchaErr.CaptchaImg) - } - - return "", fmt.Errorf("no more solve modes available") -} - -// solveVkCaptchaAutomatic performs the automatic captcha solving without UI -func solveVkCaptchaAutomatic(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { - sessionToken := captchaErr.SessionToken - if sessionToken == "" { - return "", fmt.Errorf("no session_token in redirect_uri") - } - - // Step 1: Fetch the captcha HTML page to get powInput - bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri) - if err != nil { - return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) - } - - turnLog("[Captcha] PoW input: %s, difficulty: %d", bootstrap.PowInput, bootstrap.Difficulty) - - // Step 2: Solve PoW - hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) - turnLog("[Captcha] PoW solved: hash=%s", hash) - - // Step 3: Call captchaNotRobot API with slider POC support - successToken, err := callCaptchaNotRobotWithSliderPOC(ctx, sessionToken, hash, bootstrap.Settings) - if err != nil { - return "", fmt.Errorf("captchaNotRobot API failed: %w", err) - } - - turnLog("[Captcha] Success! Got success_token") - return successToken, nil -} - -// fetchCaptchaBootstrap fetches the captcha HTML page and extracts PoW input, difficulty, and settings -func fetchCaptchaBootstrap(ctx context.Context, redirectUri string) (*captchaBootstrap, error) { - parsedURL, err := url.Parse(redirectUri) - if err != nil { - return nil, fmt.Errorf("failed to parse redirect_uri: %w", err) - } - - domain := parsedURL.Hostname() - resolvedIP, err := hostCache.Resolve(ctx, domain) - if err != nil { - return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) - } - - port := parsedURL.Port() - if port == "" { - port = "443" - } - ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path - if parsedURL.RawQuery != "" { - ipURL += "?" + parsedURL.RawQuery - } - - req, err := http.NewRequestWithContext(ctx, "GET", ipURL, nil) - if err != nil { - return nil, err - } - req.Host = domain - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - // Control: protectControl, // Disabled for standalone - }).DialContext, - TLSClientConfig: &tls.Config{ - ServerName: domain, - }, - }, - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - html := string(body) - bootstrap, err := parseCaptchaBootstrapHTML(html) - if err != nil { - return nil, err - } - return bootstrap, nil -} - -// solvePoW finds nonce where SHA-256(powInput + nonce) starts with '0' * difficulty -func solvePoW(powInput string, difficulty int) string { - target := strings.Repeat("0", difficulty) - - for nonce := 1; nonce <= 10000000; nonce++ { - data := powInput + strconv.Itoa(nonce) - hash := sha256.Sum256([]byte(data)) - hexHash := hex.EncodeToString(hash[:]) - - if strings.HasPrefix(hexHash, target) { - return hexHash - } - } - - // Fallback: should not happen with difficulty <= 3 - return "" -} + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" +) const ( + captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c" sliderCaptchaType = "slider" defaultSliderAttempts = 4 ) -// captchaBootstrap holds parsed captcha bootstrap data -type captchaBootstrap struct { - PowInput string - Difficulty int - Settings *captchaSettingsResponse +type captchaNotRobotSession struct { + ctx context.Context + sessionToken string + hash string + streamID int + client tlsclient.HttpClient + profile Profile + browserFp string } -// captchaSettingsResponse holds captcha settings from VK API type captchaSettingsResponse struct { ShowCaptchaType string SettingsByType map[string]string } -// captchaCheckResult holds the result of a captcha check request type captchaCheckResult struct { Status string SuccessToken string ShowCaptchaType string } -// sliderCaptchaContent holds decoded slider captcha content type sliderCaptchaContent struct { Image image.Image Size int @@ -314,41 +56,39 @@ type sliderCaptchaContent struct { Attempts int } -// sliderCandidate represents a ranked slider candidate type sliderCandidate struct { Index int ActiveSteps []int Score int64 } -// captchaNotRobotSession represents a captcha solving session -type captchaNotRobotSession struct { - ctx context.Context - sessionToken string - hash string - browserFp string +type captchaBootstrap struct { + PowInput string + Difficulty int + Settings *captchaSettingsResponse } -// newCaptchaNotRobotSession creates a new captcha solving session func newCaptchaNotRobotSession( ctx context.Context, sessionToken string, hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, ) *captchaNotRobotSession { - // Generate random browser fingerprint - browserFp := fmt.Sprintf("%032x", randInt63()) - return &captchaNotRobotSession{ ctx: ctx, sessionToken: sessionToken, hash: hash, - browserFp: browserFp, + streamID: streamID, + client: client, + profile: profile, + browserFp: generateBrowserFp(profile), } } -// baseValues returns base URL values for API requests -func (s *captchaNotRobotSession) baseValues() url.Values { - values := url.Values{} +func (s *captchaNotRobotSession) baseValues() neturl.Values { + values := neturl.Values{} values.Set("session_token", s.sessionToken) values.Set("domain", "vk.com") values.Set("adFp", "") @@ -356,85 +96,34 @@ func (s *captchaNotRobotSession) baseValues() url.Values { return values } -// request makes a VK API request -func (s *captchaNotRobotSession) request(method string, values url.Values) (map[string]interface{}, error) { +func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) { reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" - parsedURL, err := url.Parse(reqURL) - if err != nil { - return nil, err - } - - domain := parsedURL.Hostname() - resolvedIP, err := hostCache.Resolve(s.ctx, domain) - if err != nil { - return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) - } - - port := parsedURL.Port() - if port == "" { - port = "443" - } - ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path - if parsedURL.RawQuery != "" { - ipURL += "?" + parsedURL.RawQuery - } - - req, err := http.NewRequestWithContext(s.ctx, "POST", ipURL, strings.NewReader(values.Encode())) + req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode())) if err != nil { return nil, err } - req.Host = domain - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Origin", "https://vk.ru") - req.Header.Set("Referer", "https://vk.ru/") - req.Header.Set("sec-ch-ua-platform", "\"Linux\"") - req.Header.Set("sec-ch-ua", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"") - req.Header.Set("sec-ch-ua-mobile", "?0") - req.Header.Set("DNT", "1") - 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("Sec-GPC", "1") - - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - // Control: protectControl, - }).DialContext, - TLSClientConfig: &tls.Config{ - ServerName: domain, - }, - }, - } - - resp, err := client.Do(req) + httpResp, err := s.client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + _ = httpResp.Body.Close() + }() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(httpResp.Body) if err != nil { return nil, err } - var respMap map[string]interface{} - if err := json.Unmarshal(body, &respMap); err != nil { + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { return nil, err } - - return respMap, nil + return resp, nil } -// requestSettings fetches captcha settings from VK API func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) { resp, err := s.request("captchaNotRobot.settings", s.baseValues()) if err != nil { @@ -443,11 +132,10 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er return parseCaptchaSettingsResponse(resp) } -// requestComponentDone marks the component as done func (s *captchaNotRobotSession) requestComponentDone() error { values := s.baseValues() values.Set("browser_fp", s.browserFp) - values.Set("device", buildCaptchaDeviceJSON()) + values.Set("device", buildCaptchaDeviceJSON(s.profile)) resp, err := s.request("captchaNotRobot.componentDone", values) if err != nil { @@ -464,12 +152,10 @@ func (s *captchaNotRobotSession) requestComponentDone() error { return nil } -// requestCheckboxCheck performs a checkbox-style captcha check func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) { - return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}"))) + return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}"))) } -// requestSliderContent fetches slider captcha content func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) { values := s.baseValues() if sliderSettings != "" { @@ -483,7 +169,6 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s return parseSliderCaptchaContentResponse(resp) } -// requestSliderCheck performs a slider captcha check func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { answer, err := encodeSliderAnswer(activeSteps) if err != nil { @@ -493,7 +178,6 @@ func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidate return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer) } -// requestCheck performs the main captcha check request func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) { values := s.baseValues() values.Set("accelerometer", "[]") @@ -506,7 +190,7 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca values.Set("browser_fp", s.browserFp) values.Set("hash", s.hash) values.Set("answer", answer) - values.Set("debug_info", "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785") + values.Set("debug_info", captchaDebugInfo) resp, err := s.request("captchaNotRobot.check", values) if err != nil { @@ -515,24 +199,25 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca return parseCaptchaCheckResult(resp) } -// requestEndSession ends the captcha session func (s *captchaNotRobotSession) requestEndSession() { - turnLog("[Captcha] Step 4/4: endSession") + log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID) if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil { - turnLog("[Captcha] Warning: endSession failed: %v", err) + log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err) } } -// callCaptchaNotRobotWithSliderPOC solves captcha with slider POC support func callCaptchaNotRobotWithSliderPOC( ctx context.Context, sessionToken string, hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, initialSettings *captchaSettingsResponse, ) (string, error) { - session := newCaptchaNotRobotSession(ctx, sessionToken, hash) + session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) - turnLog("[Captcha] Step 1/4: settings") + log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) settingsResp, err := session.requestSettings() if err != nil { return "", err @@ -541,14 +226,14 @@ func callCaptchaNotRobotWithSliderPOC( time.Sleep(200 * time.Millisecond) - turnLog("[Captcha] Step 2/4: componentDone") + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) if err := session.requestComponentDone(); err != nil { return "", err } time.Sleep(200 * time.Millisecond) - turnLog("[Captcha] Step 3/4: check") + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) initialCheck, err := session.requestCheckboxCheck() if err != nil { return "", err @@ -557,14 +242,14 @@ func callCaptchaNotRobotWithSliderPOC( if initialCheck.SuccessToken == "" { return "", fmt.Errorf("success_token not found") } - time.Sleep(200 * time.Millisecond) session.requestEndSession() return initialCheck.SuccessToken, nil } sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType] - turnLog( - "[Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + log.Printf( + "[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + streamID, initialCheck.Status, settingsResp.ShowCaptchaType, initialCheck.ShowCaptchaType, @@ -572,15 +257,32 @@ func callCaptchaNotRobotWithSliderPOC( ) if !hasSlider { - turnLog( - "[Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + log.Printf( + "[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + streamID, ) } else { - turnLog("[Captcha] Trying experimental slider solver...") + log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID) } sliderContent, err := session.requestSliderContent(sliderSettings) if err != nil { + log.Printf( + "[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...", + streamID, + err, + ) + // Fallback: maybe it's just a checkbox that needs a human-like check + time.Sleep(300 * time.Millisecond) + finalCheck, err2 := session.requestCheckboxCheck() + if err2 == nil && finalCheck.Status == "OK" { + if finalCheck.SuccessToken == "" { + return "", fmt.Errorf("success_token not found in fallback check") + } + log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID) + session.requestEndSession() + return finalCheck.SuccessToken, nil + } return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err) } @@ -589,16 +291,18 @@ func callCaptchaNotRobotWithSliderPOC( return "", err } - turnLog( - "[Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + log.Printf( + "[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + streamID, len(candidates), minInt(sliderContent.Attempts, len(candidates)), sliderContent.Attempts, ) successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) { - turnLog( - "[Captcha] Slider guess position=%d score=%d", + log.Printf( + "[STREAM %d] [Captcha] Slider guess position=%d score=%d", + streamID, candidate.Index, candidate.Score, ) @@ -608,19 +312,17 @@ func callCaptchaNotRobotWithSliderPOC( return "", err } - time.Sleep(200 * time.Millisecond) session.requestEndSession() return successToken, nil } -// buildCaptchaDeviceJSON builds device information JSON -func buildCaptchaDeviceJSON() string { +func buildCaptchaDeviceJSON(profile Profile) string { return fmt.Sprintf( - `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default"}`, + `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`, + profile.UserAgent, ) } -// parseCaptchaSettingsResponse parses captcha settings from API response func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -659,7 +361,6 @@ func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettings return settings, nil } -// parseCaptchaBootstrapHTML parses HTML page to extract PoW input and settings func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) powInputMatch := powInputRe.FindStringSubmatch(html) @@ -692,7 +393,6 @@ func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { }, nil } -// parseCaptchaSettingsFromHTML parses captcha settings from HTML window.init func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) { initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`) initMatch := initRe.FindStringSubmatch(html) @@ -718,7 +418,6 @@ func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) }) } -// mergeCaptchaSettings merges two captcha settings responses func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse { if primary == nil { return cloneCaptchaSettings(fallback) @@ -740,7 +439,6 @@ func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSet return primary } -// cloneCaptchaSettings clones a captcha settings response func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse { if src == nil { return nil @@ -756,7 +454,6 @@ func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse return cloned } -// expandCaptchaSettings expands raw captcha settings into a slice func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { switch value := raw.(type) { case nil: @@ -792,7 +489,6 @@ func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { return nil, false } -// normalizeCaptchaSettings normalizes captcha settings to string func normalizeCaptchaSettings(raw interface{}) (string, error) { switch value := raw.(type) { case nil: @@ -808,7 +504,6 @@ func normalizeCaptchaSettings(raw interface{}) (string, error) { } } -// parseCaptchaCheckResult parses captcha check result from API response func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -826,7 +521,6 @@ func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, return result, nil } -// parseSliderCaptchaContentResponse parses slider captcha content from API response func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -877,7 +571,6 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt }, nil } -// parseIntSlice parses a slice of integers from interface{} func parseIntSlice(raw []interface{}) ([]int, error) { values := make([]int, 0, len(raw)) for _, item := range raw { @@ -890,7 +583,6 @@ func parseIntSlice(raw []interface{}) ([]int, error) { return values, nil } -// parseIntValue parses a single integer from interface{} func parseIntValue(raw interface{}) (int, error) { switch value := raw.(type) { case float64: @@ -908,7 +600,6 @@ func parseIntValue(raw interface{}) (int, error) { } } -// parseSliderSteps parses slider steps into size, swaps, and attempts func parseSliderSteps(steps []int) (int, []int, int, error) { if len(steps) < 3 { return 0, nil, 0, fmt.Errorf("slider steps payload too short") @@ -935,7 +626,6 @@ func parseSliderSteps(steps []int) (int, []int, int, error) { return size, remaining, attempts, nil } -// decodeSliderImage decodes base64-encoded slider image func decodeSliderImage(rawImage string) (image.Image, error) { decoded, err := base64.StdEncoding.DecodeString(rawImage) if err != nil { @@ -950,7 +640,6 @@ func decodeSliderImage(rawImage string) (image.Image, error) { return img, nil } -// encodeSliderAnswer encodes slider answer to base64 JSON func encodeSliderAnswer(activeSteps []int) (string, error) { payload := struct { Value []int `json:"value"` @@ -966,7 +655,6 @@ func encodeSliderAnswer(activeSteps []int) (string, error) { return base64.StdEncoding.EncodeToString(data), nil } -// buildSliderActiveSteps builds active steps for a candidate func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { if candidateIndex <= 0 { return []int{} @@ -980,14 +668,13 @@ func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { return append([]int(nil), swaps[:end]...) } -// buildSliderTileMapping builds tile mapping for a candidate func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { tileCount := gridSize * gridSize if tileCount <= 0 { return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) } if len(activeSteps)%2 != 0 { - return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps) / 2) + return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps)) } mapping := make([]int, tileCount) @@ -1007,7 +694,6 @@ func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { return mapping, nil } -// rankSliderCandidates ranks slider candidates by score func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) { candidateCount := len(swaps) / 2 if candidateCount == 0 { @@ -1044,7 +730,6 @@ func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderC return candidates, nil } -// scoreSliderCandidate scores a slider candidate func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) { rendered, err := renderSliderCandidate(img, gridSize, mapping) if err != nil { @@ -1054,7 +739,6 @@ func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, return scoreRenderedSliderImage(rendered, gridSize), nil } -// renderSliderCandidate renders a slider candidate image func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) { if gridSize <= 0 { return nil, fmt.Errorf("invalid grid size: %d", gridSize) @@ -1076,7 +760,6 @@ func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image return rendered, nil } -// scoreRenderedSliderImage scores a rendered slider image func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { bounds := img.Bounds() var score int64 @@ -1112,7 +795,6 @@ func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { return score } -// sliderTileRect returns the rectangle for a tile func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { row := index / gridSize col := index % gridSize @@ -1125,7 +807,6 @@ func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Recta return image.Rect(x0, y0, x1, y1) } -// copyScaledTile copies a scaled tile func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) { if dstRect.Empty() || srcRect.Empty() { return @@ -1145,7 +826,6 @@ func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, s } } -// pixelDiff calculates pixel difference func pixelDiff(left color.Color, right color.Color) int64 { lr, lg, lb, _ := left.RGBA() rr, rg, rb, _ := right.RGBA() @@ -1153,7 +833,6 @@ func pixelDiff(left color.Color, right color.Color) int64 { return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb) } -// absDiff calculates absolute difference func absDiff(left uint32, right uint32) int64 { if left > right { return int64(left - right) @@ -1161,12 +840,10 @@ func absDiff(left uint32, right uint32) int64 { return int64(right - left) } -// generateSliderCursor generates a fake slider cursor func generateSliderCursor(candidateIndex int, candidateCount int) string { return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) } -// buildSliderCursor builds a fake slider cursor func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string { if candidateCount <= 0 { return "[]" @@ -1200,7 +877,6 @@ func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) return string(data) } -// trySliderCaptchaCandidates tries slider captcha candidates func trySliderCaptchaCandidates( candidates []sliderCandidate, maxAttempts int, @@ -1237,7 +913,6 @@ func trySliderCaptchaCandidates( return "", fmt.Errorf("slider guesses exhausted") } -// minInt returns the minimum of two integers func minInt(left int, right int) int { if left < right { return left @@ -1245,7 +920,6 @@ func minInt(left int, right int) int { return right } -// describeCaptchaTypes describes available captcha types func describeCaptchaTypes(settingsByType map[string]string) string { if len(settingsByType) == 0 { return "none" @@ -1258,8 +932,3 @@ func describeCaptchaTypes(settingsByType map[string]string) string { sort.Strings(types) return strings.Join(types, ",") } - -// randInt63 generates a random int63 -func randInt63() int64 { - return rand.Int63() -} diff --git a/client/vk.go b/client/vk.go deleted file mode 100644 index 77d9549..0000000 --- a/client/vk.go +++ /dev/null @@ -1,258 +0,0 @@ -// SPDX-FileCopyrightText: 2023 The Pion community -// SPDX-License-Identifier: MIT - -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "math/rand" - "net/http" - "net/url" - "strings" - "time" - - "github.com/google/uuid" -) - -const vkClientID = "6287487" -const vkClientSecret = "QbYic1K3lEV5kTGiqlq2" -const vkAPIVersion = "5.275" - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// vkDelay sleeps for a random duration between minMs and maxMs to avoid bot detection -func vkDelay(minMs, maxMs int) { - ms := minMs + rand.Intn(maxMs-minMs+1) - time.Sleep(time.Duration(ms) * time.Millisecond) -} - -func vkHTTPPost(ctx context.Context, data string, url string) (map[string]interface{}, error) { - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - }, - } - defer client.CloseIdleConnections() - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) - if err != nil { - return nil, err - } - // Headers matching HAR capture exactly - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Origin", "https://vk.ru") - req.Header.Set("Referer", "https://vk.ru/") - req.Header.Set("sec-ch-ua-platform", `"Windows"`) - req.Header.Set("sec-ch-ua", `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`) - req.Header.Set("sec-ch-ua-mobile", "?0") - 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("DNT", "1") - req.Header.Set("Priority", "u=1, i") - - httpResp, err := client.Do(req) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - // Handle HTTP errors (redirects, rate limits, etc.) - if httpResp.StatusCode >= 400 { - body, _ := io.ReadAll(httpResp.Body) - return nil, fmt.Errorf("HTTP %d from %s: %s", httpResp.StatusCode, req.URL, string(body[:min(len(body), 500)])) - } - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - // Check content type - VK may return HTML instead of JSON (captcha page, redirect, etc.) - contentType := httpResp.Header.Get("Content-Type") - if contentType != "" && !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "text/javascript") { - // Log first 500 chars of non-JSON response for debugging - logPreview := string(body) - if len(logPreview) > 500 { - logPreview = logPreview[:500] + "...(truncated)" - } - return nil, fmt.Errorf("unexpected content-type %s, status %d, body: %s", contentType, httpResp.StatusCode, logPreview) - } - - var resp map[string]interface{} - if err = json.Unmarshal(body, &resp); err != nil { - // Log the raw body for debugging - logPreview := string(body) - if len(logPreview) > 500 { - logPreview = logPreview[:500] + "...(truncated)" - } - return nil, fmt.Errorf("JSON parse error: %w, body: %s", err, logPreview) - } - return resp, nil -} - -func getVkCreds(ctx context.Context, link string) (string, string, string, error) { - // Token 1 (messages) - log.Println("[VK Auth] Getting Token 1...") - data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", - vkClientID, vkClientSecret, vkClientID) - resp, err := vkHTTPPost(ctx, data, "https://login.vk.ru/?act=get_anonym_token") - if err != nil { - return "", "", "", fmt.Errorf("Token 1 request error: %w", err) - } - if errMsg, ok := resp["error"].(map[string]interface{}); ok { - return "", "", "", fmt.Errorf("Token 1 VK error: %v", errMsg) - } - dataObj, ok := resp["data"].(map[string]interface{}) - if !ok { - return "", "", "", fmt.Errorf("invalid Token 1 response: %v", resp) - } - token1, ok := dataObj["access_token"].(string) - if !ok { - return "", "", "", fmt.Errorf("access_token not found in Token 1 response") - } - log.Println("[VK Auth] Token 1 received") - vkDelay(100, 200) // Token 1 → getCallPreview - - // getCallPreview (optional, like browser) - log.Println("[VK Auth] Getting call preview...") - cpData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s", - url.QueryEscape(link), token1) - cpURL := fmt.Sprintf("https://api.vk.ru/method/calls.getCallPreview?v=%s&client_id=%s", vkAPIVersion, vkClientID) - _, _ = vkHTTPPost(ctx, cpData, cpURL) // non-critical - vkDelay(500, 1000) // getCallPreview → Token 2 - - // Token 2 (may require captcha) - log.Println("[VK Auth] Getting Token 2...") - t2Data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=123&access_token=%s", - url.QueryEscape(link), token1) - t2URL := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=%s&client_id=%s", vkAPIVersion, vkClientID) - resp, err = vkHTTPPost(ctx, t2Data, t2URL) - if err != nil { - return "", "", "", fmt.Errorf("Token 2 request error: %w", err) - } - - // Check for captcha error - if errMsg, ok := resp["error"].(map[string]interface{}); ok { - captchaErr := ParseVkCaptchaError(errMsg) - if captchaErr == nil || !captchaErr.IsCaptchaError() { - return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg) - } - - log.Printf("[VK Auth] Captcha detected, solving...") - successToken, solveErr := SolveVkCaptcha(ctx, captchaErr) - if solveErr != nil { - return "", "", "", fmt.Errorf("captcha solving failed: %w", solveErr) - } - - // Delay before retry (endSession → Token 2 retry) - vkDelay(100, 200) - - // Retry Token 2 with captcha solution - log.Println("[VK Auth] Retrying Token 2 with captcha solution...") - t2Data = fmt.Sprintf( - "vk_join_link=https://vk.ru/call/join/%s&name=123"+ - "&captcha_key=&captcha_sid=%s&is_sound_captcha=0"+ - "&success_token=%s&captcha_ts=%s&captcha_attempt=%s"+ - "&access_token=%s", - url.QueryEscape(link), - captchaErr.CaptchaSid, - successToken, - captchaErr.CaptchaTs, - captchaErr.CaptchaAttempt, - token1, - ) - resp, err = vkHTTPPost(ctx, t2Data, t2URL) - if err != nil { - return "", "", "", fmt.Errorf("Token 2 retry request error: %w", err) - } - if errMsg2, ok := resp["error"].(map[string]interface{}); ok { - return "", "", "", fmt.Errorf("Token 2 retry VK error: %v", errMsg2) - } - // Token 2 retry → Token 3 - vkDelay(100, 200) - } - - token2Obj, ok := resp["response"].(map[string]interface{}) - if !ok { - return "", "", "", fmt.Errorf("invalid Token 2 response: %v", resp) - } - token2, ok := token2Obj["token"].(string) - if !ok { - return "", "", "", fmt.Errorf("token not found in Token 2 response") - } - log.Println("[VK Auth] Token 2 received") - // Token 2 → Token 3 - vkDelay(100, 200) - - // Token 3 (OK auth.anonymLogin) - log.Println("[VK Auth] Getting Token 3...") - sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) - t3Data := fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", - url.QueryEscape(sessionData)) - resp, err = vkHTTPPost(ctx, t3Data, "https://calls.okcdn.ru/fb.do") - if err != nil { - return "", "", "", fmt.Errorf("Token 3 request error: %w", err) - } - if errMsg, ok := resp["error"].(string); ok && errMsg != "" { - return "", "", "", fmt.Errorf("Token 3 API error: %s", errMsg) - } - token3, ok := resp["session_key"].(string) - if !ok { - return "", "", "", fmt.Errorf("session_key not found in Token 3 response") - } - log.Println("[VK Auth] Token 3 received") - // Token 3 → Final (TURN) - vkDelay(100, 200) - - // Final: vchat.joinConversationByLink (Token 4) - log.Println("[VK Auth] Getting TURN credentials (Token 4)...") - finalData := fmt.Sprintf( - "joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", - url.QueryEscape(link), token2, token3) - resp, err = vkHTTPPost(ctx, finalData, "https://calls.okcdn.ru/fb.do") - if err != nil { - return "", "", "", fmt.Errorf("Final request error: %w", err) - } - if errMsg, ok := resp["error"].(string); ok && errMsg != "" { - return "", "", "", fmt.Errorf("Final API error: %s", errMsg) - } - - ts, ok := resp["turn_server"].(map[string]interface{}) - if !ok { - return "", "", "", fmt.Errorf("turn_server not found in response: %v", resp) - } - urls, _ := ts["urls"].([]interface{}) - if len(urls) == 0 { - return "", "", "", fmt.Errorf("urls not found in turn_server") - } - urlStr, _ := urls[0].(string) - clean := strings.Split(urlStr, "?")[0] - address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") - - username, _ := ts["username"].(string) - credential, _ := ts["credential"].(string) - - if username == "" || credential == "" { - return "", "", "", fmt.Errorf("username or credential not found in turn_server") - } - - log.Println("[VK Auth] TURN credentials received") - vkDelay(1500, 2500) // Final delay before exit - return username, credential, address, nil -} diff --git a/client/wb.go b/client/wb.go index 6beffd5..8b5cd08 100644 --- a/client/wb.go +++ b/client/wb.go @@ -17,12 +17,14 @@ import ( "strings" "time" + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" "github.com/gorilla/websocket" ) const ( - wbBase = "https://stream.wb.ru" - wbUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" + wbBase = "https://stream.wb.ru" ) // WbTurnCred stores a single TURN credential @@ -48,32 +50,19 @@ func wbFetch(ctx context.Context, link string) (string, string, string, error) { return "", "", "", fmt.Errorf("no TURN credentials received from WB") } -// wbHTTPClient creates a WB HTTP client -func wbHTTPClient() *http.Client { - return &http.Client{ - Timeout: 15 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - TLSClientConfig: &tls.Config{}, - }, - } -} - -// wbReq makes an HTTP request to WB API -func wbReq(ctx context.Context, client *http.Client, method, ep string, body []byte, tok string) ([]byte, error) { +// wbReq makes an HTTP request to WB API using tls-client +func wbReq(ctx context.Context, client tlsclient.HttpClient, profile Profile, method, ep string, body []byte, tok string) ([]byte, error) { var rd io.Reader if body != nil { rd = bytes.NewReader(body) } - rq, err := http.NewRequestWithContext(ctx, method, wbBase+ep, rd) + rq, err := fhttp.NewRequestWithContext(ctx, method, wbBase+ep, rd) if err != nil { return nil, err } - rq.Header.Set("User-Agent", wbUA) + applyBrowserProfileFhttp(rq, profile) rq.Header.Set("Accept", "application/json") rq.Header.Set("Accept-Language", "en-US,en;q=0.9") rq.Header.Set("Origin", wbBase) @@ -113,13 +102,26 @@ func wbReq(ctx context.Context, client *http.Client, method, ep string, body []b // fetchWbCreds performs the full WB credential acquisition flow func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { - client := wbHTTPClient() - defer client.CloseIdleConnections() + profile := Profile{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + } + + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), + tlsclient.WithTimeoutSeconds(20), + tlsclient.WithClientProfile(profiles.Chrome_146), + tlsclient.WithDialer(getCustomNetDialer()), + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize tls_client: %w", err) + } nm := fmt.Sprintf("lh_%d", time.Now().UnixMilli()%100000) log.Println("[WB Auth] Step 1: Guest registration...") - rr, err := wbReq(ctx, client, "POST", "/auth/api/v1/auth/user/guest-register", + rr, err := wbReq(ctx, client, profile, "POST", "/auth/api/v1/auth/user/guest-register", []byte(`{"displayName":"`+nm+`"}`), "") if err != nil { return nil, fmt.Errorf("guest register: %w", err) @@ -137,7 +139,7 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { log.Println("[WB Auth] Guest registered") log.Println("[WB Auth] Step 2: Create room...") - rr, err = wbReq(ctx, client, "POST", "/api-room/api/v2/room", + rr, err = wbReq(ctx, client, profile, "POST", "/api-room/api/v2/room", []byte(`{"roomType":"ROOM_TYPE_ALL_ON_SCREEN","roomPrivacy":"ROOM_PRIVACY_FREE"}`), reg.AccessToken) if err != nil { @@ -160,14 +162,14 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { log.Printf("[WB Auth] Room created: %s", roomPreview) log.Println("[WB Auth] Step 3: Join room...") - _, err = wbReq(ctx, client, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", room.RoomID), + _, err = wbReq(ctx, client, profile, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", room.RoomID), []byte("{}"), reg.AccessToken) if err != nil { return nil, fmt.Errorf("join room: %w", err) } log.Println("[WB Auth] Step 4: Get room token...") - rr, err = wbReq(ctx, client, "GET", fmt.Sprintf( + rr, err = wbReq(ctx, client, profile, "GET", fmt.Sprintf( "/api-room-manager/api/v1/room/%s/token?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=%s", room.RoomID, url.QueryEscape(nm)), nil, reg.AccessToken) if err != nil { @@ -185,7 +187,7 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { } log.Println("[WB Auth] Step 5: Negotiating ICE (LiveKit)...") - creds, err := wbLkICE(ctx, tok.RoomToken) + creds, err := wbLkICE(ctx, tok.RoomToken, profile.UserAgent) if err != nil { return nil, fmt.Errorf("livekit ICE: %w", err) } @@ -198,17 +200,18 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { } // wbLkICE connects to LiveKit WebSocket and extracts TURN credentials -func wbLkICE(ctx context.Context, token string) ([]WbTurnCred, error) { +func wbLkICE(ctx context.Context, token string, userAgent string) ([]WbTurnCred, error) { u := "wss://wbstream01-el.wb.ru:7880/rtc?access_token=" + url.QueryEscape(token) + "&auto_subscribe=1&sdk=js&version=2.15.3&protocol=16&adaptive_stream=1" + header := http.Header{} + header.Set("User-Agent", userAgent) + header.Set("Origin", wbBase) + conn, _, err := (&websocket.Dialer{ TLSClientConfig: &tls.Config{}, HandshakeTimeout: 10 * time.Second, - }).DialContext(ctx, u, http.Header{ - "User-Agent": {wbUA}, - "Origin": {wbBase}, - }) + }).DialContext(ctx, u, header) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 57582dd..135e112 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/cacggghp/vk-turn-proxy go 1.25.5 require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.14.0 + github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 github.com/cbeuw/connutil v1.0.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -12,10 +15,26 @@ require ( ) require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.1.1 // indirect github.com/pion/transport/v4 v4.0.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 92594c3..5f68b76 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,37 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A= +github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5TvZm8FVcuHP1288k+DEu1XM26DtRjcidOxpGXs= +github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU= github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA= github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= @@ -20,17 +46,45 @@ github.com/pion/turn/v5 v5.0.2 h1:GHlDk+fiegz+yibb3ch+tK+iPFokoVWiM+aVJakySqA= github.com/pion/turn/v5 v5.0.2/go.mod h1:cumcsSEF2ytAtDhDwkYgYhv1uJ3AOP7a4pFt0NL/snY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=