feat(vkauth): флаг -browser и профиль Firefox

Флаг -browser chrome|firefox (default firefox, Chrome - escape-hatch), client-local для VK-auth, в freeturn:// URI не идёт. browserprofile.ForKind даёт канонные UA/JA3/Accept-Language; ApplyFhttp шлёт sec-ch-ua* только для Chrome (Firefox их не отправляет). dial.go выбирает profiles.Firefox_147 / Chrome_146 под UA.

Control-plane TLS-dial переведён на MultiSplitWriteConn (фрагментация SNI).
This commit is contained in:
samosvalishe
2026-06-17 09:26:19 +03:00
parent cc405100ab
commit 69a68bbc31
8 changed files with 138 additions and 50 deletions
+2 -1
View File
@@ -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 {
+1
View File
@@ -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 клиента (автогенерация если не задан) |
+21 -3
View File
@@ -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 {
@@ -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")
}
+18 -11
View File
@@ -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() }
+29 -13
View File
@@ -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
@@ -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 {
+17 -11
View File
@@ -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 != "" {