mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
fix(captcha): persona-модель вместо захвата профиля браузера
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -38,9 +38,9 @@ func TestParseCaptchaPageSPA(t *testing.T) {
|
||||
html := `<html><head><script>
|
||||
const powInput = "Pihj7tyAHFxdwm4t";
|
||||
const difficulty = 2;
|
||||
</script></head><body><div id="spa_root"></div>
|
||||
<script>window.vk = { statsMeta: {"hash":"39RzeEGG2Qq7t4YUwG"}, brlefapmjnpg: "1d958dbe-0663-42f2-95e5-0e2780ed2f93", id: 0 };</script>
|
||||
</body></html>`
|
||||
</script>
|
||||
<script src="https://id.vk.ru/static/vkid/1.1.1374/not_robot_captcha.js"></script>
|
||||
</head><body><div id="spa_root"></div></body></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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user