Browse Source

sync captcha solver with https://github.com/cacggghp/vk-turn-proxy

pull/26/head
kiper292 2 months ago
parent
commit
d8c30b4ae9
  1. 907
      client/main.go
  2. 66
      client/manual_captcha.go
  3. 187
      client/namegen.go
  4. 82
      client/profiles.go
  5. 493
      client/slider_captcha.go
  6. 258
      client/vk.go
  7. 65
      client/wb.go
  8. 19
      go.mod
  9. 54
      go.sum

907
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)
}
}

66
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}</style>
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, `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:20px"><h2>Captcha proxy error</h2><p>%v</p></body></html>`, err)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:20px"><h2>Captcha proxy error</h2><p>%s %s</p><p>%v</p></body></html>`, 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
}

187
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)
}

82
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))]
}

493
client/vk_captcha.go → client/slider_captcha.go

@ -1,25 +1,9 @@
/* SPDX-License-Identifier: Apache-2.0
*
* Copyright © 2026 WireGuard LLC. All Rights Reserved.
*/
package main
/*
#include <stdlib.h>
// 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()
}

258
client/vk.go

@ -1,258 +0,0 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// 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
}

65
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
}

19
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
)

54
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=

Loading…
Cancel
Save