From 7dfd9fa9c3cebb2b6b013b98824ee85b04f08d2e Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:31:05 +0300 Subject: [PATCH 1/6] Add VK Calls credential path as primary VK bypass method Ports the newer VK TURN-credential path from proxy-turn-vk-android: a primary route via api.vk.me (auth.getAnonymToken / messages.getCallPreview / messages.getAnonymCallToken) that is tried before falling back to the older login.vk.ru/api.vk.ru chain, selectable with -vk-anon-path (vkcalls/legacy, defaults to vkcalls). Also brings over the captcha session-exhaustion detection so a rate-limited captcha session falls back to WebView faster instead of retrying the same solver. --- client/creds.go | 27 +++ client/creds_vkcalls.go | 389 ++++++++++++++++++++++++++++++++++++++++ client/main.go | 5 + client/vk_account.go | 20 +++ 4 files changed, 441 insertions(+) create mode 100644 client/creds_vkcalls.go diff --git a/client/creds.go b/client/creds.go index d59e03b0..6396e269 100644 --- a/client/creds.go +++ b/client/creds.go @@ -248,6 +248,17 @@ func fetchVkCreds(ctx context.Context, link string, streamID int) (string, strin return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") } + if getVkAnonPath() == "vkcalls" { + if user, pass, addrs, err := getVKCredsViaVKCallsPath(ctx, link, streamID); err == nil { + log.Printf("[STREAM %d] [VK Auth] Success via VK Calls path", streamID) + return user, pass, addrs, nil + } else { + log.Printf("[STREAM %d] [VK Auth] VK Calls path failed (%s), falling back to legacy", streamID, describeVKCallsFailure(err)) + } + } else { + log.Printf("[STREAM %d] [VK Auth] Legacy path selected, skipping VK Calls", streamID) + } + var lastErr error jar := tlsclient.NewCookieJar() @@ -291,6 +302,10 @@ func fetchVkCreds(ctx context.Context, link string, streamID int) (string, strin func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) { profile := getRandomProfile() + if saved, err := LoadProfileFromDisk(); err == nil && saved != nil && strings.TrimSpace(saved.UserAgent) != "" { + profile = saved.Profile + log.Printf("[STREAM %d] [VK Auth] Используем профиль устройства из vk_profile.json", streamID) + } client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), tlsclient.WithTimeoutSeconds(20), @@ -513,6 +528,10 @@ func solveCaptchaBySelectedMode( if ctx.Err() != nil { return "", solveErr } + if isCaptchaSessionExhausted(solveErr) { + log.Printf("[STREAM %d] [КАПЧА] RJS: сессия капчи исчерпана, открываем WebView", streamID) + return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout) + } log.Printf("[STREAM %d] [КАПЧА] RJS: ошибка, fallback на WBV Auto: %v", streamID, solveErr) return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout) } @@ -528,6 +547,14 @@ func solveCaptchaBySelectedMode( return "", solveErr } lastErr := solveErr + if isCaptchaSessionExhausted(solveErr) { + log.Printf("[STREAM %d] [КАПЧА] AUTO: сессия капчи исчерпана, сразу ручной WebView", streamID) + token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "manual", captchaManualWebViewTimeout) + if solveErr == nil { + return token, nil + } + return "", solveErr + } log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 не решил за 2 попытки: %v", streamID, solveErr) for wbvAttempt := 1; wbvAttempt <= 2; wbvAttempt++ { diff --git a/client/creds_vkcalls.go b/client/creds_vkcalls.go new file mode 100644 index 00000000..2cd7df56 --- /dev/null +++ b/client/creds_vkcalls.go @@ -0,0 +1,389 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + neturl "net/url" + "os" + "strings" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + "github.com/google/uuid" +) + +const ( + vkConnectClientID = "8093730" + vkCallsAPIHost = "api.vk.me" + vkCallsAnonAPIVersion = "5.276" +) + +var vkCallsProfile = 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: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, +} + +type vkCallsFailureKind string + +const ( + vkCallsFailureSkipped vkCallsFailureKind = "skipped" + vkCallsFailureSetup vkCallsFailureKind = "setup" + vkCallsFailureNetwork vkCallsFailureKind = "network" + vkCallsFailureDecode vkCallsFailureKind = "decode" + vkCallsFailureVKAPI vkCallsFailureKind = "vk_api" + vkCallsFailureCaptcha vkCallsFailureKind = "captcha" + vkCallsFailureOKCDN vkCallsFailureKind = "okcdn_api" + vkCallsFailureParse vkCallsFailureKind = "parse" +) + +type vkCallsFailure struct { + Step string + Kind vkCallsFailureKind + Err error +} + +func (e *vkCallsFailure) Error() string { + if e == nil { + return "vkcalls failure" + } + if e.Err == nil { + return fmt.Sprintf("step=%s kind=%s", e.Step, e.Kind) + } + return fmt.Sprintf("step=%s kind=%s: %v", e.Step, e.Kind, e.Err) +} + +func (e *vkCallsFailure) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func newVKCallsFailure(step string, kind vkCallsFailureKind, err error) error { + if err == nil { + err = fmt.Errorf("unknown error") + } + return &vkCallsFailure{Step: step, Kind: kind, Err: err} +} + +func describeVKCallsFailure(err error) string { + if err == nil { + return "" + } + var failure *vkCallsFailure + if errors.As(err, &failure) { + return failure.Error() + } + return err.Error() +} + +func vkCallsAPIErrorKind(err error) vkCallsFailureKind { + var captchaErr *VkCaptchaError + if errors.As(err, &captchaErr) { + return vkCallsFailureCaptcha + } + return vkCallsFailureVKAPI +} + +type vkCallsVKAPIError struct { + Code int + Message string +} + +func (e *vkCallsVKAPIError) Error() string { + if e == nil { + return "VK API error" + } + if e.Message == "" { + return fmt.Sprintf("error_code=%d", e.Code) + } + return fmt.Sprintf("error_code=%d %s", e.Code, e.Message) +} + +type vkCallsOKAPIError struct { + Code int + Message string +} + +func (e *vkCallsOKAPIError) Error() string { + if e == nil { + return "OK CDN API error" + } + if e.Message == "" { + return fmt.Sprintf("error_code=%d", e.Code) + } + return fmt.Sprintf("error_code=%d %s", e.Code, e.Message) +} + +func getVKCredsViaVKCallsPath(ctx context.Context, link string, streamID int) (string, string, []string, error) { + if os.Getenv("VK_SKIP_VKCALLS") == "1" { + return "", "", nil, newVKCallsFailure("preflight", vkCallsFailureSkipped, fmt.Errorf("disabled by VK_SKIP_VKCALLS=1")) + } + + deviceID := uuid.New().String() + name := generateName() + profile := vkCallsProfile + linkURL := neturl.QueryEscape("https://vk.com/call/join/" + link) + nameEnc := neturl.QueryEscape(name) + + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), + tlsclient.WithTimeoutSeconds(20), + tlsclient.WithClientProfile(profiles.Chrome_146), + tlsclient.WithCookieJar(tlsclient.NewCookieJar()), + ) + if err != nil { + return "", "", nil, newVKCallsFailure("setup", vkCallsFailureSetup, fmt.Errorf("create tls client: %w", err)) + } + + log.Printf("[STREAM %d] [VKCalls] Identity - Name: %s | device_id=%s | TLS=Chrome_146 | UA: %s", streamID, name, deviceID, profile.UserAgent) + + doRequest := func(step string, url string) (map[string]interface{}, error) { + req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(nil)) + if err != nil { + return nil, newVKCallsFailure(step, vkCallsFailureSetup, fmt.Errorf("create request: %w", err)) + } + req.Header.Set("User-Agent", profile.UserAgent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + req.Header.Set("Accept-Language", "en-GB,en;q=0.9") + + httpResp, err := client.Do(req) + if err != nil { + return nil, newVKCallsFailure(step, vkCallsFailureNetwork, fmt.Errorf("request failed: %w", 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, newVKCallsFailure(step, vkCallsFailureNetwork, fmt.Errorf("read response: %w", err)) + } + + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, newVKCallsFailure(step, vkCallsFailureDecode, fmt.Errorf("unmarshal JSON: %w, body: %s", err, truncateVKCallsLog(string(body), 200))) + } + return resp, nil + } + + step1 := "step1 auth.getAnonymToken" + step1URL := fmt.Sprintf( + "https://%s/method/auth.getAnonymToken?v=%s&client_id=%s&link=%s&device_id=%s&anonymName=%s&lang=en", + vkCallsAPIHost, vkCallsAnonAPIVersion, vkConnectClientID, + linkURL, deviceID, nameEnc, + ) + resp1, err := doRequest(step1, step1URL) + if err != nil { + return "", "", nil, err + } + anonymToken, err := extractVKCallsStr(resp1, "response", "token") + if err != nil { + return "", "", nil, newVKCallsFailure(step1, vkCallsFailureParse, fmt.Errorf("parse token: %w (resp: %s)", err, truncateVKCallsResp(resp1))) + } + anonymTokenEnc := neturl.QueryEscape(anonymToken) + log.Printf("[STREAM %d] [VKCalls] step1 OK, anonymous_token (%d chars)", streamID, len(anonymToken)) + + step2 := "step2 messages.getCallPreview" + step2URL := fmt.Sprintf( + "https://%s/method/messages.getCallPreview?v=%s&anonymous_token=%s&device_id=%s&extended=1&fields=first_name,last_name,photo_200&lang=en&link=%s", + vkCallsAPIHost, vkCallsAnonAPIVersion, anonymTokenEnc, deviceID, linkURL, + ) + resp2, err := doRequest(step2, step2URL) + if err != nil { + return "", "", nil, err + } + if apiErr := vkCallsAPIError(resp2); apiErr != nil { + if captchaErr, ok := apiErr.(*VkCaptchaError); ok { + log.Printf("[STREAM %d] [VKCalls] step2 captcha gate appeared (sid=%q, redirect_uri=%t)", streamID, captchaErr.CaptchaSid, captchaErr.RedirectURI != "") + } + return "", "", nil, newVKCallsFailure(step2, vkCallsAPIErrorKind(apiErr), apiErr) + } + userIDFloat, err := extractVKCallsFloat(resp2, "response", "user_id") + if err != nil { + return "", "", nil, newVKCallsFailure(step2, vkCallsFailureParse, fmt.Errorf("parse user_id: %w (resp: %s)", err, truncateVKCallsResp(resp2))) + } + userIDStr := fmt.Sprintf("%.0f", userIDFloat) + secret, err := extractVKCallsStr(resp2, "response", "secret") + if err != nil { + return "", "", nil, newVKCallsFailure(step2, vkCallsFailureParse, fmt.Errorf("parse secret: %w", err)) + } + log.Printf("[STREAM %d] [VKCalls] step2 OK, user_id=%s, secret (%d chars)", streamID, userIDStr, len(secret)) + + step3 := "step3 messages.getAnonymCallToken" + step3URL := fmt.Sprintf( + "https://%s/method/messages.getAnonymCallToken?v=%s&anonymous_token=%s&device_id=%s&link=%s&name=%s&user_id=%s&secret=%s&lang=en", + vkCallsAPIHost, vkCallsAnonAPIVersion, anonymTokenEnc, deviceID, linkURL, + nameEnc, userIDStr, neturl.QueryEscape(secret), + ) + resp3, err := doRequest(step3, step3URL) + if err != nil { + return "", "", nil, err + } + if apiErr := vkCallsAPIError(resp3); apiErr != nil { + if captchaErr, ok := apiErr.(*VkCaptchaError); ok { + log.Printf("[STREAM %d] [VKCalls] step3 captcha gate appeared (sid=%q, redirect_uri=%t)", streamID, captchaErr.CaptchaSid, captchaErr.RedirectURI != "") + } + return "", "", nil, newVKCallsFailure(step3, vkCallsAPIErrorKind(apiErr), apiErr) + } + okAnonymToken, err := extractVKCallsStr(resp3, "response", "token") + if err != nil { + return "", "", nil, newVKCallsFailure(step3, vkCallsFailureParse, fmt.Errorf("parse token: %w (resp: %s)", err, truncateVKCallsResp(resp3))) + } + log.Printf("[STREAM %d] [VKCalls] step3 OK, OK anonymToken (%d chars)", streamID, len(okAnonymToken)) + + okDeviceID := uuid.New().String() + step4 := "step4 auth.anonymLogin" + step4URL := "https://calls.okcdn.ru/fb.do?session_data=" + + neturl.QueryEscape(fmt.Sprintf( + `{"version":2,"device_id":"%s","client_version":"1.0.1"}`, okDeviceID, + )) + + "&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA" + resp4, err := doRequest(step4, step4URL) + if err != nil { + return "", "", nil, err + } + sessionKey, err := extractVKCallsStr(resp4, "session_key") + if err != nil { + return "", "", nil, newVKCallsFailure(step4, vkCallsFailureParse, fmt.Errorf("parse session_key: %w (resp: %s)", err, truncateVKCallsResp(resp4))) + } + log.Printf("[STREAM %d] [VKCalls] step4 OK, OK session_key (%d chars)", streamID, len(sessionKey)) + + step5 := "step5 vchat.joinConversationByLink" + step5URL := fmt.Sprintf( + "https://calls.okcdn.ru/fb.do?joinLink=%s&isVideo=false&protocolVersion=5&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", + link, okAnonymToken, sessionKey, + ) + resp5, err := doRequest(step5, step5URL) + if err != nil { + return "", "", nil, err + } + if okErr := vkCallsOKError(resp5); okErr != nil { + return "", "", nil, newVKCallsFailure(step5, vkCallsFailureOKCDN, fmt.Errorf("%w (resp: %s)", okErr, truncateVKCallsResp(resp5))) + } + + user, err := extractVKCallsStr(resp5, "turn_server", "username") + if err != nil { + return "", "", nil, newVKCallsFailure(step5, vkCallsFailureParse, fmt.Errorf("parse username: %w (resp: %s)", err, truncateVKCallsResp(resp5))) + } + pass, err := extractVKCallsStr(resp5, "turn_server", "credential") + if err != nil { + return "", "", nil, newVKCallsFailure(step5, vkCallsFailureParse, fmt.Errorf("parse credential: %w", err)) + } + addrs := parseVKCallsTURNAddresses(resp5) + if len(addrs) == 0 { + return "", "", nil, newVKCallsFailure(step5, vkCallsFailureParse, fmt.Errorf("turn_server.urls empty")) + } + + log.Printf("[STREAM %d] [VKCalls] SUCCESS, TURN urls=%d", streamID, len(addrs)) + return user, pass, addrs, nil +} + +func extractVKCallsStr(resp map[string]interface{}, keys ...string) (string, error) { + var cur interface{} = resp + for _, k := range keys { + m, ok := cur.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("expected map at key %q, got %T", k, cur) + } + cur = m[k] + } + s, ok := cur.(string) + if !ok { + return "", fmt.Errorf("expected string at end of path, got %T", cur) + } + return s, nil +} + +func extractVKCallsFloat(resp map[string]interface{}, keys ...string) (float64, error) { + var cur interface{} = resp + for _, k := range keys { + m, ok := cur.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("expected map at key %q, got %T", k, cur) + } + cur = m[k] + } + f, ok := cur.(float64) + if !ok { + return 0, fmt.Errorf("expected float64 at end of path, got %T", cur) + } + return f, nil +} + +func parseVKCallsTURNAddresses(resp map[string]interface{}) []string { + turnServer, ok := resp["turn_server"].(map[string]interface{}) + if !ok { + return nil + } + urls, ok := turnServer["urls"].([]interface{}) + if !ok { + return nil + } + var addrs []string + for i, u := range urls { + s, ok := u.(string) + if !ok { + log.Printf("[VKCalls] turn_server.urls[%d]=, skipping", i, u) + continue + } + clean := strings.Split(s, "?")[0] + addr := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + log.Printf("[VKCalls] turn_server.urls[%d]=%s", i, addr) + addrs = append(addrs, addr) + } + return addrs +} + +func vkCallsAPIError(resp map[string]interface{}) error { + errObj, ok := resp["error"].(map[string]interface{}) + if !ok { + return nil + } + code, _ := errObj["error_code"].(float64) + msg, _ := errObj["error_msg"].(string) + if code == 0 && msg == "" { + return nil + } + if int(code) == 14 { + if errJSON, err := json.Marshal(errObj); err == nil { + log.Printf("[VKCalls] captcha error response: %s", truncateVKCallsLog(string(errJSON), 300)) + } + return parseVkCaptchaError(errObj) + } + return &vkCallsVKAPIError{Code: int(code), Message: msg} +} + +func vkCallsOKError(resp map[string]interface{}) error { + code, ok := resp["error_code"].(float64) + if !ok || code == 0 { + return nil + } + msg, _ := resp["error_msg"].(string) + return &vkCallsOKAPIError{Code: int(code), Message: msg} +} + +func truncateVKCallsLog(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func truncateVKCallsResp(resp map[string]interface{}) string { + b, err := json.Marshal(resp) + if err != nil { + return fmt.Sprintf("(unmarshallable: %v)", err) + } + return truncateVKCallsLog(string(b), 300) +} diff --git a/client/main.go b/client/main.go index c6797dff..22ae75dc 100644 --- a/client/main.go +++ b/client/main.go @@ -127,16 +127,21 @@ func main() { connPassword := flag.String("password", "", "пароль подключения") captchaMode := flag.String("captcha-mode", "auto", "режим обхода капчи (auto/wv/rjs)") vkAuthMode := flag.String("vk-auth", "anonymous", "режим VK авторизации (account/anonymous)") + vkAnonPath := flag.String("vk-anon-path", "vkcalls", "анонимный путь VK TURN (vkcalls/legacy)") vkCredsFile := flag.String("vk-creds-file", "", "файл с TURN кредами от аккаунта VK") flag.Parse() activeCaptchaMode := setCaptchaMode(*captchaMode) activeVkAuthMode := setVkAuthMode(*vkAuthMode) + activeVkAnonPath := setVkAnonPath(*vkAnonPath) if err := loadVkCredsFile(*vkCredsFile); err != nil { log.Fatalf("[КЛИЕНТ] Ошибка чтения vk-creds-file: %v", err) } log.Printf("[КЛИЕНТ] VK auth mode: %s", activeVkAuthMode) + if activeVkAuthMode == "anonymous" { + log.Printf("[КЛИЕНТ] VK anon path: %s", activeVkAnonPath) + } if *peerAddr == "" || *vkHash == "" { log.Fatal("[КЛИЕНТ] Нужны -peer и -vk") diff --git a/client/vk_account.go b/client/vk_account.go index 443e7616..6ac9e79c 100644 --- a/client/vk_account.go +++ b/client/vk_account.go @@ -34,12 +34,14 @@ type vkCredsFile struct { var ( vkAuthModeValue atomic.Value + vkAnonPathValue atomic.Value injectedCredsMu sync.RWMutex injectedCredsByLink map[string]injectedTurnCreds ) func init() { vkAuthModeValue.Store("anonymous") + vkAnonPathValue.Store("vkcalls") injectedCredsByLink = make(map[string]injectedTurnCreds) } @@ -63,6 +65,24 @@ func getVkAuthMode() string { return mode } +func setVkAnonPath(path string) string { + path = strings.ToLower(strings.TrimSpace(path)) + if path == "legacy" { + vkAnonPathValue.Store("legacy") + return "legacy" + } + vkAnonPathValue.Store("vkcalls") + return "vkcalls" +} + +func getVkAnonPath() string { + path, _ := vkAnonPathValue.Load().(string) + if path == "" { + return "vkcalls" + } + return path +} + func drainTurnCredsResult() { select { case <-TurnCredsResultChan: From e87888c21d41cec322487facdfd4531e6cb92855 Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:34:03 +0300 Subject: [PATCH 2/6] Speed up captcha fallback when the solver session is rate-limited Adds isCaptchaSessionExhausted() (ported from proxy-turn-vk-android) so a rate-limited/exhausted captcha session jumps straight to WebView instead of retrying the same Go solver, plus the small supporting helpers (captchaV2DeviceJSON, captchaV2AcceptLanguage, captchaV2MaxSliderChecks) it depends on. --- client/captcha_v2.go | 64 +++++++++++++++++++++++++++++++++---- client/captcha_v2_slider.go | 19 ++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/client/captcha_v2.go b/client/captcha_v2.go index 3c27d83d..de7ee992 100644 --- a/client/captcha_v2.go +++ b/client/captcha_v2.go @@ -42,7 +42,8 @@ var ( errCaptchaV2RateLimit = errors.New("captcha session rate limit reached") errCaptchaV2Bot = errors.New("captcha bot challenge") - captchaV2MaxAttempts = 2 + captchaV2MaxAttempts = 2 + captchaV2MaxSliderChecks = 2 captchaV2DebugCache sync.Map // scriptURL -> string captchaV2HeaderOrder = []string{ @@ -263,6 +264,33 @@ func captchaV2BaseValues(sessionToken string) [][2]string { } } +func isCaptchaSessionExhausted(err error) bool { + if err == nil { + return false + } + if errors.Is(err, errCaptchaV2RateLimit) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "getcontent status:") || + strings.Contains(msg, "error_limit") || + strings.Contains(msg, "rate limit") +} + +func captchaV2DeviceJSON(savedProfile *SavedProfile) string { + if savedProfile != nil && strings.TrimSpace(savedProfile.DeviceJSON) != "" { + return savedProfile.DeviceJSON + } + return captchaV2DeviceInfo +} + +func captchaV2AcceptLanguage(profile Profile) string { + if strings.Contains(profile.SecChUaMobile, "?1") { + return "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7" + } + return "en-US,en;q=0.9" +} + func captchaV2BrowserFP() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { @@ -426,10 +454,7 @@ func (s *captchaV2Session) solveCheckboxCaptcha( hash string, debugInfo string, ) (string, error) { - deviceJSON := captchaV2DeviceInfo - if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { - deviceJSON = s.savedProfile.DeviceJSON - } + deviceJSON := captchaV2DeviceJSON(s.savedProfile) if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ {"session_token", sessionToken}, {"domain", "vk.com"}, @@ -444,7 +469,7 @@ func (s *captchaV2Session) solveCheckboxCaptcha( select { case <-s.ctx.Done(): return "", s.ctx.Err() - case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond): + case <-time.After(time.Duration(800+mathrand.Intn(500)) * time.Millisecond): } check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo) @@ -595,7 +620,7 @@ func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) { 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("Accept-Language", captchaV2AcceptLanguage(profile)) req.Header.Set("DNT", "1") } @@ -611,6 +636,31 @@ type VkCaptchaError struct { CaptchaImg string } +func (e *VkCaptchaError) Error() string { + if e == nil { + return "VK captcha required" + } + if e.ErrorCode != 0 && e.ErrorCode != 14 { + if e.ErrorMsg != "" { + return fmt.Sprintf("VK API error %d: %s", e.ErrorCode, e.ErrorMsg) + } + return fmt.Sprintf("VK API error %d", e.ErrorCode) + } + if e.RedirectURI != "" { + return fmt.Sprintf("VK captcha required: redirect_uri, sid=%q", e.CaptchaSid) + } + if e.CaptchaImg != "" { + return fmt.Sprintf("VK captcha required: captcha_img, sid=%q", e.CaptchaSid) + } + if e.CaptchaSid != "" { + return fmt.Sprintf("VK captcha required: sid=%q", e.CaptchaSid) + } + if e.ErrorMsg != "" { + return fmt.Sprintf("VK captcha required: %s", e.ErrorMsg) + } + return "VK captcha required" +} + func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { codeFloat, _ := errData["error_code"].(float64) redirectUri, _ := errData["redirect_uri"].(string) diff --git a/client/captcha_v2_slider.go b/client/captcha_v2_slider.go index 92f55875..debd2466 100644 --- a/client/captcha_v2_slider.go +++ b/client/captcha_v2_slider.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "time" ) type sliderPuzzleV2 struct { @@ -67,6 +68,9 @@ func (s *captchaV2Session) solveSliderCaptcha( } limit := puzzle.Attempts + if limit > captchaV2MaxSliderChecks { + limit = captchaV2MaxSliderChecks + } if limit > len(guesses) { limit = len(guesses) } @@ -75,10 +79,7 @@ func (s *captchaV2Session) solveSliderCaptcha( } log.Printf("[КАПЧА] v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit) - deviceJSON := captchaV2DeviceInfo - if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { - deviceJSON = s.savedProfile.DeviceJSON - } + deviceJSON := captchaV2DeviceJSON(s.savedProfile) if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ {"session_token", sessionToken}, {"domain", "vk.com"}, @@ -119,6 +120,13 @@ func (s *captchaV2Session) solveSliderCaptcha( if strings.EqualFold(check.Status, "error_limit") { return "", errCaptchaV2RateLimit } + if i+1 < limit { + select { + case <-s.ctx.Done(): + return "", s.ctx.Err() + case <-time.After(time.Duration(350+mathrand.Intn(300)) * time.Millisecond): + } + } } return "", errors.New("slider guesses exhausted") } @@ -130,6 +138,9 @@ func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) { } status := captchaV2StringifyAny(resp["status"]) if !strings.EqualFold(status, "ok") { + if strings.EqualFold(status, "error") || strings.EqualFold(status, "error_limit") { + return nil, errCaptchaV2RateLimit + } return nil, fmt.Errorf("slider getContent status: %s", status) } rawImage := captchaV2StringifyAny(resp["image"]) From 4176d7288aa1cf427c095d38228db39933b534e1 Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:34:19 +0300 Subject: [PATCH 3/6] Document -vk-anon-path flag --- README.md | 306 +----------------------------------------------------- 1 file changed, 1 insertion(+), 305 deletions(-) diff --git a/README.md b/README.md index 64665084..9a9bae05 100644 --- a/README.md +++ b/README.md @@ -1,305 +1 @@ -# VK TURN Proxy - -VK TURN Proxy — клиент и сервер для прокидывания локального UDP-трафика через TURN-реле, получаемые из ссылки на VK Calls. Типичный сценарий — поднять `server` на VPS рядом с WireGuard, а на клиентском устройстве запустить `client`, который слушает локальный адрес вроде `127.0.0.1:9000`. - -> [!CAUTION] -> Проект предназначен для обучения, исследований и администрирования собственных стендов. Используйте его только там, где у вас есть право запускать такой трафик и менять сетевую конфигурацию. - -## Содержание - -- [Как это работает](#как-это-работает) -- [Возможности](#возможности) -- [Что нужно](#что-нужно) -- [Быстрый старт: WireGuard](#быстрый-старт-wireguard) - - [Запуск сервера на VPS](#1-запустите-сервер-на-vps) - - [Настройка WireGuard](#2-настройте-wireguard-на-клиенте) - - [Запуск клиента](#3-запустите-клиент) -- [Android](#android) -- [systemd-сервис](#сервер-как-systemd-сервис) -- [Docker](#docker) -- [VLESS / Xray](#vless--xray) -- [Флаги клиента](#флаги-клиента) -- [Флаги сервера](#флаги-сервера) -- [Сборка из исходников](#сборка-из-исходников) -- [Решение проблем](#решение-проблем) -- [Похожие проекты](#похожие-проекты) -- [Лицензия](#лицензия) - -## Как Это Работает - -Схема для WireGuard: - -```text -WireGuard client → 127.0.0.1:9000 → VK TURN Proxy client - → VK TURN relay → VK TURN Proxy server на VPS - → 127.0.0.1:51820 → WireGuard server -``` - -Клиент берёт временные TURN-учётные данные из ссылки VK Calls, открывает несколько DTLS-соединений к TURN-реле и отправляет через них трафик к `server` на VPS. Трафик обфусцируется под WebRTC OPUS-аудио (RTP-заголовки + ChaCha20-Poly1305 AEAD), что делает его неотличимым от реального звонка. - -## Возможности - -- VK Calls как источник TURN-учётных данных (2 стабильных app_id, ротация). -- Несколько параллельных TURN-потоков через `-n` (кратно 9). -- RTP-AEAD обфускация DTLS-пакетов: трафик похож на WebRTC OPUS для DPI. -- Ключ шифрования выводится из пароля через HKDF — никаких hex-ключей. -- DTLS Connection ID для снижения DPI-отпечатка. -- Автоматическое прохождение VK captcha (v2, checkbox). -- WireGuard UDP backend. -- VLESS/Xray TCP backend через `-vless`. -- Docker-образ для серверной части. - -## Что Нужно - -- VPS с публичным IP. -- На VPS уже должен слушать backend: - - WireGuard: обычно `127.0.0.1:51820/udp`; - - Xray/VLESS: обычно `127.0.0.1:443/tcp`. -- Ссылка на активный VK Calls вида `https://vk.com/call/join/...`. -- На клиенте: WireGuard или другой локальный клиент, который будет ходить в `127.0.0.1:9000`. - -## Быстрый Старт: WireGuard - -### 1. Запустите Сервер На VPS - -Скачайте бинарник для Linux amd64: - -```bash -curl -L -o server https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/server-linux-amd64 -chmod +x server -``` - -Запустите сервер, указав адрес WireGuard и пароль: - -```bash -./server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" -``` - -Порт `56000/udp` должен быть доступен снаружи. Если WireGuard слушает другой порт — замените `51820`. Пароль может быть любой строкой — главное, чтобы он совпадал на клиенте и сервере. - -### 2. Настройте WireGuard На Клиенте - -В клиентском конфиге WireGuard замените endpoint сервера на локальный адрес VK TURN Proxy: - -```ini -[Peer] -Endpoint = 127.0.0.1:9000 -# ... - -[Interface] -MTU = 1280 -# ... -``` - -### 3. Запустите Клиент - -Linux: - -```bash -curl -L -o client https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-linux-amd64 -chmod +x client -./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 -``` - -Windows PowerShell: - -```powershell -Invoke-WebRequest -Uri https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe -.\client.exe -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 -``` - -macOS: - -```bash -curl -L -o client https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-darwin-arm64 -chmod +x client -./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 -``` - -После запуска клиента включите WireGuard. - -> `-vk` принимает как полную ссылку, так и только хеш (часть после `/call/join/`). Несколько ссылок через запятую: `-vk "ХЕШ1,ХЕШ2"`. - -## Android - -Скачайте бинарник `client-android-arm64` из Releases и запустите через Termux или встройте в своё приложение: - -```bash -termux-wake-lock -./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 -``` - -В WireGuard укажите `Endpoint = 127.0.0.1:9000` и `MTU = 1280`. Добавьте Termux в исключения WireGuard (split tunneling). - -## Сервер Как systemd-Сервис - -Пример `/etc/systemd/system/vk-turn-proxy.service`: - -```ini -[Unit] -Description=VK TURN Proxy server -After=network.target - -[Service] -Type=simple -ExecStart=/opt/vk-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" -Restart=always -RestartSec=5 -User=nobody -Group=nogroup - -[Install] -WantedBy=multi-user.target -``` - -Применить: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now vk-turn-proxy.service -sudo systemctl status vk-turn-proxy.service -``` - -## Docker - -Образ публикуется в GitHub Container Registry: - -```bash -docker pull ghcr.io/mysorez/vk-turn-proxy:latest -``` - -Host network (рекомендуется, если backend на том же хосте): - -```bash -docker run --rm --network host \ - ghcr.io/mysorez/vk-turn-proxy:latest \ - -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" -``` - -Bridge mode: - -```bash -docker run --rm -p 56000:56000/udp \ - ghcr.io/mysorez/vk-turn-proxy:latest \ - -listen 0.0.0.0:56000 -connect HOST_IP:51820 -wrap -password "ВАШ_ПАРОЛЬ" -``` - -## VLESS / Xray - -В режиме `-vless` `server` подключается к локальному TCP backend (например Xray). - -Сервер: - -```bash -./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -wrap -password "ВАШ_ПАРОЛЬ" -``` - -Клиент — для VLESS используйте отдельную сборку с поддержкой TCP (см. вашу конфигурацию). - -С bonding (несколько параллельных DTLS через одно TCP): - -```bash -./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -vless-bond -wrap -password "ВАШ_ПАРОЛЬ" -``` - -## Флаги Клиента - -| Флаг | По умолчанию | Описание | -| --- | --- | --- | -| `-peer` | **обязательный** | адрес сервера на VPS, например `1.2.3.4:56000` | -| `-vk` | **обязательный** | ссылка(и) VK Calls или хеш(и), через запятую | -| `-password` | **обязательный** | пароль для шифрования (совпадает на клиенте и сервере) | -| `-listen` | `127.0.0.1:9000` | локальный адрес для WireGuard или другого клиента | -| `-n` | `24` | количество воркеров, кратно 9 (минимум 9) | -| `-ping-only` | `false` | замерить RTT через TURN и выйти | -| `-device-id` | `unknown` | ID устройства (для логов) | -| `-captcha-mode` | `auto` | режим капчи: `auto`, `wv` (WebView), `rjs` (Go solver) | -| `-vk-auth` | `anonymous` | режим авторизации VK: `anonymous` или `account` | -| `-vk-creds-file` | пусто | JSON-файл с TURN-кредами от аккаунта VK | -| `-stats-interval` | `30` | интервал статистики в секундах (`0` = выключить) | -| `-turn` | из ссылки | переопределить IP TURN-сервера | -| `-port` | из ссылки | переопределить порт TURN-сервера | - -## Флаги Сервера - -| Флаг | По умолчанию | Описание | -| --- | --- | --- | -| `-listen` | `0.0.0.0:56000` | адрес прослушивания | -| `-connect` | **обязательный** | backend-адрес: `127.0.0.1:51820` (WireGuard) или `127.0.0.1:443` (VLESS) | -| `-wrap` | `false` | включить RTP-AEAD обфускацию (требует `-password`) | -| `-password` | пусто | пароль для `-wrap`, ключ выводится через HKDF | -| `-vless` | `false` | TCP/VLESS режим | -| `-vless-bond` | `false` | bonding для VLESS | -| `-debug` | `false` | подробные логи | - -## Сборка Из Исходников - -Нужен Go 1.22+. - -```bash -go build -o client ./client -go build -o server ./server -``` - -Кросс-сборка для Linux amd64: - -```bash -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./server -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-linux-amd64 ./client -``` - -Кросс-сборка для Android arm64 (требует NDK): - -```bash -export CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang -GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build -trimpath -ldflags "-s -w" -o client-android-arm64 ./client -``` - -## Решение Проблем - -- Сначала запускайте VK TURN Proxy client, потом включайте WireGuard. -- Убедитесь, что `-password` одинаковый на клиенте и сервере. -- Если соединение не устанавливается — проверьте, что порт `56000/udp` открыт на VPS (firewall, ufw). -- Если WireGuard забирает весь трафик — добавьте IP TURN-реле в исключения маршрутизации. -- Если соединение нестабильное — попробуйте уменьшить `-n 9` (минимум). -- Если VK просит капчу слишком часто — попробуйте сменить ссылку VK Calls. -- Если клиент завис на получении кредов — проверьте, что ссылка VK Calls живая. -- Для отладки добавьте `-stats-interval 5` на клиенте, `-debug` на сервере. - -## Похожие Проекты - -Авторы этого репозитория не отвечают за работу сторонних проектов. - -Server: - -- https://github.com/Urtyom-Alyanov/turn-proxy — реализация на Rust. -- https://github.com/NedgNDG/vk-proxy-auto-installer — автоустановщик VK TURN Proxy. - -Android: - -- https://github.com/samosvalishe/turn-proxy-android -- https://github.com/MYSOREZ/vk-turn-proxy-android -- https://github.com/kiper292/wireguard-turn-android -- https://github.com/WINGS-N/WINGSV -- https://github.com/oxsidee/vkpn -- https://github.com/amurcanov/proxy-turn-vk-android - -iOS: - -- https://github.com/nullcstring/turnbridge - -macOS: - -- https://github.com/denny4-user/vk-turn-proxy-macos-gui - -## Лицензия - -GPL-3.0. См. [LICENSE](LICENSE). - - - - - - Star History Chart - - +IyBWSyBUVVJOIFByb3h5CgpWSyBUVVJOIFByb3h5INCay7Bg \ No newline at end of file From 83843314e89b448bb69dbc63c1e305a05aa18ea1 Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:36:15 +0300 Subject: [PATCH 4/6] Restore README and document -vk-anon-path flag Fixes a bad update that truncated README.md; restores full content and adds the -vk-anon-path row (vkcalls/legacy) to the client flags table. --- README.md | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a9bae05..41370f99 100644 --- a/README.md +++ b/README.md @@ -1 +1,306 @@ -IyBWSyBUVVJOIFByb3h5CgpWSyBUVVJOIFByb3h5INCay7Bg \ No newline at end of file +# VK TURN Proxy + +VK TURN Proxy — клиент и сервер для прокидывания локального UDP-трафика через TURN-реле, получаемые из ссылки на VK Calls. Типичный сценарий — поднять `server` на VPS рядом с WireGuard, а на клиентском устройстве запустить `client`, который слушает локальный адрес вроде `127.0.0.1:9000`. + +> [!CAUTION] +> Проект предназначен для обучения, исследований и администрирования собственных стендов. Используйте его только там, где у вас есть право запускать такой трафик и менять сетевую конфигурацию. + +## Содержание + +- [Как это работает](#как-это-работает) +- [Возможности](#возможности) +- [Что нужно](#что-нужно) +- [Быстрый старт: WireGuard](#быстрый-старт-wireguard) + - [Запуск сервера на VPS](#1-запустите-сервер-на-vps) + - [Настройка WireGuard](#2-настройте-wireguard-на-клиенте) + - [Запуск клиента](#3-запустите-клиент) +- [Android](#android) +- [systemd-сервис](#сервер-как-systemd-сервис) +- [Docker](#docker) +- [VLESS / Xray](#vless--xray) +- [Флаги клиента](#флаги-клиента) +- [Флаги сервера](#флаги-сервера) +- [Сборка из исходников](#сборка-из-исходников) +- [Решение проблем](#решение-проблем) +- [Похожие проекты](#похожие-проекты) +- [Лицензия](#лицензия) + +## Как Это Работает + +Схема для WireGuard: + +```text +WireGuard client → 127.0.0.1:9000 → VK TURN Proxy client + → VK TURN relay → VK TURN Proxy server на VPS + → 127.0.0.1:51820 → WireGuard server +``` + +Клиент берёт временные TURN-учётные данные из ссылки VK Calls, открывает несколько DTLS-соединений к TURN-реле и отправляет через них трафик к `server` на VPS. Трафик обфусцируется под WebRTC OPUS-аудио (RTP-заголовки + ChaCha20-Poly1305 AEAD), что делает его неотличимым от реального звонка. + +## Возможности + +- VK Calls как источник TURN-учётных данных (2 стабильных app_id, ротация). +- Несколько параллельных TURN-потоков через `-n` (кратно 9). +- RTP-AEAD обфускация DTLS-пакетов: трафик похож на WebRTC OPUS для DPI. +- Ключ шифрования выводится из пароля через HKDF — никаких hex-ключей. +- DTLS Connection ID для снижения DPI-отпечатка. +- Автоматическое прохождение VK captcha (v2, checkbox). +- WireGuard UDP backend. +- VLESS/Xray TCP backend через `-vless`. +- Docker-образ для серверной части. + +## Что Нужно + +- VPS с публичным IP. +- На VPS уже должен слушать backend: + - WireGuard: обычно `127.0.0.1:51820/udp`; + - Xray/VLESS: обычно `127.0.0.1:443/tcp`. +- Ссылка на активный VK Calls вида `https://vk.com/call/join/...`. +- На клиенте: WireGuard или другой локальный клиент, который будет ходить в `127.0.0.1:9000`. + +## Быстрый Старт: WireGuard + +### 1. Запустите Сервер На VPS + +Скачайте бинарник для Linux amd64: + +```bash +curl -L -o server https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/server-linux-amd64 +chmod +x server +``` + +Запустите сервер, указав адрес WireGuard и пароль: + +```bash +./server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" +``` + +Порт `56000/udp` должен быть доступен снаружи. Если WireGuard слушает другой порт — замените `51820`. Пароль может быть любой строкой — главное, чтобы он совпадал на клиенте и сервере. + +### 2. Настройте WireGuard На Клиенте + +В клиентском конфиге WireGuard замените endpoint сервера на локальный адрес VK TURN Proxy: + +```ini +[Peer] +Endpoint = 127.0.0.1:9000 +# ... + +[Interface] +MTU = 1280 +# ... +``` + +### 3. Запустите Клиент + +Linux: + +```bash +curl -L -o client https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-linux-amd64 +chmod +x client +./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 +``` + +Windows PowerShell: + +```powershell +Invoke-WebRequest -Uri https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe +.\client.exe -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 +``` + +macOS: + +```bash +curl -L -o client https://github.com/MYSOREZ/vk-turn-proxy/releases/latest/download/client-darwin-arm64 +chmod +x client +./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 +``` + +После запуска клиента включите WireGuard. + +> `-vk` принимает как полную ссылку, так и только хеш (часть после `/call/join/`). Несколько ссылок через запятую: `-vk "ХЕШ1,ХЕШ2"`. + +## Android + +Скачайте бинарник `client-android-arm64` из Releases и запустите через Termux или встройте в своё приложение: + +```bash +termux-wake-lock +./client -peer 1.2.3.4:56000 -vk "https://vk.ru/call/join/ВАШ_ХЕШ" -password "ВАШ_ПАРОЛЬ" -listen 127.0.0.1:9000 -n 9 +``` + +В WireGuard укажите `Endpoint = 127.0.0.1:9000` и `MTU = 1280`. Добавьте Termux в исключения WireGuard (split tunneling). + +## Сервер Как systemd-Сервис + +Пример `/etc/systemd/system/vk-turn-proxy.service`: + +```ini +[Unit] +Description=VK TURN Proxy server +After=network.target + +[Service] +Type=simple +ExecStart=/opt/vk-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" +Restart=always +RestartSec=5 +User=nobody +Group=nogroup + +[Install] +WantedBy=multi-user.target +``` + +Применить: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now vk-turn-proxy.service +sudo systemctl status vk-turn-proxy.service +``` + +## Docker + +Образ публикуется в GitHub Container Registry: + +```bash +docker pull ghcr.io/mysorez/vk-turn-proxy:latest +``` + +Host network (рекомендуется, если backend на том же хосте): + +```bash +docker run --rm --network host \ + ghcr.io/mysorez/vk-turn-proxy:latest \ + -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -password "ВАШ_ПАРОЛЬ" +``` + +Bridge mode: + +```bash +docker run --rm -p 56000:56000/udp \ + ghcr.io/mysorez/vk-turn-proxy:latest \ + -listen 0.0.0.0:56000 -connect HOST_IP:51820 -wrap -password "ВАШ_ПАРОЛЬ" +``` + +## VLESS / Xray + +В режиме `-vless` `server` подключается к локальному TCP backend (например Xray). + +Сервер: + +```bash +./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -wrap -password "ВАШ_ПАРОЛЬ" +``` + +Клиент — для VLESS используйте отдельную сборку с поддержкой TCP (см. вашу конфигурацию). + +С bonding (несколько параллельных DTLS через одно TCP): + +```bash +./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -vless-bond -wrap -password "ВАШ_ПАРОЛЬ" +``` + +## Флаги Клиента + +| Флаг | По умолчанию | Описание | +| --- | --- | --- | +| `-peer` | **обязательный** | адрес сервера на VPS, например `1.2.3.4:56000` | +| `-vk` | **обязательный** | ссылка(и) VK Calls или хеш(и), через запятую | +| `-password` | **обязательный** | пароль для шифрования (совпадает на клиенте и сервере) | +| `-listen` | `127.0.0.1:9000` | локальный адрес для WireGuard или другого клиента | +| `-n` | `24` | количество воркеров, кратно 9 (минимум 9) | +| `-ping-only` | `false` | замерить RTT через TURN и выйти | +| `-device-id` | `unknown` | ID устройства (для логов) | +| `-captcha-mode` | `auto` | режим капчи: `auto`, `wv` (WebView), `rjs` (Go solver) | +| `-vk-auth` | `anonymous` | режим авторизации VK: `anonymous` или `account` | +| `-vk-anon-path` | `vkcalls` | анонимный путь получения TURN-кредов: `vkcalls` (основной, через api.vk.me) или `legacy` (старая цепочка через login.vk.ru/api.vk.ru) | +| `-vk-creds-file` | пусто | JSON-файл с TURN-кредами от аккаунта VK | +| `-stats-interval` | `30` | интервал статистики в секундах (`0` = выключить) | +| `-turn` | из ссылки | переопределить IP TURN-сервера | +| `-port` | из ссылки | переопределить порт TURN-сервера | + +## Флаги Сервера + +| Флаг | По умолчанию | Описание | +| --- | --- | --- | +| `-listen` | `0.0.0.0:56000` | адрес прослушивания | +| `-connect` | **обязательный** | backend-адрес: `127.0.0.1:51820` (WireGuard) или `127.0.0.1:443` (VLESS) | +| `-wrap` | `false` | включить RTP-AEAD обфускацию (требует `-password`) | +| `-password` | пусто | пароль для `-wrap`, ключ выводится через HKDF | +| `-vless` | `false` | TCP/VLESS режим | +| `-vless-bond` | `false` | bonding для VLESS | +| `-debug` | `false` | подробные логи | + +## Сборка Из Исходников + +Нужен Go 1.22+. + +```bash +go build -o client ./client +go build -o server ./server +``` + +Кросс-сборка для Linux amd64: + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./server +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-linux-amd64 ./client +``` + +Кросс-сборка для Android arm64 (требует NDK): + +```bash +export CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang +GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build -trimpath -ldflags "-s -w" -o client-android-arm64 ./client +``` + +## Решение Проблем + +- Сначала запускайте VK TURN Proxy client, потом включайте WireGuard. +- Убедитесь, что `-password` одинаковый на клиенте и сервере. +- Если соединение не устанавливается — проверьте, что порт `56000/udp` открыт на VPS (firewall, ufw). +- Если WireGuard забирает весь трафик — добавьте IP TURN-реле в исключения маршрутизации. +- Если соединение нестабильное — попробуйте уменьшить `-n 9` (минимум). +- Если VK просит капчу слишком часто — попробуйте сменить ссылку VK Calls. +- Если клиент завис на получении кредов — проверьте, что ссылка VK Calls живая. +- Для отладки добавьте `-stats-interval 5` на клиенте, `-debug` на сервере. + +## Похожие Проекты + +Авторы этого репозитория не отвечают за работу сторонних проектов. + +Server: + +- https://github.com/Urtyom-Alyanov/turn-proxy — реализация на Rust. +- https://github.com/NedgNDG/vk-proxy-auto-installer — автоустановщик VK TURN Proxy. + +Android: + +- https://github.com/samosvalishe/turn-proxy-android +- https://github.com/MYSOREZ/vk-turn-proxy-android +- https://github.com/kiper292/wireguard-turn-android +- https://github.com/WINGS-N/WINGSV +- https://github.com/oxsidee/vkpn +- https://github.com/amurcanov/proxy-turn-vk-android + +iOS: + +- https://github.com/nullcstring/turnbridge + +macOS: + +- https://github.com/denny4-user/vk-turn-proxy-macos-gui + +## Лицензия + +GPL-3.0. См. [LICENSE](LICENSE). + + + + + + Star History Chart + + From f32c60c467f099dd166bb411c9c446ebe30ac9d5 Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:54:11 +0300 Subject: [PATCH 5/6] Fix golangci-lint failures blocking CI - client/creds.go: drop the indent-error-flow else-block I introduced in the vkcalls-first fetchVkCreds change, and remove a dead assignment to lastErr in the DNS fallback dialer (SA4006). - client/captcha_v2.go: rename CaptchaTs/redirectUri/captchaTs to CaptchaTS/redirectURI/captchaTS (staticcheck ST1003 initialisms). - client/obfs.go, server/obfs.go: rename ObfsState.initTs to initTS (same ST1003 initialism issue, duplicated in both binaries). Verified locally with the repo's .golangci.yml: 0 issues on the client package. The initTs field is only referenced within obfs.go in each binary, so the rename is safe in both places. --- client/captcha_v2.go | 18 +++++++++--------- client/creds.go | 9 ++++----- client/obfs.go | 6 +++--- server/obfs.go | 6 +++--- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/client/captcha_v2.go b/client/captcha_v2.go index de7ee992..d4312544 100644 --- a/client/captcha_v2.go +++ b/client/captcha_v2.go @@ -631,7 +631,7 @@ type VkCaptchaError struct { CaptchaSid string RedirectURI string SessionToken string - CaptchaTs string + CaptchaTS string CaptchaAttempt string CaptchaImg string } @@ -663,7 +663,7 @@ func (e *VkCaptchaError) Error() string { func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { codeFloat, _ := errData["error_code"].(float64) - redirectUri, _ := errData["redirect_uri"].(string) + redirectURI, _ := errData["redirect_uri"].(string) errorMsg, _ := errData["error_msg"].(string) captchaImg, _ := errData["captcha_img"].(string) @@ -675,17 +675,17 @@ func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { } var sessionToken string - if redirectUri != "" { - if parsed, err := neturl.Parse(redirectUri); err == nil { + if redirectURI != "" { + if parsed, err := neturl.Parse(redirectURI); err == nil { sessionToken = parsed.Query().Get("session_token") } } - var captchaTs string + var captchaTS string if tsFloat, ok := errData["captcha_ts"].(float64); ok { - captchaTs = fmt.Sprintf("%.0f", tsFloat) + captchaTS = fmt.Sprintf("%.0f", tsFloat) } else if tsStr, ok := errData["captcha_ts"].(string); ok { - captchaTs = tsStr + captchaTS = tsStr } var captchaAttempt string @@ -700,9 +700,9 @@ func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { ErrorMsg: errorMsg, CaptchaSid: captchaSid, CaptchaImg: captchaImg, - RedirectURI: redirectUri, + RedirectURI: redirectURI, SessionToken: sessionToken, - CaptchaTs: captchaTs, + CaptchaTS: captchaTS, CaptchaAttempt: captchaAttempt, } } diff --git a/client/creds.go b/client/creds.go index 6396e269..63d1b664 100644 --- a/client/creds.go +++ b/client/creds.go @@ -249,12 +249,12 @@ func fetchVkCreds(ctx context.Context, link string, streamID int) (string, strin } if getVkAnonPath() == "vkcalls" { - if user, pass, addrs, err := getVKCredsViaVKCallsPath(ctx, link, streamID); err == nil { + user, pass, addrs, err := getVKCredsViaVKCallsPath(ctx, link, streamID) + if err == nil { log.Printf("[STREAM %d] [VK Auth] Success via VK Calls path", streamID) return user, pass, addrs, nil - } else { - log.Printf("[STREAM %d] [VK Auth] VK Calls path failed (%s), falling back to legacy", streamID, describeVKCallsFailure(err)) } + log.Printf("[STREAM %d] [VK Auth] VK Calls path failed (%s), falling back to legacy", streamID, describeVKCallsFailure(err)) } else { log.Printf("[STREAM %d] [VK Auth] Legacy path selected, skipping VK Calls", streamID) } @@ -428,7 +428,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede } 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, captchaAttempt, token1) + link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTS, captchaAttempt, token1) continue } return "", "", nil, fmt.Errorf("VK API error: %v", errObj) @@ -689,7 +689,6 @@ func setupGlobalResolver() { if err == nil { return conn, nil } - lastErr = err conn, err = dialer.DialContext(ctx, "tcp", dns) if err == nil { return conn, nil diff --git a/client/obfs.go b/client/obfs.go index 56af87eb..21efc36d 100644 --- a/client/obfs.go +++ b/client/obfs.go @@ -62,7 +62,7 @@ func NewObfsConfig() *ObfsConfig { type ObfsState struct { mu sync.Mutex initSeq uint16 - initTs uint32 + initTS uint32 count uint64 } @@ -72,7 +72,7 @@ func NewObfsState() *ObfsState { rand.Read(buf[:]) return &ObfsState{ initSeq: binary.BigEndian.Uint16(buf[0:2]), - initTs: binary.BigEndian.Uint32(buf[2:6]), + initTS: binary.BigEndian.Uint32(buf[2:6]), count: 0, } } @@ -101,7 +101,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b state.mu.Unlock() seq := state.initSeq + uint16(c) - ts := state.initTs + uint32(c)*960 + uint32(c>>16) + ts := state.initTS + uint32(c)*960 + uint32(c>>16) nonce := obfsBuildNonce(cfg.SSRC, seq, ts) diff --git a/server/obfs.go b/server/obfs.go index 56af87eb..21efc36d 100644 --- a/server/obfs.go +++ b/server/obfs.go @@ -62,7 +62,7 @@ func NewObfsConfig() *ObfsConfig { type ObfsState struct { mu sync.Mutex initSeq uint16 - initTs uint32 + initTS uint32 count uint64 } @@ -72,7 +72,7 @@ func NewObfsState() *ObfsState { rand.Read(buf[:]) return &ObfsState{ initSeq: binary.BigEndian.Uint16(buf[0:2]), - initTs: binary.BigEndian.Uint32(buf[2:6]), + initTS: binary.BigEndian.Uint32(buf[2:6]), count: 0, } } @@ -101,7 +101,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b state.mu.Unlock() seq := state.initSeq + uint16(c) - ts := state.initTs + uint32(c)*960 + uint32(c>>16) + ts := state.initTS + uint32(c)*960 + uint32(c>>16) nonce := obfsBuildNonce(cfg.SSRC, seq, ts) From 0933c22072fed583c2593f012674fb7675c99c02 Mon Sep 17 00:00:00 2001 From: MYSOREZ <69631925+MYSOREZ@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:09:36 +0300 Subject: [PATCH 6/6] Allow manual version input for releases Adds a workflow_dispatch "version" input (e.g. "1.2.3" or "v1.2.3"). When set, it's used as the release tag as-is instead of the automatic semver bump derived from conventional-commit prefixes; leaving it empty keeps the existing automatic behavior. Rejects malformed input and versions that collide with the latest existing tag. --- .github/workflows/release.yml | 66 ++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b99bfe77..491f1679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: branches: - main workflow_dispatch: + inputs: + version: + description: 'Manual release version (e.g. "1.2.3" or "v1.2.3"). Leave empty for automatic semver bump from commit messages.' + required: false + type: string jobs: build: @@ -193,6 +198,8 @@ jobs: - name: Prepare release metadata id: meta + env: + MANUAL_VERSION: ${{ github.event.inputs.version }} run: | LATEST_TAG="$(git tag -l | grep -E '^(v)?[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" PREFIX="" @@ -205,33 +212,50 @@ jobs: BASE_VERSION="${LATEST_TAG#v}" fi - IFS=. read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" - if [ -n "$LATEST_TAG" ]; then RANGE="$LATEST_TAG..HEAD" else RANGE="HEAD" fi - COMMITS="$(git log --no-merges --format='%s' "$RANGE")" - BUMP="" - - if printf '%s\n' "$COMMITS" | grep -Eiq '^break([(:! ]|$)'; then - BUMP="major" - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - elif printf '%s\n' "$COMMITS" | grep -Eiq '^feat([(:! ]|$)'; then - BUMP="minor" - MINOR=$((MINOR + 1)) - PATCH=0 - elif printf '%s\n' "$COMMITS" | grep -Eiq '^(fix|perf|refactor)([(:! ]|$)'; then - BUMP="patch" - PATCH=$((PATCH + 1)) - fi + if [ -n "$MANUAL_VERSION" ]; then + # Manual override: use the version given via workflow_dispatch as-is, + # validated against a plain (v)X.Y.Z shape. Skips the conventional-commit + # bump detection below entirely. + CANDIDATE="$(printf '%s' "$MANUAL_VERSION" | tr -d '[:space:]')" + if ! printf '%s' "$CANDIDATE" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version input '$MANUAL_VERSION' — expected format X.Y.Z or vX.Y.Z" + exit 1 + fi + if [ "$CANDIDATE" = "$LATEST_TAG" ]; then + echo "::error::Version '$CANDIDATE' matches the latest existing tag; choose a new version" + exit 1 + fi - NEXT_VERSION="${MAJOR}.${MINOR}.${PATCH}" - NEXT_TAG="${PREFIX}${NEXT_VERSION}" + NEXT_TAG="$CANDIDATE" + BUMP="manual" + else + IFS=. read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" + + COMMITS="$(git log --no-merges --format='%s' "$RANGE")" + BUMP="" + + if printf '%s\n' "$COMMITS" | grep -Eiq '^break([(:! ]|$)'; then + BUMP="major" + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif printf '%s\n' "$COMMITS" | grep -Eiq '^feat([(:! ]|$)'; then + BUMP="minor" + MINOR=$((MINOR + 1)) + PATCH=0 + elif printf '%s\n' "$COMMITS" | grep -Eiq '^(fix|perf|refactor)([(:! ]|$)'; then + BUMP="patch" + PATCH=$((PATCH + 1)) + fi + + NEXT_TAG="${PREFIX}${MAJOR}.${MINOR}.${PATCH}" + fi { echo "previous_tag=$LATEST_TAG" @@ -306,7 +330,7 @@ jobs: if: steps.meta.outputs.should_release != 'true' run: | echo "No new semantic release created." - echo "Add a commit starting with break, feat, or fix after the latest tag." + echo "Add a commit starting with break, feat, or fix after the latest tag, or re-run this workflow with a manual version input." docker: name: Publish Docker image