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 diff --git a/README.md b/README.md index 64665084..41370f99 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ docker run --rm -p 56000:56000/udp \ | `-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-сервера | diff --git a/client/captcha_v2.go b/client/captcha_v2.go index 3c27d83d..d4312544 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") } @@ -606,14 +631,39 @@ type VkCaptchaError struct { CaptchaSid string RedirectURI string SessionToken string - CaptchaTs string + CaptchaTS string CaptchaAttempt string 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) + redirectURI, _ := errData["redirect_uri"].(string) errorMsg, _ := errData["error_msg"].(string) captchaImg, _ := errData["captcha_img"].(string) @@ -625,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 @@ -650,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/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"]) diff --git a/client/creds.go b/client/creds.go index d59e03b0..63d1b664 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" { + 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 + } + 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), @@ -413,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) @@ -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++ { @@ -662,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/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/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/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: 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)