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=