diff --git a/cmd/client/main.go b/cmd/client/main.go index ed691a7..206b31b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -195,6 +195,7 @@ func buildProvider(cfg *config.Client, dialer net.Dialer, connected *atomic.Int3 Link: cfg.VK.Link, Dialer: dialer, ManualOnly: cfg.VK.ManualCaptcha, + Browser: string(cfg.VK.Browser), StreamsPerCache: cfg.VK.StreamsPerCred, StreamsAlive: connected.Load, Log: logger, @@ -239,7 +240,7 @@ func resolveClientID(cliID string) string { b, _ := json.MarshalIndent(lc, "", " ") // Запись: первый доступный для записи путь. На Android каталог рядом с - // бинарём (/data/app/.../lib/arm64) read-only — падаем на UserConfigDir, + // бинарём (/data/app/.../lib/arm64) read-only - падаем на UserConfigDir, // затем TempDir. Иначе ID не сохраняется и ротируется на каждый запуск, // ломая allowlist (-clients-file) и статистику. for _, path := range paths { diff --git a/docs/flags.md b/docs/flags.md index 497ef6f..3aefd4e 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -19,6 +19,7 @@ | `-gen-obf-key` | `false` | напечатать новый ключ и выйти | | `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) | | `-streams-per-cred` | `10` | потоков на один кеш VK-учёток (только `-provider vk`) | +| `-browser` | `firefox` | браузерный профиль VK-auth (UA + TLS JA3 + client hints): `chrome` \| `firefox` (только `-provider vk`) | | `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` | | `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` | | `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) | diff --git a/internal/config/config.go b/internal/config/config.go index ea73bbd..d063934 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,11 +75,22 @@ type ProxyOpts struct { Peer string // -peer: адрес серверного прокси, куда дозванивается клиент (только клиент) } +// Browser выбирает браузерный профиль (UA + TLS JA3 + client hints) для +// control-plane запросов VK-провайдера. firefox несёт меньше client hints +// (sec-ch-ua* - Chromium-only), chrome даёт herd-cover. +type Browser string + +const ( + BrowserChrome Browser = "chrome" + BrowserFirefox Browser = "firefox" +) + // VKOpts - опции VK-учёток и captcha (только клиент, провайдер "vk"). type VKOpts struct { - Link string // -link (нормализован до join-кода) - StreamsPerCred int // -streams-per-cred - ManualCaptcha bool // -manual-captcha + Link string // -link (нормализован до join-кода) + StreamsPerCred int // -streams-per-cred + ManualCaptcha bool // -manual-captcha + Browser Browser // -browser: chrome | firefox } // ProviderOpts выбирает реализацию provider.Provider. @@ -176,6 +187,7 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { streamsPerCred := fs.Int("streams-per-cred", defaultStreamsPerCache, "TURN-потоков на один кеш VK-creds; только -provider vk") 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; только -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 клиента (автогенерация если не задан)") @@ -207,6 +219,7 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { VK: VKOpts{ StreamsPerCred: *streamsPerCred, ManualCaptcha: *manualCaptcha, + Browser: Browser(*browser), }, DNS: DNSOpts{ Mode: *dnsMode, @@ -316,6 +329,11 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { if c.VK.StreamsPerCred <= 0 { return nil, fmt.Errorf("-streams-per-cred must be positive") } + switch c.VK.Browser { + case BrowserChrome, BrowserFirefox: + default: + return nil, fmt.Errorf("invalid -browser value %q: must be %s | %s", c.VK.Browser, BrowserChrome, BrowserFirefox) + } parts := strings.Split(*link, "join/") link := parts[len(parts)-1] if idx := strings.IndexAny(link, "/?#"); idx != -1 { diff --git a/internal/provider/vk/internal/browserprofile/profile.go b/internal/provider/vk/internal/browserprofile/profile.go index 7942d22..f1a95bb 100644 --- a/internal/provider/vk/internal/browserprofile/profile.go +++ b/internal/provider/vk/internal/browserprofile/profile.go @@ -7,11 +7,47 @@ import ( fhttp "github.com/bogdanfinn/fhttp" ) +// Kind - семейство браузера. Определяет UA, JA3-профиль и набор client hints. +type Kind string + +const ( + Chrome Kind = "chrome" + Firefox Kind = "firefox" +) + +// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное - +// Firefox (текущий дефолт продукта). +func KindFromString(s string) Kind { + if s == string(Chrome) { + return Chrome + } + return Firefox +} + type Profile struct { UserAgent string - SecChUa string + SecChUa string // пусто для Firefox (Chromium-only client hint) SecChUaMobile string SecChUaPlatform string + AcceptLanguage string +} + +// ForKind возвращает канонический профиль для браузера. JA3 (см. +// vkauth.clientProfile) обязан совпадать с UA отсюда, иначе рассинхрон = флаг. +func ForKind(k Kind) Profile { + if k == Firefox { + return Profile{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0", + AcceptLanguage: "en-US,en;q=0.5", + } + } + return Profile{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + AcceptLanguage: "en-US,en;q=0.9", + } } type Saved struct { @@ -44,9 +80,17 @@ func Save(sp Saved) error { func ApplyFhttp(req *fhttp.Request, profile Profile) { req.Header.Set("User-Agent", profile.UserAgent) - req.Header.Set("sec-ch-ua", profile.SecChUa) - req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile) - req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform) - req.Header.Set("Accept-Language", "en-US,en;q=0.9") + // sec-ch-ua* - Chromium-only client hints. Для Firefox SecChUa пуст - + // заголовки не ставим (UA Firefox + sec-ch-ua = мгновенный флаг). + if profile.SecChUa != "" { + req.Header.Set("sec-ch-ua", profile.SecChUa) + req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile) + req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform) + } + acceptLang := profile.AcceptLanguage + if acceptLang == "" { + acceptLang = "en-US,en;q=0.9" + } + req.Header.Set("Accept-Language", acceptLang) req.Header.Set("DNT", "1") } diff --git a/internal/provider/vk/internal/vkauth/client.go b/internal/provider/vk/internal/vkauth/client.go index d649550..23cdb20 100644 --- a/internal/provider/vk/internal/vkauth/client.go +++ b/internal/provider/vk/internal/vkauth/client.go @@ -11,15 +11,16 @@ import ( "time" "github.com/samosvalishe/free-turn-proxy/internal/logx" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" "github.com/samosvalishe/free-turn-proxy/internal/randx" tlsclient "github.com/bogdanfinn/tls-client" ) -// Config конфигурирует Client. Нулевые значения — безопасные дефолты, +// Config конфигурирует Client. Нулевые значения - безопасные дефолты, // кроме Dialer (должен быть задан явно для кастомного DNS). type Config struct { - // Credentials для перебора по порядку. nil/empty → DefaultCredentials. + // Credentials для перебора по порядку. nil/empty -> DefaultCredentials. Credentials []VKCredentials // Dialer для HTTP-транспорта запросов к VK API. @@ -28,30 +29,35 @@ type Config struct { // ManualOnly форсирует ручной путь captcha с первой попытки. ManualOnly bool - // StreamsPerCache — делитель streamID → cacheID. <=0 → дефолт. + // StreamsPerCache - делитель streamID -> cacheID. <=0 -> дефолт. StreamsPerCache int // StreamsAlive возвращает число подключённых потоков; используется для // решения, является ли исчерпанная captcha фатальной или только throttle. - // nil → 1. + // nil -> 1. StreamsAlive func() int32 - // AutoSolver / ManualSolver — подключаемые решалки captcha. nil отключает + // AutoSolver / ManualSolver - подключаемые решалки captcha. nil отключает // соответствующий путь (поток переходит к следующей попытке). AutoSolver AutoSolveFunc ManualSolver ManualSolveFunc - // Log — уровневый логгер. nil → no-op. + // Browser - браузерный профиль (UA + JA3 + client hints) для control-plane. + // Нулевое значение -> Chrome (исторический дефолт для прямых вызовов в тестах). + Browser browserprofile.Kind + + // Log - уровневый логгер. nil -> no-op. Log logx.Logger } -// Client — фасад VK-аутентификации и кэша реквизитов. Владеет кэшем группы +// Client - фасад VK-аутентификации и кэша реквизитов. Владеет кэшем группы // потоков, глобальным throttle запросов, таймером блокировки captcha и счётчиком // ошибок аутентификации для инвалидации устаревших TURN-реквизитов. type Client struct { credentials []VKCredentials dialer net.Dialer manualOnly bool + browser browserprofile.Kind streamsFn func() int32 autoSolver AutoSolveFunc manualSolve ManualSolveFunc @@ -64,7 +70,7 @@ type Client struct { fetchMu sync.Mutex lastFetchTime time.Time - // tokenChain — 4-шаговый получатель токена для пары credentials. + // tokenChain - 4-шаговый получатель токена для пары credentials. // В prod подключён (*Client).getTokenChain; тесты подменяют fake. tokenChain tokenChainFn @@ -79,6 +85,7 @@ func New(cfg Config) *Client { credentials: cfg.Credentials, dialer: cfg.Dialer, manualOnly: cfg.ManualOnly, + browser: cfg.Browser, streamsFn: cfg.StreamsAlive, autoSolver: cfg.AutoSolver, manualSolve: cfg.ManualSolver, @@ -103,7 +110,7 @@ func New(cfg Config) *Client { // GetCredentials возвращает (username, password, server-addrs) для TURN-allocate, // обращаясь к VK (с throttle + кэшем) только при необходимости. Адреса отдаются -// в порядке предпочтения для streamID (предпочтительный первым) — pipeline +// в порядке предпочтения для streamID (предпочтительный первым) - pipeline // пробует их по очереди при allocate. func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) (string, string, []string, error) { cache := c.store.Get(streamID) @@ -143,7 +150,7 @@ func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) } // orderAddrs возвращает копию addrs, ротированную так, что предпочтительный -// для streamID адрес стоит первым, остальные — следом (сохраняя порядок). +// для streamID адрес стоит первым, остальные - следом (сохраняя порядок). // Раскидывает primary по стримам (балансировка relay-IP), оставляя остальные // как фоллбэк при DPI-дропе. func orderAddrs(addrs []string, streamID int) []string { @@ -194,7 +201,7 @@ func (c *Client) LockoutUntilUnix() int64 { return c.lockout.Load() } -// BackoffUntilUnix реализует provider.Provider — алиас LockoutUntilUnix. +// BackoffUntilUnix реализует provider.Provider - алиас LockoutUntilUnix. // vkauth-lockout глобальный (без per-stream), streamID-параметра в самом // методе нет; интерфейс provider.Provider определяет no-arg сигнатуру. func (c *Client) BackoffUntilUnix() int64 { return c.LockoutUntilUnix() } diff --git a/internal/provider/vk/internal/vkauth/dial.go b/internal/provider/vk/internal/vkauth/dial.go index 4c6788a..4af4aee 100644 --- a/internal/provider/vk/internal/vkauth/dial.go +++ b/internal/provider/vk/internal/vkauth/dial.go @@ -6,6 +6,7 @@ import ( "time" "github.com/samosvalishe/free-turn-proxy/internal/netconn" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" fhttp "github.com/bogdanfinn/fhttp" tlsclient "github.com/bogdanfinn/tls-client" @@ -14,17 +15,18 @@ import ( ) const ( - // clientHelloSplitAt — смещение разбиения первого TCP-сегмента TLS - // ClientHello. SNI уезжает во второй сегмент, ТСПУ не собирает его из - // раздробленного потока и не инжектит delayed-RST. Совпадает с TURN - // (turndial.SplitFirstWriteConn). - clientHelloSplitAt = 6 - clientHelloSplitDelay = 20 * time.Millisecond + // clientHelloSplitAt - фоллбэк-offset разбиения, когда SNI в ClientHello не + // распарсился. Совпадает с TURN STUN-split. + clientHelloSplitAt = 6 + // clientHelloSplitDelay / clientHelloSplitJitter - пауза между сегментами + // ClientHello (base + случайная добавка [0,jitter)) для антифингерпринта тайминга. + clientHelloSplitDelay = 20 * time.Millisecond + clientHelloSplitJitter = 15 * time.Millisecond ) -// splitDialer оборачивает base.DialContext и дробит первый Write -// результирующего conn (TLS ClientHello) для обхода SNI-based DPI RST. -// Реализует proxy.ContextDialer — tls-client берёт его через +// splitDialer оборачивает base.DialContext и дробит первый Write результирующего +// conn (TLS ClientHello) по границам внутри SNI host_name для обхода SNI-based +// DPI RST. Реализует proxy.ContextDialer - tls-client берёт его через // WithProxyDialerFactory как прямой (не прокси) дилер. type splitDialer struct { base net.Dialer @@ -39,17 +41,31 @@ func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (ne if err != nil { return nil, err } - return &netconn.SplitFirstWriteConn{Conn: c, SplitAt: clientHelloSplitAt, Delay: clientHelloSplitDelay}, nil + return &netconn.MultiSplitWriteConn{ + Conn: c, + Delay: clientHelloSplitDelay, + Jitter: clientHelloSplitJitter, + FallbackSplitAt: clientHelloSplitAt, + }, nil +} + +// clientProfile выбирает TLS/HTTP2-профиль (JA3 + client hints) под браузер. +// JA3 обязан совпадать с UA из browserprofile.ForKind, иначе рассинхрон = флаг. +func (c *Client) clientProfile() profiles.ClientProfile { + if c.browser == browserprofile.Firefox { + return profiles.Firefox_147 + } + return profiles.Chrome_146 } // newTLSClient строит tls-client с Chrome-fingerprint и фрагментацией -// ClientHello на всех исходящих control-plane TLS-соединениях. Базовый дилер — +// ClientHello на всех исходящих control-plane TLS-соединениях. Базовый дилер - // c.dialer (несёт DNS-резолвер dnsdial); фабрика вызывается без proxyUrl, поэтому -// CONNECT не используется — splitDialer работает как прямой транспорт. +// CONNECT не используется - splitDialer работает как прямой транспорт. func (c *Client) newTLSClient(jar tlsclient.CookieJar) (tlsclient.HttpClient, error) { return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), tlsclient.WithTimeoutSeconds(20), - tlsclient.WithClientProfile(profiles.Chrome_146), + tlsclient.WithClientProfile(c.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/token.go b/internal/provider/vk/internal/vkauth/token.go index 6d30abd..ca8de58 100644 --- a/internal/provider/vk/internal/vkauth/token.go +++ b/internal/provider/vk/internal/vkauth/token.go @@ -15,12 +15,7 @@ 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.Profile{ - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", - SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`, - SecChUaMobile: "?0", - SecChUaPlatform: `"Windows"`, - } + profile := browserprofile.ForKind(c.browser) httpClient, err := c.newTLSClient(jar) if err != nil { diff --git a/internal/provider/vk/vk.go b/internal/provider/vk/vk.go index bb4f553..3f33eb9 100644 --- a/internal/provider/vk/vk.go +++ b/internal/provider/vk/vk.go @@ -1,4 +1,4 @@ -// Package vk — провайдер TURN-реквизитов через VK Calls API. +// Package vk - провайдер TURN-реквизитов через VK Calls API. // // Фасад над internal/provider/vk/internal/vkauth: добавляет фиксированный link (адрес // VK callroom) и адаптирует сигнатуру GetCredentials к provider.Provider. @@ -14,14 +14,15 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/logx" "github.com/samosvalishe/free-turn-proxy/internal/provider" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha" manualcaptcha "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha/manual" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/vkauth" ) -// Config — параметры VK-провайдера. +// Config - параметры VK-провайдера. type Config struct { - // Link — VK callroom join-код (нормализованный, без префикса URL). + // Link - VK callroom join-код (нормализованный, без префикса URL). // Обязателен. Link string @@ -31,24 +32,28 @@ type Config struct { // ManualOnly форсирует ручной путь captcha с первой попытки. ManualOnly bool - // StreamsPerCache — делитель streamID → cacheID. <=0 → дефолт (10). + // Browser - браузерный профиль control-plane: "chrome" | "firefox". + // Пустое -> firefox (дефолт продукта). + Browser string + + // StreamsPerCache - делитель streamID -> cacheID. <=0 -> дефолт (10). StreamsPerCache int // StreamsAlive возвращает число подключённых потоков; vkauth использует // для решения, является ли исчерпанная captcha фатальной или throttle. StreamsAlive func() int32 - // Credentials — VK app_id/secret пары; nil → vkauth.DefaultCredentials. + // Credentials - VK app_id/secret пары; nil -> vkauth.DefaultCredentials. Credentials []vkauth.VKCredentials - // Log — уровневый логгер. nil → no-op. + // Log - уровневый логгер. nil -> no-op. Log logx.Logger // Debug включает debug-вывод в manual-captcha (HTTP-сервер). Debug bool } -// ManualSolverFunc — кастомный решатель captcha. Если nil, vkauth не пытается +// ManualSolverFunc - кастомный решатель captcha. Если nil, vkauth не пытается // решать ручную captcha (поток падает на ErrFatalNoStreams при auto-fail). type ManualSolverFunc = vkauth.ManualSolveFunc @@ -58,13 +63,13 @@ type Provider struct { auth *vkauth.Client } -// New создаёт VK-провайдер. solver — функция ручного решения captcha -// (опциональная); если nil — manual captcha путь отключён. +// New создаёт VK-провайдер. solver - функция ручного решения captcha +// (опциональная); если nil - manual captcha путь отключён. func New(cfg Config, solver ManualSolverFunc) (*Provider, error) { if cfg.Link == "" { return nil, fmt.Errorf("vk: empty Link") } - // captcha-пакеты — internal/ для provider/vk, поэтому подключаем + // captcha-пакеты - internal/ для provider/vk, поэтому подключаем // логгер здесь, а не в cmd/client. captcha.SetLogger(cfg.Log) manualcaptcha.SetLogger(cfg.Log) @@ -73,6 +78,7 @@ func New(cfg Config, solver ManualSolverFunc) (*Provider, error) { Credentials: cfg.Credentials, Dialer: cfg.Dialer, ManualOnly: cfg.ManualOnly, + Browser: browserprofile.KindFromString(cfg.Browser), StreamsPerCache: cfg.StreamsPerCache, StreamsAlive: cfg.StreamsAlive, ManualSolver: solver, @@ -105,7 +111,7 @@ func (p *Provider) BackoffUntilUnix() int64 { return p.auth.BackoffUntilUnix() } // Name реализует provider.Provider. func (*Provider) Name() string { return "vk" } -// DefaultManualSolver — стандартный manual-captcha solver, использует +// 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, string, error) { if e.RedirectURI != "" {