diff --git a/client/main.go b/client/main.go index f381cae..011c396 100644 --- a/client/main.go +++ b/client/main.go @@ -12,8 +12,10 @@ import ( "fmt" "io" "log" + "math/rand" "net" "net/http" + "net/url" "os" "os/signal" "strings" @@ -31,123 +33,378 @@ import ( "github.com/pion/turn/v5" ) -type getCredsFunc func(string) (string, string, string, error) +type getCredsFunc func(context.Context, string, int) (string, string, string, error) -func getVkCreds(link string) (string, string, string, error) { - doRequest := func(data string, url string) (resp map[string]interface{}, err 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.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) - if err != nil { - return nil, err - } +const vkClientID = "6287487" +const vkClientSecret = "QbYic1K3lEV5kTGiqlq2" +const vkAPIVersion = "5.275" - req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0") - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") +// TurnCredentials stores cached TURN credentials +type TurnCredentials struct { + Username string + Password string + ServerAddr string + ExpiresAt time.Time + Link string +} - httpResp, err := client.Do(req) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() +// StreamCredentialsCache holds credentials cache for a single stream +type StreamCredentialsCache struct { + creds TurnCredentials + mutex sync.RWMutex + errorCount atomic.Int32 + lastErrorTime atomic.Int64 +} - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } +const ( + credentialLifetime = 10 * time.Minute + cacheSafetyMargin = 60 * time.Second + maxCacheErrors = 3 + errorWindow = 10 * time.Second + streamsPerCache = 4 // Number of streams sharing one credentials cache +) - err = json.Unmarshal(body, &resp) - if err != nil { - return nil, err - } +// getCacheID returns the shared cache ID for a given stream ID +func getCacheID(streamID int) int { + return streamID / streamsPerCache +} + +// credentialsStore manages per-stream credentials caches +var credentialsStore = struct { + mu sync.RWMutex + caches map[int]*StreamCredentialsCache +}{ + caches: make(map[int]*StreamCredentialsCache), +} + +// getStreamCache returns or creates a shared cache for the given stream ID +func getStreamCache(streamID int) *StreamCredentialsCache { + cacheID := getCacheID(streamID) + + // Try read lock first for fast path + credentialsStore.mu.RLock() + cache, exists := credentialsStore.caches[cacheID] + credentialsStore.mu.RUnlock() - return resp, nil + if exists { + return cache } - var resp map[string]interface{} - defer func() { - if r := recover(); r != nil { - log.Panicf("get TURN creds error: %v\n\n", resp) - } - }() -/* - data := "client_secret=QbYic1K3lEV5kTGiqlq2&client_id=6287487&scopes=audio_anonymous%2Cvideo_anonymous%2Cphotos_anonymous%2Cprofile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=6287487" - url := "https://login.vk.ru/?act=get_anonym_token" + // Need to create new cache + credentialsStore.mu.Lock() + defer credentialsStore.mu.Unlock() - resp, err := doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + // Double-check after acquiring write lock + if cache, exists = credentialsStore.caches[cacheID]; exists { + return cache } - token1 := resp["data"].(map[string]interface{})["access_token"].(string) + cache = &StreamCredentialsCache{} + credentialsStore.caches[cacheID] = cache + return cache +} - data = fmt.Sprintf("access_token=%s", token1) - url = "https://api.vk.ru/method/calls.getAnonymousAccessTokenPayload?v=5.264&client_id=6287487" +// invalidate invalidates the credentials cache for this stream +func (c *StreamCredentialsCache) invalidate(streamID int) { + c.mutex.Lock() + c.creds = TurnCredentials{} + c.mutex.Unlock() - resp, err = doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + // Reset auth error counter + c.errorCount.Store(0) + c.lastErrorTime.Store(0) + + log.Printf("[VK Auth] Credentials cache invalidated for stream %d", streamID) +} + +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) +} - token2 := resp["response"].(map[string]interface{})["payload"].(string) -*/ - //data = fmt.Sprintf("client_id=6287487&token_type=messages&payload=%s&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487", token2) - data := fmt.Sprintf("client_id=6287487&token_type=messages&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487") - url := "https://login.vk.ru/?act=get_anonym_token" +// vkCredsMu serializes VK credential fetching to avoid BOT detection from parallel requests +var vkCredsMu sync.Mutex - resp, err := doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) +// getVkCredsCached checks cache before fetching credentials +func getVkCredsCached(ctx context.Context, link string, streamID int) (string, string, string, error) { + cache := getStreamCache(streamID) + cacheID := getCacheID(streamID) + + cache.mutex.Lock() + defer cache.mutex.Unlock() + + // Check cache - another stream may have populated it while waiting + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) { + expires := time.Until(cache.creds.ExpiresAt) + log.Printf("[VK Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires) + return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil } - token3 := resp["data"].(map[string]interface{})["access_token"].(string) + log.Printf("[VK Auth] Cache miss (cache=%d), starting credential fetch...", cacheID) - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=123&access_token=%s", link, token3) - url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.274&client_id=6287487" + // Check context before long fetch + select { + case <-ctx.Done(): + return "", "", "", ctx.Err() + default: + } - resp, err = doRequest(data, url) + // Fetch credentials with mutex to avoid VK flood control + user, pass, addr, err := getVkCredsSafe(ctx, link, streamID) if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - token4 := resp["response"].(map[string]interface{})["token"].(string) + // Store in cache + cache.creds = TurnCredentials{ + Username: user, + Password: pass, + ServerAddr: addr, + ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), + Link: link, + } + + log.Printf("[VK Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID) + return user, pass, addr, nil +} - data = fmt.Sprintf("%s%s%s", "session_data=%7B%22version%22%3A2%2C%22device_id%22%3A%22", uuid.New(), "%22%2C%22client_version%22%3A1.1%2C%22client_type%22%3A%22SDK_JS%22%7D&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA") - url = "https://calls.okcdn.ru/fb.do" +// getVkCredsSafe wraps getVkCreds with mutex to avoid VK flood control +func getVkCredsSafe(ctx context.Context, link string, streamID int) (string, string, string, error) { + vkCredsMu.Lock() + defer vkCredsMu.Unlock() + return getVkCreds(ctx, link) +} - resp, err = doRequest(data, url) +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 "", "", "", fmt.Errorf("request error:%s", err) + return nil, err } + defer httpResp.Body.Close() - token5 := resp["session_key"].(string) + // 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)])) + } - 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, token4, token5) - url = "https://calls.okcdn.ru/fb.do" + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } - resp, err = doRequest(data, url) + // 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("request error:%s", err) + return "", "", "", fmt.Errorf("Token 2 request error: %w", err) } - user := resp["turn_server"].(map[string]interface{})["username"].(string) - pass := resp["turn_server"].(map[string]interface{})["credential"].(string) - turn := resp["turn_server"].(map[string]interface{})["urls"].([]interface{})[0].(string) + // Check for captcha error + if errMsg, ok := resp["error"].(map[string]interface{}); ok { + captchaData, isCaptcha := ExtractCaptchaData(errMsg) + if !isCaptcha { + return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg) + } + + log.Printf("[VK Auth] Captcha detected, solving...") + successToken, solveErr := SolveVkCaptcha(ctx, captchaData) + 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), + captchaData.CaptchaSid, + successToken, + captchaData.CaptchaTs, + captchaData.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) + } - clean := strings.Split(turn, "?")[0] + 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:") - return user, pass, address, nil + 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 } -func getYandexCreds(link string) (string, string, string, error) { +func getYandexCreds(ctx context.Context, link string, streamID int) (string, string, string, error) { const debug = false const telemostConfHost = "cloud-api.yandex.ru" telemostConfPath := fmt.Sprintf("%s%s%s", "/telemost_front/v2/telemost/conferences/https%3A%2F%2Ftelemost.yandex.ru%2Fj%2F", link, "/connection?next_gen_media_platform_allowed=false") @@ -441,7 +698,8 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, ConnectionIDGenerator: dtls.OnlySendCIDGenerator(), } - ctx1, cancel := context.WithTimeout(ctx, 30*time.Second) + // Extended timeout to accommodate serialized credential fetching via mutex + ctx1, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() dtlsConn, err := dtls.Client(conn, peer, config) if err != nil { @@ -586,13 +844,14 @@ type turnParams struct { port string link string udp bool + streamID int getCreds getCredsFunc } func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, c chan<- error) { var err error = nil defer func() { c <- err }() - user, pass, url, err1 := turnParams.getCreds(turnParams.link) + user, pass, url, err1 := turnParams.getCreds(ctx, turnParams.link, turnParams.streamID) if err1 != nil { err = fmt.Errorf("failed to get TURN credentials: %s", err1) return @@ -785,7 +1044,11 @@ func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnCha } } -func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time) { +func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) { + // Create a copy of turnParams with the streamID + tp := *turnParams + tp.streamID = streamID + for { select { case <-ctx.Done(): @@ -794,7 +1057,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne select { case <-t: c := make(chan error) - go oneTurnConnection(ctx, turnParams, peer, conn2, c) + go oneTurnConnection(ctx, &tp, peer, conn2, c) if err := <-c; err != nil { log.Printf("%s", err) } @@ -846,9 +1109,9 @@ func main() { //nolint:cyclop if *vklink != "" { parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] - getCreds = getVkCreds + getCreds = getVkCredsCached if *n <= 0 { - *n = 16 + *n = 4 } } else { parts := strings.Split(*yalink, "j/") @@ -862,11 +1125,12 @@ func main() { //nolint:cyclop link = link[:idx] } params := &turnParams{ - *host, - *port, - link, - *udp, - getCreds, + host: *host, + port: *port, + link: link, + udp: *udp, + streamID: 0, + getCreds: getCreds, } var sessionID []byte @@ -905,9 +1169,10 @@ func main() { //nolint:cyclop if *direct { for i := 0; i < *n; i++ { wg1.Add(1) + streamID := i go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t) + oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t, streamID) }() } } else { @@ -923,7 +1188,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) + oneTurnConnectionLoop(ctx, params, peer, connchan, t, 0) }() select { @@ -932,15 +1197,16 @@ func main() { //nolint:cyclop } for i := 0; i < *n-1; i++ { connchan := make(chan net.PacketConn) + streamID := i + 1 wg1.Add(1) - go func(streamID byte) { + go func(sID byte) { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, streamID) - }(byte(i + 1)) + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, sID) + }(byte(streamID)) wg1.Add(1) go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) + oneTurnConnectionLoop(ctx, params, peer, connchan, t, streamID) }() } } diff --git a/client/vk_captcha.go b/client/vk_captcha.go new file mode 100644 index 0000000..8e3a348 --- /dev/null +++ b/client/vk_captcha.go @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + vkCaptchaAPIVersion = "5.275" + vkCaptchaNotRobotVer = "5.131" + vkDebugInfo = "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785" +) + +// VkCaptchaData holds captcha challenge data from VK error response +type VkCaptchaData struct { + CaptchaSid string + CaptchaTs string + CaptchaAttempt string + RedirectURI string + SessionToken string +} + +// ExtractCaptchaData parses VK API error response for captcha info +func ExtractCaptchaData(errResp map[string]interface{}) (*VkCaptchaData, bool) { + code, _ := errResp["error_code"].(float64) + if int(code) != 14 { + return nil, false + } + + redirectURI, _ := errResp["redirect_uri"].(string) + if redirectURI == "" { + return nil, false + } + + // Parse session_token from redirect_uri + parsed, err := url.Parse(redirectURI) + if err != nil { + return nil, false + } + sessionToken := parsed.Query().Get("session_token") + if sessionToken == "" { + return nil, false + } + + // Extract captcha_sid + captchaSid, _ := errResp["captcha_sid"].(string) + + // captcha_ts can be float64 or string + var captchaTs string + if tsFloat, ok := errResp["captcha_ts"].(float64); ok { + captchaTs = fmt.Sprintf("%.0f", tsFloat) + } else if tsStr, ok := errResp["captcha_ts"].(string); ok { + captchaTs = tsStr + } + + // captcha_attempt + var captchaAttempt string + if attFloat, ok := errResp["captcha_attempt"].(float64); ok { + captchaAttempt = fmt.Sprintf("%.0f", attFloat) + } else if attStr, ok := errResp["captcha_attempt"].(string); ok { + captchaAttempt = attStr + } + + return &VkCaptchaData{ + CaptchaSid: captchaSid, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + RedirectURI: redirectURI, + SessionToken: sessionToken, + }, true +} + +// SolveVkCaptcha fetches captcha page, solves PoW, and calls captchaNotRobot API +func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, error) { + log.Printf("[Captcha] Solving Not Robot Captcha...") + + // HAR: Token 2 error → Captcha HTML = 2.72s (browser page load + user perception) + time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + + // Fetch captcha HTML (browser redirect) + powInput, difficulty, cookies, err := fetchCaptchaPowInput(ctx, captchaData.RedirectURI) + if err != nil { + return "", fmt.Errorf("failed to fetch powInput: %w", err) + } + log.Printf("[Captcha] PoW input: %s, difficulty: %d", powInput, difficulty) + + // Solve PoW + hash := solveCaptchaPoW(powInput, difficulty) + log.Printf("[Captcha] PoW solved: hash=%s", hash) + + // Call captchaNotRobot API with cookies from captcha page + successToken, err := callCaptchaNotRobotAPI(ctx, captchaData.SessionToken, hash, cookies) + if err != nil { + return "", fmt.Errorf("captchaNotRobot API failed: %w", err) + } + + log.Printf("[Captcha] Success! Got success_token") + return successToken, nil +} + +func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", redirectURI, nil) + if err != nil { + return "", 0, "", err + } + 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{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return "", 0, "", err + } + defer resp.Body.Close() + + // Capture Set-Cookie headers + var cookieValues []string + for _, setCookie := range resp.Header.Values("Set-Cookie") { + // Extract just the cookie name=value part (before ; expires= or ; path=) + cookieParts := strings.Split(setCookie, ";") + cookieValues = append(cookieValues, strings.TrimSpace(cookieParts[0])) + } + cookies := strings.Join(cookieValues, "; ") + if cookies != "" { + log.Printf("[Captcha] Captcha page set %d cookie(s)", len(cookieValues)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 0, "", err + } + + html := string(body) + + // Extract powInput: const powInput = "..." + powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + powInputMatch := powInputRe.FindStringSubmatch(html) + if len(powInputMatch) < 2 { + return "", 0, "", fmt.Errorf("powInput not found in captcha HTML") + } + powInput := powInputMatch[1] + + // Extract difficulty: '0'.repeat(N) + diffRe := regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`) + diffMatch := diffRe.FindStringSubmatch(html) + difficulty := 2 // default + if len(diffMatch) >= 2 { + if d, err := strconv.Atoi(diffMatch[1]); err == nil { + difficulty = d + } + } + + return powInput, difficulty, cookies, nil +} + +func solveCaptchaPoW(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 callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies string) (string, error) { + vkReq := func(method string, postData string) (map[string]interface{}, error) { + requestURL := fmt.Sprintf("https://api.vk.ru/method/%s?v=%s", method, vkCaptchaNotRobotVer) + + req, err := http.NewRequestWithContext(ctx, "POST", requestURL, strings.NewReader(postData)) + 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://id.vk.ru") + req.Header.Set("Referer", "https://id.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") + // Add cookies captured from captcha page + if cookies != "" { + req.Header.Set("Cookie", cookies) + } + + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + 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 + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return result, nil + } + + domain := "vk.com" + baseParams := fmt.Sprintf("session_token=%s&domain=%s&adFp=&access_token=", + url.QueryEscape(sessionToken), url.QueryEscape(domain)) + + // Step 1: settings + log.Printf("[Captcha] Step 1/4: settings") + _, err := vkReq("captchaNotRobot.settings", baseParams) + if err != nil { + return "", fmt.Errorf("settings failed: %w", err) + } + // HAR: settings → componentDone = 0.19s + time.Sleep(100*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond) + + // Step 2: componentDone + log.Printf("[Captcha] Step 2/4: componentDone") + browserFp := fmt.Sprintf("%032x", uint64(time.Now().UnixNano())) + deviceJSON := `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1920,"innerHeight":945,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":16,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` + componentData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, url.QueryEscape(deviceJSON)) + _, err = vkReq("captchaNotRobot.componentDone", componentData) + if err != nil { + return "", fmt.Errorf("componentDone failed: %w", err) + } + // HAR: componentDone → check ≈ 1.95s + statEvents delay ≈ 3.2s total + time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + + // Step 3: check + log.Printf("[Captcha] Step 3/4: check") + cursorJSON := `[{"x":950,"y":500},{"x":945,"y":510},{"x":940,"y":520},{"x":938,"y":525},{"x":938,"y":525}]` + answer := base64.StdEncoding.EncodeToString([]byte("{}")) // e30= + + // Generate random connectionDownlink values (simulating Network Information API) + // HAR shows browser repeats the same value 7 times: [9.8,9.8,9.8,9.8,9.8,9.8,9.8] + baseDownlink := 8.0 + rand.Float64()*4.0 // Random in [8.0, 12.0) for typical WiFi + downlinkStr := fmt.Sprintf("%.1f", baseDownlink) + connectionDownlink := "[" + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "]" + + 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", + url.QueryEscape("[]"), // accelerometer + url.QueryEscape("[]"), // gyroscope + url.QueryEscape("[]"), // motion + url.QueryEscape(cursorJSON), // cursor + url.QueryEscape("[]"), // taps + url.QueryEscape("[]"), // connectionRtt + url.QueryEscape(connectionDownlink), + browserFp, // browser_fp + hash, // hash (PoW result) + answer, // answer + vkDebugInfo, // debug_info (static) + ) + + checkResp, err := vkReq("captchaNotRobot.check", checkData) + if err != nil { + return "", fmt.Errorf("check request failed: %w", err) + } + + respObj, ok := checkResp["response"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid check response: %v", checkResp) + } + + status, _ := respObj["status"].(string) + if status != "OK" { + return "", fmt.Errorf("check response status: %s, full response: %v", status, checkResp) + } + + successToken, ok := respObj["success_token"].(string) + if !ok || successToken == "" { + return "", fmt.Errorf("success_token not found in check response: %v", checkResp) + } + // HAR: check → endSession = 0.48s + time.Sleep(200*time.Millisecond + time.Duration(rand.Intn(300))*time.Millisecond) + + // Step 4: endSession + log.Printf("[Captcha] Step 4/4: endSession") + vkReq("captchaNotRobot.endSession", baseParams) + + return successToken, nil +}