From 305754cd6037fe3b4ec5558b826a89eefc64834b Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Mon, 13 Jul 2026 21:24:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(captcha):=20persona-=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=85=D0=B2=D0=B0=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8F=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/client/main.go | 1 + internal/config/config.go | 19 ++ internal/config/config_test.go | 29 +++ .../vk/internal/browserprofile/profile.go | 187 +++++++++++------- .../internal/browserprofile/profile_test.go | 106 ++++++---- .../provider/vk/internal/captcha/captcha.go | 86 +++++--- .../vk/internal/captcha/captcha_test.go | 10 +- .../vk/internal/captcha/manual/manual.go | 41 +--- .../provider/vk/internal/captcha/slider.go | 9 +- .../vk/internal/vkauth/auto_solver.go | 40 +--- internal/provider/vk/internal/vkauth/cache.go | 6 - .../provider/vk/internal/vkauth/client.go | 36 ++-- internal/provider/vk/internal/vkauth/dial.go | 20 +- .../provider/vk/internal/vkauth/solver.go | 6 - .../vk/internal/vkauth/solver_test.go | 37 ---- internal/provider/vk/internal/vkauth/token.go | 4 +- .../provider/vk/internal/vkauth/token_call.go | 1 - .../vk/internal/vkauth/token_request.go | 2 - internal/provider/vk/internal/vkauth/types.go | 28 --- internal/provider/vk/vk.go | 30 +-- 20 files changed, 322 insertions(+), 376 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 86a20b5..e98120d 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -201,6 +201,7 @@ func buildProvider(cfg *config.Client, dialer net.Dialer, connected *atomic.Int3 Dialer: dialer, ManualOnly: cfg.VK.ManualCaptcha, Browser: string(cfg.VK.Browser), + Platform: string(cfg.VK.Platform), StreamsPerCache: cfg.VK.StreamsPerCred, StreamsAlive: connected.Load, Log: logger, diff --git a/internal/config/config.go b/internal/config/config.go index 10d0ff5..151589a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,12 +87,21 @@ const ( BrowserSafari Browser = "safari" ) +// Platform выбирает класс устройства персоны (мобильность UA/device/client hints). +type Platform string + +const ( + PlatformDesktop Platform = "desktop" + PlatformMobile Platform = "mobile" +) + // VKOpts - опции VK-учёток и captcha (только клиент, провайдер "vk"). type VKOpts struct { Links []string // -links (нормализованные join-коды); несколько = больше стримов StreamsPerCred int // -streams-per-cred ManualCaptcha bool // -manual-captcha Browser Browser // -browser: chrome | firefox | safari + Platform Platform // -platform: desktop | mobile } // ProviderOpts выбирает реализацию provider.Provider. @@ -192,6 +201,7 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { debug := fs.Bool("debug", false, "подробные debug-логи") manualCaptcha := fs.Bool("manual-captcha", false, "ручная VK captcha в браузере вместо авто; только -provider vk") browser := fs.String("browser", string(BrowserFirefox), "браузерный профиль VK-auth: chrome | firefox | safari; только -provider vk") + platform := fs.String("platform", string(PlatformDesktop), "класс устройства персоны VK-auth: desktop | mobile; только -provider vk") dnsMode := fs.String("dns-mode", dnsModeAuto, "резолвер клиента: plain | doh | auto") dnsServers := fs.String("dns-servers", "", "свои UDP/53 DNS через запятую: ip[:port][,ip[:port]...]") clientID := fs.String("client-id", "", "уникальный ID клиента (автогенерация если не задан)") @@ -225,6 +235,7 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { StreamsPerCred: *streamsPerCred, ManualCaptcha: *manualCaptcha, Browser: Browser(*browser), + Platform: Platform(*platform), }, DNS: DNSOpts{ Mode: *dnsMode, @@ -339,6 +350,14 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { default: return nil, fmt.Errorf("invalid -browser value %q: must be %s | %s | %s", c.VK.Browser, BrowserChrome, BrowserFirefox, BrowserSafari) } + if c.VK.Platform == "" { + c.VK.Platform = PlatformDesktop + } + switch c.VK.Platform { + case PlatformDesktop, PlatformMobile: + default: + return nil, fmt.Errorf("invalid -platform value %q: must be %s | %s", c.VK.Platform, PlatformDesktop, PlatformMobile) + } rawLinks := strings.Split(*links, ",") if len(rawLinks) == 1 && rawLinks[0] == "" { // -links не задан, используем -link (backward compat) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1bc88c4..7e79a4a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -134,6 +134,35 @@ func TestParseClient_BrowserInvalid(t *testing.T) { } } +func TestParseClient_PlatformDefaultDesktop(t *testing.T) { + c, err := ParseClient(validClientArgs(), io.Discard) + if err != nil { + t.Fatal(err) + } + if c.VK.Platform != PlatformDesktop { + t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformDesktop) + } +} + +func TestParseClient_PlatformMobile(t *testing.T) { + args := append(validClientArgs(), "-platform", "mobile") + c, err := ParseClient(args, io.Discard) + if err != nil { + t.Fatal(err) + } + if c.VK.Platform != PlatformMobile { + t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformMobile) + } +} + +func TestParseClient_PlatformInvalid(t *testing.T) { + args := append(validClientArgs(), "-platform", "tablet") + _, err := ParseClient(args, io.Discard) + if err == nil || !strings.Contains(err.Error(), "invalid -platform") { + t.Errorf("expected platform error, got %v", err) + } +} + func TestParseClient_BondWithoutTCPMode(t *testing.T) { args := append(validClientArgs(), "-bond") _, err := ParseClient(args, io.Discard) diff --git a/internal/provider/vk/internal/browserprofile/profile.go b/internal/provider/vk/internal/browserprofile/profile.go index 81052f7..330b16c 100644 --- a/internal/provider/vk/internal/browserprofile/profile.go +++ b/internal/provider/vk/internal/browserprofile/profile.go @@ -1,14 +1,10 @@ package browserprofile import ( - "encoding/json" - "os" - "strings" - fhttp "github.com/bogdanfinn/fhttp" + "github.com/bogdanfinn/tls-client/profiles" ) -// Kind - семейство браузера. type Kind string const ( @@ -17,8 +13,15 @@ const ( Safari Kind = "safari" ) -// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное - -// Firefox (текущий дефолт продукта). +type Platform string + +const ( + Desktop Platform = "desktop" + Mobile Platform = "mobile" +) + +// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное - Firefox +// (дефолт продукта). func KindFromString(s string) Kind { switch s { case string(Chrome): @@ -29,95 +32,139 @@ func KindFromString(s string) Kind { return Firefox } +// PlatformFromString мапит строку флага -platform в Platform. Пустое/неизвестное - +// Desktop. +func PlatformFromString(s string) Platform { + if s == string(Mobile) { + return Mobile + } + return Desktop +} + +// Profile - самосогласованная браузерная личность: единственный источник UA, +// client hints, device-fingerprint и accept-language для VK control-plane и captcha. +// Строится один раз из (Family, Platform); не мутируется в рантайме. type Profile struct { + Family Kind + Platform Platform UserAgent string - SecChUa string // пусто для non-Chromium профилей (Chromium-only client hint) + SecChUa string // пусто для не-Chromium (Chromium-only client hint) SecChUaMobile string SecChUaPlatform string AcceptLanguage string + // DeviceJSON - navigator/screen fingerprint для captcha componentDone. + // Согласован с Family/Platform (Chromium-only поля есть только у Chrome). + DeviceJSON string } -// ForKind возвращает канонический профиль для браузера. -func ForKind(k Kind) Profile { +const ( + deviceChromeDesktop = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":16,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` + deviceChromeMobile = `{"screenWidth":393,"screenHeight":852,"screenAvailWidth":393,"screenAvailHeight":852,"innerWidth":393,"innerHeight":659,"devicePixelRatio":3,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` + deviceFirefoxDesktop = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU","ru"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}` + deviceFirefoxMobile = `{"screenWidth":393,"screenHeight":852,"screenAvailWidth":393,"screenAvailHeight":852,"innerWidth":393,"innerHeight":659,"devicePixelRatio":3,"language":"ru-RU","languages":["ru-RU","ru"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}` + deviceSafariDesktop = `{"screenWidth":1512,"screenHeight":982,"screenAvailWidth":1512,"screenAvailHeight":944,"innerWidth":1147,"innerHeight":870,"devicePixelRatio":2,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}` + deviceSafariMobile = `{"screenWidth":393,"screenHeight":852,"screenAvailWidth":393,"screenAvailHeight":852,"innerWidth":393,"innerHeight":659,"devicePixelRatio":3,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":4,"notificationsPermission":"denied"}` +) + +// For строит канонический профиль для (family, platform). Неизвестный family -> Chrome. +func For(k Kind, p Platform) Profile { + if p != Mobile { + p = Desktop + } switch k { case Safari: - return Profile{ - UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", - AcceptLanguage: "ru-RU,ru;q=0.9", - } + return safariProfile(p) case Firefox: + return firefoxProfile(p) + default: + return chromeProfile(p) + } +} + +func chromeProfile(p Platform) Profile { + if p == Mobile { return Profile{ - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0", - AcceptLanguage: "ru-RU,ru;q=0.9", + Family: Chrome, + Platform: Mobile, + UserAgent: "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36", + SecChUa: `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`, + SecChUaMobile: "?1", + SecChUaPlatform: `"Android"`, + AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceChromeMobile, } } return Profile{ + Family: Chrome, + Platform: Desktop, 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: `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`, SecChUaMobile: "?0", SecChUaPlatform: `"Windows"`, AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceChromeDesktop, } } -func Family(p Profile) Kind { - ua := strings.ToLower(p.UserAgent) - switch { - case strings.Contains(ua, "firefox/"): - return Firefox - case strings.Contains(ua, "version/") && strings.Contains(ua, "safari/") && !strings.Contains(ua, "chrome/") && !strings.Contains(ua, "chromium/"): - return Safari - case strings.Contains(ua, "chrome/") || strings.Contains(ua, "chromium/"): - return Chrome +func firefoxProfile(p Platform) Profile { + if p == Mobile { + return Profile{ + Family: Firefox, + Platform: Mobile, + UserAgent: "Mozilla/5.0 (Android 14; Mobile; rv:148.0) Gecko/148.0 Firefox/148.0", + AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceFirefoxMobile, + } + } + return Profile{ + Family: Firefox, + Platform: Desktop, + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0", + AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceFirefoxDesktop, + } +} + +func safariProfile(p Platform) Profile { + if p == Mobile { + return Profile{ + Family: Safari, + Platform: Mobile, + UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceSafariMobile, + } + } + return Profile{ + Family: Safari, + Platform: Desktop, + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", + AcceptLanguage: "ru-RU,ru;q=0.9", + DeviceJSON: deviceSafariDesktop, + } +} + +func Family(p Profile) Kind { return p.Family } + +func IsMobile(p Profile) bool { return p.Platform == Mobile } + +// ClientProfile - TLS/HTTP2-отпечаток персоны (JA3 + ALPN + h2 settings). +// Chrome/Firefox используют один TLS-стек на всех платформах, поэтому platform на +// них не влияет; Safari desktop и iOS различаются в ClientHello. +func (p Profile) ClientProfile() profiles.ClientProfile { + switch p.Family { + case Safari: + if p.Platform == Mobile { + return profiles.Safari_IOS_17_0 + } + return profiles.Safari_16_0 + case Firefox: + return profiles.Firefox_148 default: - return "" + return profiles.Chrome_146 } } -func IsMobile(p Profile) bool { - ua := strings.ToLower(p.UserAgent) - return strings.Contains(ua, " mobile ") || - strings.Contains(ua, " mobile/") || - strings.Contains(ua, "android") || - strings.Contains(strings.ToLower(p.SecChUaMobile), "?1") -} - -type Saved struct { - Profile - DeviceJSON string - BrowserFp string -} - -const profileFile = "vk_profile.json" - -// profilePath возвращает путь к сохранённому профилю. -func profilePath() string { - if p := strings.TrimSpace(os.Getenv("VK_PROFILE_PATH")); p != "" { - return p - } - return profileFile -} - -func Load() (*Saved, error) { - data, err := os.ReadFile(profilePath()) - if err != nil { - return nil, err - } - var sp Saved - if err := json.Unmarshal(data, &sp); err != nil { - return nil, err - } - return &sp, nil -} - -func Save(sp Saved) error { - data, err := json.MarshalIndent(sp, "", " ") - if err != nil { - return err - } - return os.WriteFile(profilePath(), data, 0600) -} - func ApplyFhttp(req *fhttp.Request, profile Profile) { req.Header.Set("User-Agent", profile.UserAgent) if profile.SecChUa != "" { diff --git a/internal/provider/vk/internal/browserprofile/profile_test.go b/internal/provider/vk/internal/browserprofile/profile_test.go index 5b268a1..be45301 100644 --- a/internal/provider/vk/internal/browserprofile/profile_test.go +++ b/internal/provider/vk/internal/browserprofile/profile_test.go @@ -1,11 +1,11 @@ package browserprofile import ( - "os" - "path/filepath" + "strings" "testing" fhttp "github.com/bogdanfinn/fhttp" + "github.com/bogdanfinn/tls-client/profiles" ) func TestKindFromString(t *testing.T) { @@ -26,29 +26,25 @@ func TestKindFromString(t *testing.T) { } } -func TestProfilePathHonorsEnv(t *testing.T) { - path := filepath.Join(t.TempDir(), "vk_profile_chrome_42.json") - t.Setenv("VK_PROFILE_PATH", path) - - want := Saved{Profile: ForKind(Chrome), DeviceJSON: "{}", BrowserFp: "abc123"} - if err := Save(want); err != nil { - t.Fatal(err) +func TestPlatformFromString(t *testing.T) { + cases := []struct { + in string + want Platform + }{ + {"mobile", Mobile}, + {"desktop", Desktop}, + {"", Desktop}, + {"unknown", Desktop}, } - if _, err := os.Stat(path); err != nil { - t.Fatalf("profile not written to VK_PROFILE_PATH %s: %v", path, err) - } - - got, err := Load() - if err != nil { - t.Fatal(err) - } - if got.BrowserFp != want.BrowserFp || got.UserAgent != want.UserAgent { - t.Fatalf("loaded = %+v, want fp=%s ua=%s", got, want.BrowserFp, want.UserAgent) + for _, tc := range cases { + if got := PlatformFromString(tc.in); got != tc.want { + t.Fatalf("PlatformFromString(%q) = %q, want %q", tc.in, got, tc.want) + } } } -func TestForKindChrome146(t *testing.T) { - p := ForKind(Chrome) +func TestForChromeDesktop146(t *testing.T) { + p := For(Chrome, Desktop) if p.UserAgent != "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" { t.Fatalf("unexpected chrome UA: %q", p.UserAgent) } @@ -63,35 +59,67 @@ func TestApplyFhttpOmitsClientHintsForFirefoxAndSafari(t *testing.T) { if err != nil { t.Fatal(err) } - ApplyFhttp(req, ForKind(kind)) + ApplyFhttp(req, For(kind, Desktop)) if req.Header.Get("sec-ch-ua") != "" { t.Fatalf("%s sec-ch-ua = %q, want empty", kind, req.Header.Get("sec-ch-ua")) } - if req.Header.Get("DNT") != "" { - t.Fatalf("%s DNT = %q, want empty", kind, req.Header.Get("DNT")) - } } } -func TestFamily(t *testing.T) { +func TestFamilyAndPlatformSet(t *testing.T) { for _, kind := range []Kind{Chrome, Firefox, Safari} { - if got := Family(ForKind(kind)); got != kind { - t.Fatalf("Family(%s profile) = %q, want %q", kind, got, kind) + for _, plat := range []Platform{Desktop, Mobile} { + p := For(kind, plat) + if Family(p) != kind { + t.Fatalf("Family(For(%s,%s)) = %q, want %q", kind, plat, Family(p), kind) + } + if IsMobile(p) != (plat == Mobile) { + t.Fatalf("IsMobile(For(%s,%s)) = %v", kind, plat, IsMobile(p)) + } } } - if got := Family(Profile{UserAgent: "curl/8.0"}); got != "" { - t.Fatalf("Family(unknown) = %q, want empty", got) - } } -func TestIsMobile(t *testing.T) { - if IsMobile(ForKind(Chrome)) { - t.Fatal("desktop chrome detected as mobile") +// Персона обязана быть самосогласованной: мобильная платформа -> мобильный UA, +// мобильный sec-ch-ua (для chromium) и непустой device для captcha. +func TestPersonaSelfConsistent(t *testing.T) { + for _, kind := range []Kind{Chrome, Firefox, Safari} { + p := For(kind, Mobile) + ua := strings.ToLower(p.UserAgent) + if !strings.Contains(ua, "mobile") && !strings.Contains(ua, "iphone") && !strings.Contains(ua, "android") { + t.Fatalf("%s mobile UA not mobile: %q", kind, p.UserAgent) + } + if p.DeviceJSON == "" { + t.Fatalf("%s mobile persona has empty DeviceJSON", kind) + } + if kind == Chrome && p.SecChUaMobile != "?1" { + t.Fatalf("chrome mobile sec-ch-ua-mobile = %q, want ?1", p.SecChUaMobile) + } } - if !IsMobile(Profile{UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36"}) { - t.Fatal("android chrome was not detected as mobile") - } - if !IsMobile(Profile{UserAgent: ForKind(Chrome).UserAgent, SecChUaMobile: "?1"}) { - t.Fatal("sec-ch-ua-mobile ?1 was not detected as mobile") + if For(Chrome, Desktop).SecChUaMobile != "?0" { + t.Fatalf("chrome desktop sec-ch-ua-mobile != ?0") + } +} + +// TLS-отпечаток - часть персоны: Safari desktop/iOS различаются, Chrome/Firefox +// одинаковы на всех платформах (один TLS-стек). +func TestClientProfilePlatformAware(t *testing.T) { + cases := []struct { + family Kind + plat Platform + want profiles.ClientProfile + }{ + {Safari, Desktop, profiles.Safari_16_0}, + {Safari, Mobile, profiles.Safari_IOS_17_0}, + {Chrome, Desktop, profiles.Chrome_146}, + {Chrome, Mobile, profiles.Chrome_146}, + {Firefox, Desktop, profiles.Firefox_148}, + {Firefox, Mobile, profiles.Firefox_148}, + } + for _, tc := range cases { + got := For(tc.family, tc.plat).ClientProfile() + if got.GetClientHelloStr() != tc.want.GetClientHelloStr() { + t.Fatalf("For(%s,%s).ClientProfile() = %s, want %s", tc.family, tc.plat, got.GetClientHelloStr(), tc.want.GetClientHelloStr()) + } } } diff --git a/internal/provider/vk/internal/captcha/captcha.go b/internal/provider/vk/internal/captcha/captcha.go index be5e782..36fbcdd 100644 --- a/internal/provider/vk/internal/captcha/captcha.go +++ b/internal/provider/vk/internal/captcha/captcha.go @@ -17,6 +17,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" fhttp "github.com/bogdanfinn/fhttp" @@ -27,30 +28,32 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/randx" ) -// Log - пакетный логгер. var Log logx.Logger = logx.Nop() -// SetLogger ставит логгер пакета. func SetLogger(l logx.Logger) { Log = logx.OrNop(l) } const ( captchaAPIVersion = "5.131" captchaAPIOrigin = "https://id.vk.ru" captchaDomain = "vk.ru" - captchaDeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":16,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` captchaConnectionSamples = 4 ) var ( reCaptchaPowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) reCaptchaDifficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`) - reCaptchaDebugUUID = regexp.MustCompile(`[a-zA-Z0-9_]{6,}:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"`) + reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`) + reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`) errCaptchaRateLimit = errors.New("captcha session rate limit reached") errCaptchaBot = errors.New("captcha bot challenge") captchaMaxAttempts = 2 + // debugInfoCache кэширует 64-hex debug_info по URL JS-бандла (константа виджета, + // одна на всех). + debugInfoCache sync.Map + captchaHeaderOrder = []string{ "host", "content-length", @@ -81,7 +84,7 @@ type captchaInitSetting struct { type captchaPage struct { PowInput string PowDifficulty int - DebugInfo string + ScriptURL string } type captchaCheck struct { @@ -99,12 +102,11 @@ func (e *captchaShowTypeError) Error() string { } type captchaSession struct { - ctx context.Context - client tlsclient.HttpClient - profile browserprofile.Profile - savedProfile *browserprofile.Saved - domain string - log logx.Logger + ctx context.Context + client tlsclient.HttpClient + profile browserprofile.Profile + domain string + log logx.Logger } func (s *captchaSession) logger() logx.Logger { @@ -121,7 +123,6 @@ func Solve( streamID int, client tlsclient.HttpClient, profile browserprofile.Profile, - savedProfile *browserprofile.Saved, log logx.Logger, ) (string, error) { if captchaErr == nil || captchaErr.SessionToken == "" { @@ -130,7 +131,7 @@ func Solve( l := logx.OrNop(log) l.Infof("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically...", streamID) - s := &captchaSession{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile, domain: captchaDomain, log: l} + s := &captchaSession{ctx: ctx, client: client, profile: profile, domain: captchaDomain, log: l} for attempt := 1; attempt <= captchaMaxAttempts; attempt++ { token, solveErr := s.solveOnce(captchaErr) @@ -176,19 +177,12 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) { return "", errors.New("captcha pow failed") } - debugInfo := page.DebugInfo - if debugInfo == "" { - debugInfo = newCaptchaUUID() - s.logger().Warnf("[Captcha] debug_info uuid not in HTML; generated fallback") - } + debugInfo := s.resolveDebugInfo(page.ScriptURL) browserFP, err := captchaBrowserFP() if err != nil { return "", err } - if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" { - browserFP = s.savedProfile.BrowserFp - } base := s.captchaBaseValues(captchaErr.SessionToken) if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil { @@ -255,7 +249,6 @@ func newCaptchaUUID() string { return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } -// captchaContentRef несёт значение настроек слайдера. type captchaContentRef struct { Source string Value string @@ -313,6 +306,42 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) { return string(body), nil } +func (s *captchaSession) resolveDebugInfo(scriptURL string) string { + if scriptURL != "" { + if v, err := s.fetchDebugInfoJS(scriptURL); err == nil { + s.logger().Debugf("[Captcha] debug_info from JS len=%d", len(v)) + return v + } else { + s.logger().Warnf("[Captcha] debug_info JS fetch failed: %v; using UUID fallback", err) + } + } else { + s.logger().Warnf("[Captcha] captcha script URL not in HTML; using UUID fallback") + } + return newCaptchaUUID() +} + +func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) { + if cached, ok := debugInfoCache.Load(scriptURL); ok { + if v, ok := cached.(string); ok { + return v, nil + } + } + body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{ + "Accept": "text/javascript,*/*", + "Referer": captchaAPIOrigin + "/", + }) + if err != nil { + return "", err + } + m := reCaptchaDebugInfo.FindSubmatch(body) + if len(m) < 2 { + return "", errors.New("debug_info constant not found in JS") + } + v := string(m[1]) + debugInfoCache.Store(scriptURL, v) + return v, nil +} + func (s *captchaSession) dumpChunks(label, body string) { const chunk = 1800 total := (len(body) + chunk - 1) / chunk @@ -328,8 +357,8 @@ func (s *captchaSession) dumpChunks(label, body string) { func parseCaptchaPage(html string) (*captchaPage, error) { page := &captchaPage{} - if m := reCaptchaDebugUUID.FindStringSubmatch(html); len(m) >= 2 { - page.DebugInfo = m[1] + if m := reCaptchaScriptSrc.FindStringSubmatch(html); len(m) >= 2 { + page.ScriptURL = m[1] } m := reCaptchaPowInput.FindStringSubmatch(html) @@ -442,13 +471,8 @@ func (s *captchaSession) solveCheckboxCaptcha( hash string, debugInfo string, ) (string, error) { - deviceJSON := captchaDeviceInfo - deviceSource := "default" - if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { - deviceJSON = s.savedProfile.DeviceJSON - deviceSource = "saved" - } - s.logger().Debugf("[Captcha] checkbox componentDone device_source=%s device_bytes=%d", deviceSource, len(deviceJSON)) + deviceJSON := s.profile.DeviceJSON + s.logger().Debugf("[Captcha] checkbox componentDone device_bytes=%d", len(deviceJSON)) if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ {"session_token", sessionToken}, {"domain", s.domain}, diff --git a/internal/provider/vk/internal/captcha/captcha_test.go b/internal/provider/vk/internal/captcha/captcha_test.go index 66ac25e..b761598 100644 --- a/internal/provider/vk/internal/captcha/captcha_test.go +++ b/internal/provider/vk/internal/captcha/captcha_test.go @@ -38,9 +38,9 @@ func TestParseCaptchaPageSPA(t *testing.T) { html := `
- -` + + +
` page, err := parseCaptchaPage(html) if err != nil { @@ -49,8 +49,8 @@ const difficulty = 2; if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 { t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty) } - if page.DebugInfo != "1d958dbe-0663-42f2-95e5-0e2780ed2f93" { - t.Fatalf("debug_info = %q, want brlefapmjnpg uuid", page.DebugInfo) + if page.ScriptURL != "https://id.vk.ru/static/vkid/1.1.1374/not_robot_captcha.js" { + t.Fatalf("script url = %q", page.ScriptURL) } } diff --git a/internal/provider/vk/internal/captcha/manual/manual.go b/internal/provider/vk/internal/captcha/manual/manual.go index c934406..16524fd 100644 --- a/internal/provider/vk/internal/captcha/manual/manual.go +++ b/internal/provider/vk/internal/captcha/manual/manual.go @@ -1,8 +1,3 @@ -// Package manual реализует ручное решение CAPTCHA - fallback, когда автосолвер -// (internal/provider/vk/internal/captcha) не справился или пользователь дал -manual-captcha. -// Поднимает локальный HTTP-сервер 127.0.0.1:8765, проксирующий страницу VK -// CAPTCHA (с переписыванием абсолютных URL и gzip), открывает её в браузере и -// ждёт токен/ключ после решения пользователем. package manual import ( @@ -26,18 +21,13 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/client/ish" "github.com/samosvalishe/free-turn-proxy/internal/logx" - "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" ) -// Debug включает подробное логирование request/response проксируемого -// браузерного трафика. Ставится из main после разбора cfg. +// Debug включает логирование проксируемого браузерного трафика. var Debug bool -// Log - пакетный логгер; по умолчанию no-op. main устанавливает его через -// SetLogger, чтобы вывод подчинялся -debug. var Log logx.Logger = logx.Nop() -// SetLogger ставит логгер пакета. func SetLogger(l logx.Logger) { Log = logx.OrNop(l) } const captchaListenPort = "8765" @@ -431,35 +421,6 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) Log.Debugf("[Captcha Proxy] header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", ")) } } - - if strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") || strings.Contains(req.URL.Path, "captchaNotRobot.check") { - parsedBody, err := neturl.ParseQuery(string(b)) - if err != nil { - Log.Warnf("[Captcha Proxy] failed to parse request body: %v", err) - } - device := parsedBody.Get("device") - browserFp := parsedBody.Get("browser_fp") - - // сохраняем только если есть device. componentDone обычно его содержит. - if device != "" && browserFp != "" { - sp := browserprofile.Saved{ - Profile: browserprofile.Profile{ - UserAgent: req.Header.Get("User-Agent"), - SecChUa: req.Header.Get("Sec-Ch-Ua"), - SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"), - SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"), - AcceptLanguage: req.Header.Get("Accept-Language"), - }, - DeviceJSON: device, - BrowserFp: browserFp, - } - if err := browserprofile.Save(sp); err != nil { - Log.Warnf("[Captcha Proxy] failed to save browser profile: %v", err) - } else { - Log.Infof("[Captcha Proxy] saved real browser profile") - } - } - } } return t.rt.RoundTrip(req) } diff --git a/internal/provider/vk/internal/captcha/slider.go b/internal/provider/vk/internal/captcha/slider.go index 4978cdb..92727b9 100644 --- a/internal/provider/vk/internal/captcha/slider.go +++ b/internal/provider/vk/internal/captcha/slider.go @@ -80,13 +80,8 @@ func (s *captchaSession) solveSliderCaptcha( } s.logger().Debugf("[Captcha] slider guesses ranked: total=%d limit=%d", len(guesses), limit) - deviceJSON := captchaDeviceInfo - deviceSource := "default" - if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { - deviceJSON = s.savedProfile.DeviceJSON - deviceSource = "saved" - } - s.logger().Debugf("[Captcha] slider componentDone device_source=%s device_bytes=%d", deviceSource, len(deviceJSON)) + deviceJSON := s.profile.DeviceJSON + s.logger().Debugf("[Captcha] slider componentDone device_bytes=%d", len(deviceJSON)) if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ {"session_token", sessionToken}, {"domain", s.domain}, diff --git a/internal/provider/vk/internal/vkauth/auto_solver.go b/internal/provider/vk/internal/vkauth/auto_solver.go index 2ffe247..8db8a98 100644 --- a/internal/provider/vk/internal/vkauth/auto_solver.go +++ b/internal/provider/vk/internal/vkauth/auto_solver.go @@ -3,7 +3,6 @@ package vkauth import ( "context" "fmt" - "strings" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha" @@ -19,7 +18,7 @@ func (c *Client) defaultAutoSolve( profile browserprofile.Profile, ) (string, error) { log := captcha.Log - log.Infof("[STREAM %d] [Captcha] Solving captcha...", streamID) + log.Infof("[STREAM %d] [Captcha] Solving captcha (family=%s platform=%s)...", streamID, profile.Family, profile.Platform) if captchaErr.SessionToken == "" { return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") @@ -28,45 +27,10 @@ func (c *Client) defaultAutoSolve( return "", fmt.Errorf("no redirect_uri for auto-solve") } - var savedProfile *browserprofile.Saved - switch sp, err := browserprofile.Load(); { - case err == nil && savedProfileUsable(*sp): - savedFamily := browserprofile.Family(sp.Profile) - log.Infof("[STREAM %d] [Captcha] Using captured browser profile (authority) family=%s mobile=%t ua=%q device=%t browser_fp=%t", - streamID, savedFamily, browserprofile.IsMobile(sp.Profile), sp.UserAgent, sp.DeviceJSON != "", sp.BrowserFp != "") - savedProfile = sp - profile = sp.Profile - if savedFamily != "" && normalizeFamily(savedFamily) != normalizeFamily(c.browser) { - if rebuilt, rebuildErr := c.newTLSClientForKind(savedFamily, tlsclient.NewCookieJar()); rebuildErr == nil { - client = rebuilt - log.Infof("[STREAM %d] [Captcha] Rebuilt TLS client to match captured family=%s", streamID, savedFamily) - } else { - log.Warnf("[STREAM %d] [Captcha] Failed to rebuild TLS for captured family=%s: %v; keeping -browser TLS", streamID, savedFamily, rebuildErr) - } - } - case err == nil: - log.Warnf("[STREAM %d] [Captcha] Captured profile unusable (empty UA); using -browser fallback", streamID) - default: - log.Debugf("[STREAM %d] [Captcha] No captured browser profile: %v", streamID, err) - } - - successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, savedProfile, log) + successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, log) if err != nil { return "", err } log.Infof("[STREAM %d] [Captcha] solver succeeded", streamID) return successToken, nil } - -func savedProfileUsable(saved browserprofile.Saved) bool { - return strings.TrimSpace(saved.UserAgent) != "" -} - -func normalizeFamily(k browserprofile.Kind) browserprofile.Kind { - switch k { - case browserprofile.Firefox, browserprofile.Safari: - return k - default: - return browserprofile.Chrome - } -} diff --git a/internal/provider/vk/internal/vkauth/cache.go b/internal/provider/vk/internal/vkauth/cache.go index 53edf2b..4913926 100644 --- a/internal/provider/vk/internal/vkauth/cache.go +++ b/internal/provider/vk/internal/vkauth/cache.go @@ -6,8 +6,6 @@ import ( "sync/atomic" ) -// StreamCredentialsCache хранит TURN-реквизиты для группы потоков -// и счётчик ошибок для решений об инвалидации. type StreamCredentialsCache struct { creds TurnCredentials mutex sync.RWMutex @@ -15,7 +13,6 @@ type StreamCredentialsCache struct { lastErrorTime atomic.Int64 } -// Store отображает cache-id ((streamID-1) / streamsPerCache) -> StreamCredentialsCache. type Store struct { mu sync.RWMutex caches map[int]*StreamCredentialsCache @@ -64,7 +61,6 @@ func (s *Store) Get(streamID int) *StreamCredentialsCache { return cache } -// Invalidate сбрасывает реквизиты кэша потока и обнуляет счётчик ошибок. func (c *StreamCredentialsCache) Invalidate() { c.mutex.Lock() c.creds = TurnCredentials{} @@ -74,8 +70,6 @@ func (c *StreamCredentialsCache) Invalidate() { c.lastErrorTime.Store(0) } -// IsAuthError проверяет ошибку по эвристике TURN-клиента: -// auth/401/stale-nonce - признак инвалидации кэша. func IsAuthError(err error) bool { if err == nil { return false diff --git a/internal/provider/vk/internal/vkauth/client.go b/internal/provider/vk/internal/vkauth/client.go index 0e4b754..984d185 100644 --- a/internal/provider/vk/internal/vkauth/client.go +++ b/internal/provider/vk/internal/vkauth/client.go @@ -23,7 +23,6 @@ type Config struct { // Credentials для перебора по порядку. nil/empty -> DefaultCredentials. Credentials []VKCredentials - // Dialer для HTTP-транспорта запросов к VK API. Dialer net.Dialer // ManualOnly форсирует ручной путь captcha с первой попытки. @@ -46,18 +45,19 @@ type Config struct { // Нулевое значение -> Chrome (исторический дефолт для прямых вызовов в тестах). Browser browserprofile.Kind + // Platform - класс устройства персоны (desktop|mobile). Нулевое -> desktop. + Platform browserprofile.Platform + // Log - уровневый логгер. nil -> no-op. Log logx.Logger } -// Client - фасад VK-аутентификации и кэша реквизитов. Владеет кэшем группы -// потоков, глобальным throttle запросов, таймером блокировки captcha и счётчиком -// ошибок аутентификации для инвалидации устаревших TURN-реквизитов. type Client struct { credentials []VKCredentials dialer net.Dialer manualOnly bool browser browserprofile.Kind + platform browserprofile.Platform streamsFn func() int32 autoSolver AutoSolveFunc manualSolve ManualSolveFunc @@ -86,6 +86,7 @@ func New(cfg Config) *Client { dialer: cfg.Dialer, manualOnly: cfg.ManualOnly, browser: cfg.Browser, + platform: cfg.Platform, streamsFn: cfg.StreamsAlive, autoSolver: cfg.AutoSolver, manualSolve: cfg.ManualSolver, @@ -108,10 +109,8 @@ func New(cfg Config) *Client { return c } -// GetCredentials возвращает (username, password, server-addrs) для TURN-allocate, -// обращаясь к VK (с throttle + кэшем) только при необходимости. Адреса отдаются -// в порядке предпочтения для streamID (предпочтительный первым) - pipeline -// пробует их по очереди при allocate. +// GetCredentials -> (username, password, server-addrs); addrs ротированы под +// streamID (предпочтительный первым, см. orderAddrs), fetch к VK только при промахе кэша. func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) (string, string, []string, error) { cache := c.store.Get(streamID) cacheID := c.store.CacheID(streamID) @@ -165,9 +164,7 @@ func orderAddrs(addrs []string, streamID int) []string { return out } -// HandleAuthError увеличивает счётчик ошибок аутентификации кэша потока и -// инвалидирует кэш при достижении порога внутри скользящего окна. -// Возвращает true при инвалидации. +// HandleAuthError инвалидирует кэш при MaxCacheErrors в окне ErrorWindow; true = инвалидирован. func (c *Client) HandleAuthError(streamID int) bool { cache := c.store.Get(streamID) cacheID := c.store.CacheID(streamID) @@ -190,35 +187,29 @@ func (c *Client) HandleAuthError(streamID int) bool { return false } -// ResetErrors обнуляет счётчик ошибок аутентификации (вызывать при успешном allocate). +// ResetErrors вызывать при успешном allocate. func (c *Client) ResetErrors(streamID int) { c.store.Get(streamID).errorCount.Store(0) } -// LockoutUntilUnix возвращает unix-секунду дедлайна глобальной блокировки captcha -// или 0, если блокировки нет. func (c *Client) LockoutUntilUnix() int64 { return c.lockout.Load() } -// BackoffUntilUnix реализует provider.Provider - алиас LockoutUntilUnix. -// vkauth-lockout глобальный (без per-stream), streamID-параметра в самом -// методе нет; интерфейс provider.Provider определяет no-arg сигнатуру. +// BackoffUntilUnix - алиас LockoutUntilUnix: lockout глобальный, а provider.Provider +// требует no-arg сигнатуру (без streamID). func (c *Client) BackoffUntilUnix() int64 { return c.LockoutUntilUnix() } -// Name реализует provider.Provider. func (*Client) Name() string { return "vk" } -// IsAuthError оборачивает пакетный IsAuthError как метод для работы через интерфейс. func (*Client) IsAuthError(err error) bool { return IsAuthError(err) } -// engageLockout устанавливает глобальную блокировку captcha на d с момента вызова. func (c *Client) engageLockout(d time.Duration) { c.lockout.Store(time.Now().Add(d).Unix()) } -// fetchSerialized соблюдает интервал 3s+jitter между запросами, затем -// выполняет перебор всех credentials. +// fetchSerialized сериализует fetch и держит min-интервал между запросами (анти +// rate-limit VK). func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) { c.fetchMu.Lock() defer c.fetchMu.Unlock() @@ -238,7 +229,6 @@ func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int) return c.fetch(ctx, link, streamID) } -// fetch перебирает c.credentials, возвращая первый успех или терминальную ошибку. func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, string, []string, error) { if time.Now().Unix() < c.lockout.Load() { return "", "", nil, fmt.Errorf("%w: %w", ErrCaptchaWaitRequired, ErrLockoutActive) diff --git a/internal/provider/vk/internal/vkauth/dial.go b/internal/provider/vk/internal/vkauth/dial.go index edaabe1..ccd7467 100644 --- a/internal/provider/vk/internal/vkauth/dial.go +++ b/internal/provider/vk/internal/vkauth/dial.go @@ -10,7 +10,6 @@ import ( fhttp "github.com/bogdanfinn/fhttp" tlsclient "github.com/bogdanfinn/tls-client" - "github.com/bogdanfinn/tls-client/profiles" "golang.org/x/net/proxy" ) @@ -49,25 +48,10 @@ func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (ne }, nil } -// clientProfileForKind мапит семейство браузера в TLS/HTTP2-профиль. -func clientProfileForKind(kind browserprofile.Kind) profiles.ClientProfile { - switch kind { - case browserprofile.Safari: - return profiles.Safari_16_0 - case browserprofile.Firefox: - return profiles.Firefox_148 - } - return profiles.Chrome_146 -} - -func (c *Client) newTLSClient(jar tlsclient.CookieJar) (tlsclient.HttpClient, error) { - return c.newTLSClientForKind(c.browser, jar) -} - -func (c *Client) newTLSClientForKind(kind browserprofile.Kind, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) { +func (c *Client) newTLSClient(profile browserprofile.Profile, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) { return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), tlsclient.WithTimeoutSeconds(20), - tlsclient.WithClientProfile(clientProfileForKind(kind)), + tlsclient.WithClientProfile(profile.ClientProfile()), tlsclient.WithCookieJar(jar), tlsclient.WithProxyDialerFactory(func(_ string, timeout time.Duration, localAddr *net.TCPAddr, _ fhttp.Header, _ tlsclient.Logger) (proxy.ContextDialer, error) { base := c.dialer diff --git a/internal/provider/vk/internal/vkauth/solver.go b/internal/provider/vk/internal/vkauth/solver.go index 6c00cd4..7245999 100644 --- a/internal/provider/vk/internal/vkauth/solver.go +++ b/internal/provider/vk/internal/vkauth/solver.go @@ -10,7 +10,6 @@ import ( tlsclient "github.com/bogdanfinn/tls-client" ) -// CaptchaSolveMode - выбор между автоматическим решением и ручным браузерным fallback. type CaptchaSolveMode int const ( @@ -18,8 +17,6 @@ const ( CaptchaSolveModeManual ) -// CaptchaSolveModeForAttempt выбирает решалку для конкретной попытки retry. -// Возвращает (mode, true) если режим доступен, (_, false) если исчерпан. func CaptchaSolveModeForAttempt(attempt int, manualOnly bool) (CaptchaSolveMode, bool) { if manualOnly { return CaptchaSolveModeManual, attempt == 0 @@ -44,8 +41,6 @@ func CaptchaSolveModeLabel(mode CaptchaSolveMode) string { } } -// AutoSolveFunc возвращает success_token для captcha через in-page widget flow. -// Реализации обязаны соблюдать отмену ctx. type AutoSolveFunc func( ctx context.Context, captchaErr *captcha.Error, @@ -54,7 +49,6 @@ type AutoSolveFunc func( profile browserprofile.Profile, ) (token string, err error) -// ManualSolveFunc открывает локальный браузерный fallback и возвращает success_token. type ManualSolveFunc func( ctx context.Context, captchaErr *captcha.Error, diff --git a/internal/provider/vk/internal/vkauth/solver_test.go b/internal/provider/vk/internal/vkauth/solver_test.go index 315c53a..e8878c2 100644 --- a/internal/provider/vk/internal/vkauth/solver_test.go +++ b/internal/provider/vk/internal/vkauth/solver_test.go @@ -2,8 +2,6 @@ package vkauth import ( "testing" - - "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" ) func TestCaptchaSolveModeForAttempt(t *testing.T) { @@ -40,38 +38,3 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) { } }) } - -func TestSavedProfileUsable(t *testing.T) { - t.Parallel() - - if savedProfileUsable(browserprofile.Saved{}) { - t.Fatal("empty profile must be unusable (no UA to impersonate)") - } - if savedProfileUsable(browserprofile.Saved{Profile: browserprofile.Profile{UserAgent: " "}}) { - t.Fatal("whitespace UA must be unusable") - } - mobile := browserprofile.Saved{Profile: browserprofile.Profile{ - UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36", - SecChUaMobile: "?1", - }} - if !savedProfileUsable(mobile) { - t.Fatal("captured mobile profile must be usable") - } -} - -func TestNormalizeFamily(t *testing.T) { - t.Parallel() - - cases := map[browserprofile.Kind]browserprofile.Kind{ - browserprofile.Firefox: browserprofile.Firefox, - browserprofile.Safari: browserprofile.Safari, - browserprofile.Chrome: browserprofile.Chrome, - "": browserprofile.Chrome, - "weird": browserprofile.Chrome, - } - for in, want := range cases { - if got := normalizeFamily(in); got != want { - t.Fatalf("normalizeFamily(%q) = %q, want %q", in, got, want) - } - } -} diff --git a/internal/provider/vk/internal/vkauth/token.go b/internal/provider/vk/internal/vkauth/token.go index a8f34c7..69c8a77 100644 --- a/internal/provider/vk/internal/vkauth/token.go +++ b/internal/provider/vk/internal/vkauth/token.go @@ -15,9 +15,9 @@ import ( // и возвращает тройку TURN-allocate. Ошибки captcha запускают настроенную цепочку // auto/manual solver. func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) { - profile := browserprofile.ForKind(c.browser) + profile := browserprofile.For(c.browser, c.platform) - httpClient, err := c.newTLSClient(jar) + httpClient, err := c.newTLSClient(profile, jar) if err != nil { return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err) } diff --git a/internal/provider/vk/internal/vkauth/token_call.go b/internal/provider/vk/internal/vkauth/token_call.go index eefd536..2fa4718 100644 --- a/internal/provider/vk/internal/vkauth/token_call.go +++ b/internal/provider/vk/internal/vkauth/token_call.go @@ -211,7 +211,6 @@ func (c *Client) solveCaptcha( return buildCaptchaRetryData(link, escapedName, token1, captchaErr, successToken), nil } -// buildCaptchaRetryData формирует тело POST для следующей попытки captcha. func buildCaptchaRetryData(link, escapedName, token1 string, captchaErr *captcha.Error, successToken string) string { if captchaErr.CaptchaSid == "" { return fmt.Sprintf( diff --git a/internal/provider/vk/internal/vkauth/token_request.go b/internal/provider/vk/internal/vkauth/token_request.go index cf23fde..c927ff2 100644 --- a/internal/provider/vk/internal/vkauth/token_request.go +++ b/internal/provider/vk/internal/vkauth/token_request.go @@ -14,8 +14,6 @@ import ( tlsclient "github.com/bogdanfinn/tls-client" ) -// doRequest отправляет POST form-запрос по url через tls-клиент с браузерным -// профилем и десериализует JSON-тело ответа. func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, data, url string) (map[string]any, error) { parsedURL, err := neturl.Parse(url) if err != nil { diff --git a/internal/provider/vk/internal/vkauth/types.go b/internal/provider/vk/internal/vkauth/types.go index f64badb..2cf4184 100644 --- a/internal/provider/vk/internal/vkauth/types.go +++ b/internal/provider/vk/internal/vkauth/types.go @@ -7,13 +7,11 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/provider" ) -// VKCredentials - пара app_id/app_secret для получения анонимных токенов. type VKCredentials struct { ClientID string ClientSecret string } -// TurnCredentials - разрешённые TURN-реквизиты для группы потоков. type TurnCredentials struct { Username string Password string @@ -22,13 +20,6 @@ type TurnCredentials struct { Link string } -// DefaultCredentials - публичные app_id/secret официальных VK-клиентов. Это НЕ -// приватные креды пользователя - VK раздаёт их в JS-бандле страницы калла. -// Клиент перебирает по порядку при ошибках авторизации. В списке только app_id -// с доступом к calls.getAnonymousToken (проверено живым звонком); приложения -// без calls-scope ("Unknown method passed") исключены. -// -//nolint:gosec // public VK SDK app credentials, not user secrets var DefaultCredentials = []VKCredentials{ {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID {ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID @@ -37,8 +28,6 @@ var DefaultCredentials = []VKCredentials{ {ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"}, // VK_IPHONE_APP } -// APIVersion - версия VK API во всех calls.* запросах. Держать единой и близкой -// к версии живого web-клиента звонка (рассинхрон = fingerprint-аномалия). const APIVersion = "5.282" const ( @@ -50,28 +39,11 @@ const ( DefaultStreamsPerCache = 10 ) -// Sentinel-ошибки auth-потока. Строковые формы стабильны (используются в логах). -// -// ErrCaptchaWaitRequired и ErrFatalCaptchaNoStreams также матчатся через -// provider.ErrBackoffActive / provider.ErrFatalNoStreams - pipeline проверяет -// generic-sentinels, vkauth-внутренний код может проверять и старые. var ( ErrCaptchaWaitRequired = errors.Join(provider.ErrBackoffActive, errors.New("CAPTCHA_WAIT_REQUIRED")) ErrFatalCaptchaNoStreams = errors.Join(provider.ErrFatalNoStreams, errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS")) ErrLockoutActive = errors.New("global lockout active") - - // Terminal call/link errors: retrying other client_ids or solving the captcha - // won't help — only the user changing the link / call settings will. We fast- - // fail on these instead of looping "solve captcha -> error -> next credential" - // forever, and the joined provider.ErrFatalNoStreams lets the host surface a - // specific, actionable message. - // - // ErrInvalidJoinLink: VK 9008 "Join link is not valid" (broken link) or 9000 - // "Call not found" (the call ended / no longer exists). ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK")) - // ErrAnonymousBlocked: the call is live but anonymous join is disabled - // ("authorized users only") — the user must allow joining by link in VK. ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED")) - // ErrCallFull: the call has reached its participant limit. ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL")) ) diff --git a/internal/provider/vk/vk.go b/internal/provider/vk/vk.go index a62b0be..c94eff7 100644 --- a/internal/provider/vk/vk.go +++ b/internal/provider/vk/vk.go @@ -1,10 +1,3 @@ -// Package vk - провайдер TURN-реквизитов через VK Calls API. -// -// Фасад над internal/provider/vk/internal/vkauth: добавляет фиксированный link (адрес -// VK callroom) и адаптирует сигнатуру GetCredentials к provider.Provider. -// -// vk.Provider удовлетворяет provider.Provider и используется через generic -// pipeline (proxy/udprelay, proxy/tcpfwd) без VK-specific импортов. package vk import ( @@ -20,22 +13,22 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/vkauth" ) -// Config - параметры VK-провайдера. type Config struct { - // Link - VK callroom join-код (нормализованный, без префикса URL). - // Обязателен. + // Link - VK callroom join-код (нормализованный, без префикса URL). Обязателен. Link string - // Dialer для HTTP-транспорта VK API. Dialer net.Dialer // ManualOnly форсирует ручной путь captcha с первой попытки. ManualOnly bool // Browser - браузерный профиль control-plane: "chrome" | "firefox" | "safari". - // Пустое -> firefox (дефолт продукта). + // Пустое -> firefox Browser string + // Platform - класс устройства персоны: "desktop" | "mobile". Пустое -> desktop. + Platform string + // StreamsPerCache - делитель streamID -> cacheID. <=0 -> дефолт (10). StreamsPerCache int @@ -57,14 +50,12 @@ type Config struct { // решать ручную captcha (поток падает на ErrFatalNoStreams при auto-fail). type ManualSolverFunc = vkauth.ManualSolveFunc -// Provider реализует provider.Provider через vkauth.Client + сохранённый link. type Provider struct { link string auth *vkauth.Client } -// New создаёт VK-провайдер. solver - функция ручного решения captcha -// (опциональная); если nil - manual captcha путь отключён. +// New: solver nil -> ручной путь captcha отключён (поток падает на auto-fail). func New(cfg Config, solver ManualSolverFunc) (*Provider, error) { if cfg.Link == "" { return nil, fmt.Errorf("vk: empty Link") @@ -79,6 +70,7 @@ func New(cfg Config, solver ManualSolverFunc) (*Provider, error) { Dialer: cfg.Dialer, ManualOnly: cfg.ManualOnly, Browser: browserprofile.KindFromString(cfg.Browser), + Platform: browserprofile.PlatformFromString(cfg.Platform), StreamsPerCache: cfg.StreamsPerCache, StreamsAlive: cfg.StreamsAlive, ManualSolver: solver, @@ -87,7 +79,6 @@ func New(cfg Config, solver ManualSolverFunc) (*Provider, error) { return &Provider{link: cfg.Link, auth: auth}, nil } -// GetCredentials реализует provider.Provider. func (p *Provider) GetCredentials(ctx context.Context, streamID int) (provider.Credentials, error) { user, pass, addrs, err := p.auth.GetCredentials(ctx, p.link, streamID) if err != nil { @@ -96,23 +87,16 @@ func (p *Provider) GetCredentials(ctx context.Context, streamID int) (provider.C return provider.Credentials{User: user, Pass: pass, ServerAddrs: addrs}, nil } -// IsAuthError реализует provider.Provider. func (p *Provider) IsAuthError(err error) bool { return p.auth.IsAuthError(err) } -// HandleAuthError реализует provider.Provider. func (p *Provider) HandleAuthError(streamID int) bool { return p.auth.HandleAuthError(streamID) } -// ResetErrors реализует provider.Provider. func (p *Provider) ResetErrors(streamID int) { p.auth.ResetErrors(streamID) } -// BackoffUntilUnix реализует provider.Provider. func (p *Provider) BackoffUntilUnix() int64 { return p.auth.BackoffUntilUnix() } -// Name реализует provider.Provider. func (*Provider) Name() string { return "vk" } -// DefaultManualSolver - стандартный manual-captcha solver, использует -// internal/provider/vk/internal/captcha/manual (HTTP-сервер 127.0.0.1:8765 + браузер). func DefaultManualSolver(ctx context.Context, e *captcha.Error, d net.Dialer) (string, error) { if e.RedirectURI == "" { return "", fmt.Errorf("no redirect_uri")