12 Commits

Author SHA1 Message Date
Anton 62064a5eca Merge pull request #46 from samosvalishe/release-please--branches--master
chore(master): release 1.8.0
2026-07-13 22:54:01 +03:00
Anton d67c829532 chore(master): release 1.8.0 2026-07-13 22:31:36 +03:00
samosvalishe e678ee1fd8 feat: автопубликация телеграм анонса при релизе 2026-07-13 22:31:06 +03:00
samosvalishe e5874e3c30 chore: lint fix 2026-07-13 21:48:45 +03:00
samosvalishe 8311a2e2f9 chore: fmt autofix 2026-07-13 21:37:22 +03:00
samosvalishe 5a347bece9 docs: флаг -platform, persona-модель и пунктуация по конвенции 2026-07-13 21:29:35 +03:00
samosvalishe 305754cd60 fix(captcha): persona-модель вместо захвата профиля браузера 2026-07-13 21:29:34 +03:00
Anton e009db0b9a Merge pull request #45 from samosvalishe/release-please--branches--master
chore(master): release 1.7.3
2026-07-12 19:15:19 +03:00
Anton 321a0995c3 chore(master): release 1.7.3 2026-07-12 19:13:46 +03:00
samosvalishe 6f934f10f4 chore: fmt autofix 2026-07-12 19:13:26 +03:00
samosvalishe ea410a044a fix(deps): обновить Go 1.26.4 -> 1.26.5 2026-07-12 19:10:27 +03:00
samosvalishe b4189f8f43 fix(captcha): адаптировать авторешение под SPA-капчу VK 2026-07-12 18:54:08 +03:00
29 changed files with 505 additions and 627 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
env:
GO_VERSION: "1.26.4"
GO_VERSION: "1.26.5"
GOLANGCI_VERSION: v2.12.2
GOVULN_VERSION: v1.1.4
GORELEASER_VERSION: v2.15.4
+37 -1
View File
@@ -10,7 +10,7 @@ permissions:
packages: write
env:
GO_VERSION: "1.26.4"
GO_VERSION: "1.26.5"
GORELEASER_VERSION: v2.15.4
GOMOBILE_VERSION: latest
TASK_VERSION: 3.51.1
@@ -129,3 +129,39 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
name: Telegram announce
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Send release message
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
BODY=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json body -q .body | head -c 3000)
URL=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json url -q .url)
# GH-markdown -> Telegram-HTML: сначала экранируем HTML, потом размечаем.
# Хвосты "by @user in <PR-url>" из авто-нотов убираем - в посте это шум.
HTML=$(printf '%s' "$BODY" \
| sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' \
| sed -E \
-e 's| by @[[:alnum:]_-]+ in https://[^[:space:]]+||g' \
-e 's|\[([^]]+)\]\((https?://[^)]+)\)|<a href="\2">\1</a>|g' \
-e 's|\*\*([^*]+)\*\*|<b>\1</b>|g' \
-e 's|`([^`]+)`|<code>\1</code>|g' \
-e 's|^#{1,6}[[:space:]]+(.*)$|<b>\1</b>|' \
-e 's|^[-*][[:space:]]+|• |')
TEXT=$(printf '🚀 <b>FreeTurn %s</b>\n\n%s\n\n<a href="%s">Релиз на GitHub</a>' "$GITHUB_REF_NAME" "$HTML" "$URL")
send() {
curl -fsS -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=$1" \
--data-urlencode "parse_mode=${2:-}" \
--data-urlencode "disable_web_page_preview=true" \
-o /dev/null
}
# Кривой HTML роняет весь sendMessage - фолбэк plain text, чтобы анонс не пропал
send "$TEXT" HTML || send "$(printf '🚀 FreeTurn %s\n\n%s\n\n%s' "$GITHUB_REF_NAME" "$BODY" "$URL")"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.7.2"
".": "1.8.0"
}
+22 -2
View File
@@ -1,5 +1,25 @@
# Changelog
## [1.8.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.3...v1.8.0) (2026-07-13)
### Features
* автопубликация телеграм анонса при релизе ([e678ee1](https://github.com/samosvalishe/free-turn-proxy/commit/e678ee1fd85c64eb42da3ba9ccc580fccbc79749))
### Bug Fixes
* **captcha:** persona-модель вместо захвата профиля браузера ([305754c](https://github.com/samosvalishe/free-turn-proxy/commit/305754cd6037fe3b4ec5558b826a89eefc64834b))
## [1.7.3](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.2...v1.7.3) (2026-07-12)
### Bug Fixes
* **captcha:** адаптировать авторешение под SPA-капчу VK ([b4189f8](https://github.com/samosvalishe/free-turn-proxy/commit/b4189f8f4317b1c5c919ab8e0e183ebfb927d8be))
* **deps:** обновить Go 1.26.4 -&gt; 1.26.5 ([ea410a0](https://github.com/samosvalishe/free-turn-proxy/commit/ea410a044ad1ebd3fbc14286f41213357a6732f4))
## [1.7.2](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.1...v1.7.2) (2026-07-07)
@@ -253,7 +273,7 @@
* **docker:** исправить сборку, добавить compose, убрать VLESS_BOND ([dfbfae2](https://github.com/samosvalishe/free-turn-proxy/commit/dfbfae29014b3cd246550589640e871ee424807e))
* **dtlsdial:** Dial использует хелпер GenerateSelfSignedCert внутри ([21138fe](https://github.com/samosvalishe/free-turn-proxy/commit/21138fef26b61f8ba4b72f8e5389d3b90b7026b2))
* **dtlsdial:** унифицировать генерацию self-signed сертификата ([ef549dc](https://github.com/samosvalishe/free-turn-proxy/commit/ef549dc2b8405a1e8378d9c81f2ae3871495121f))
* **install:** переписать установщик сервера — TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
* **install:** переписать установщик сервера - TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
* **lint:** устранить замечания golangci-lint + переход на dockers_v2 ([dd82332](https://github.com/samosvalishe/free-turn-proxy/commit/dd8233208ccbe60ea17745ddc17df7a8e6c56872))
* **routes:** починить установку маршрутов в routes.ps1 на Windows ([7b0a907](https://github.com/samosvalishe/free-turn-proxy/commit/7b0a9076380bbcdc2f83d6da63ca3800b5d68412))
* **server:** ограниченное ожидание второго сигнала; предупреждение при выключенном -wrap ([fb711c1](https://github.com/samosvalishe/free-turn-proxy/commit/fb711c12f5e1f78ee037ceed434b6cdde5a961d8))
@@ -298,7 +318,7 @@
* **tcpfwd:** заменить busy-loop poll пула на Ready-канал; тихий accept-цикл при shutdown ([2b24471](https://github.com/samosvalishe/free-turn-proxy/commit/2b24471553b7a9acb0c0117d7ce666bac758e97e))
* **udprelay:** разбить на run/loop/listener ([d82258f](https://github.com/samosvalishe/free-turn-proxy/commit/d82258f844ef65ba06ba05aa7de4130c43f2c56e))
* **vkauth:** разбить token.go на файлы по шагам ([0e2d42c](https://github.com/samosvalishe/free-turn-proxy/commit/0e2d42c9126505599db25d8c6f82956036c193be))
* **wire:** переименовать srtpmimicry → rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
* **wire:** переименовать srtpmimicry -> rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
* **wrap:** заменить DTLS-мимикрию на noise-only AEAD ([e2dd09a](https://github.com/samosvalishe/free-turn-proxy/commit/e2dd09a7296f972285850786c6552d66006bf19d))
* **wrap:** перейти на мимикрию под SRTP в обход content-фильтра VK TURN ([729557d](https://github.com/samosvalishe/free-turn-proxy/commit/729557d00d3f7b16e4d1d876adca23404eff738c))
* **wrap:** переписать как мимикрию под DTLS 1.2 ApplicationData с AEAD ([39f95a8](https://github.com/samosvalishe/free-turn-proxy/commit/39f95a8e0c2ca3520c530e425aa55a5b586feadb))
+1
View File
@@ -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,
+3 -2
View File
@@ -21,7 +21,8 @@
| `-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` \| `safari` (только `-provider vk`) |
| `-browser` | `firefox` | семейство персоны VK-auth (UA + TLS JA3 + client hints): `chrome` \| `firefox` \| `safari` (только `-provider vk`) |
| `-platform` | `desktop` | класс устройства персоны VK-auth (мобильность UA/device/client hints): `desktop` \| `mobile` (только `-provider vk`) |
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
| `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) |
@@ -45,7 +46,7 @@
## Управление Client ID (Команды Сервера)
> [!NOTE]
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает — wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает - wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
Сервер содержит встроенные команды для управления файлом `clients.json` (горячая перезагрузка поддерживается автоматически, перезапускать сервер после изменений не нужно).
+10 -10
View File
@@ -17,13 +17,13 @@
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp
```
**Bonding** — распределение одного TCP-соединения по всем активным smux-сессиям, флаг только клиентский:
**Bonding** - распределение одного TCP-соединения по всем активным smux-сессиям, флаг только клиентский:
```bash
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp -bond -n 4
```
Серверного `-bond` нет — сервер автоопределяет bond по magic-префиксу в стриме.
Серверного `-bond` нет - сервер автоопределяет bond по magic-префиксу в стриме.
## OBF (wire-профили обфускации)
@@ -59,30 +59,30 @@
```
> [!WARNING]
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь — используйте стандартный TCP/TLS (просто не указывайте этот флаг).
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь - используйте стандартный TCP/TLS (просто не указывайте этот флаг).
## Потоки и Лимиты (-n)
Параметр `-n` задает количество параллельных потоков к TURN-серверу (по умолчанию 10).
Увеличение потоков может помочь поднять скорость или улучшить стабильность, **но не стоит этим злоупотреблять**. Слишком высокое значение (например, больше 15-20) повышает риск получения бана со стороны VK. Практика показывает, что значений от `6` до `12` зачастую достаточно для комфортной работы.
То же самое касается параметра `-streams-per-cred` — используйте разумные значения.
То же самое касается параметра `-streams-per-cred` - используйте разумные значения.
## Captcha
Клиент сам проходит VK captcha. Если автоматика не справилась — открывается локальный браузер. Принудительно ручной:
Клиент сам проходит VK captcha. Если автоматика не справилась - открывается локальный браузер. Принудительно ручной:
```bash
./client -manual-captcha ...
```
Профиль браузера: `vk_profile.json` рядом с бинарником.
Персона auth-запросов (UA + TLS JA3 + client hints + device) задаётся флагами `-browser` (семейство) и `-platform` (desktop/mobile); значения самосогласованы, без внешнего состояния.
## DNS
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (VK API, токены, captcha) — не DNS-маршрутизацией пользовательского трафика.
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (VK API, токены, captcha) - не DNS-маршрутизацией пользовательского трафика.
- `plain` — только UDP/53;
- `doh` — только DNS-over-HTTPS (Yandex DoH);
- `auto` (дефолт) — сначала UDP/53, sticky-fallback на DoH при полном отказе.
- `plain` - только UDP/53;
- `doh` - только DNS-over-HTTPS (Yandex DoH);
- `auto` (дефолт) - сначала UDP/53, sticky-fallback на DoH при полном отказе.
Свои UDP/53 резолверы: `-dns-servers ip[:port][,ip[:port]...]`.
+5 -3
View File
@@ -9,8 +9,10 @@
VK Calls API. Перебирает встроенные `app_id/app_secret`, получает короткоживущие (≈10 мин) TURN-creds через 4-шаговый token chain. Solver captcha auto+manual.
**Обязательные флаги:**
- `-link` — VK callroom URL вида `https://vk.ru/call/join/<code>` (нормализуется до join-кода).
- `-link` - VK callroom URL вида `https://vk.ru/call/join/<code>` (нормализуется до join-кода).
**Опциональные:**
- `-streams-per-cred` (default 10) — сколько TURN-стримов делят один кеш креденшалов.
- `-manual-captcha` — пропустить auto-solver, сразу открыть браузер.
- `-streams-per-cred` (default 10) - сколько TURN-стримов делят один кеш креденшалов.
- `-manual-captcha` - пропустить auto-solver, сразу открыть браузер.
- `-browser` (default `firefox`) - семейство персоны auth (UA + TLS JA3 + client hints + device): `chrome` \| `firefox` \| `safari`.
- `-platform` (default `desktop`) - класс устройства персоны: `desktop` \| `mobile`.
+1 -1
View File
@@ -2,7 +2,7 @@
- **Сначала запускайте client, потом включайте WireGuard / Xray.**
- **Трафик не идет (нет интернета):** Проверьте серверный флаг `-connect`. В нем должен быть указан точный локальный порт, на котором слушает ваш VPN (например, `51820` для WG или `443` для VLESS). Если порт не совпадает, прокси будет стучаться в пустоту.
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются → убедитесь, что в команде есть пайп перед скриптом.
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются -> убедитесь, что в команде есть пайп перед скриптом.
- **DPI блокирует UDP:** Если используете `-transport udp` и ничего не работает, попробуйте убрать флаг (использовать TCP по умолчанию).
- **Обход шейпинга (кастомный TURN):** Если вы знаете "чистый" IP-адрес TURN-сервера VK, где нет агрессивного шейпинга, вы можете передать его вручную: `-turn <ip_address> -port <port>`. Это перезапишет данные, полученные из ссылки.
- **Проблемы с DNS на мобильном интернете:** Резолв ломается. Передайте DNS вашего провайдера явно: `-dns-servers <ip_dns>`.
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/samosvalishe/free-turn-proxy
go 1.26.4
go 1.26.5
require (
github.com/bogdanfinn/fhttp v0.6.8
+19
View File
@@ -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)
+29
View File
@@ -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())
}
}
}
+103 -183
View File
@@ -28,37 +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"
captchaScriptVersion = "1.1.1374"
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+)`)
reCaptchaWindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
reCaptchaHex64 = regexp.MustCompile(`"([a-fA-F0-9]{64})"`)
reCaptchaVersion = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
errCaptchaBot = errors.New("captcha bot challenge")
captchaMaxAttempts = 2
captchaDebugCache sync.Map // scriptURL -> string
captchaVersionSeen sync.Map // script version -> struct{} (drift warned once)
// debugInfoCache кэширует 64-hex debug_info по URL JS-бандла (константа виджета,
// одна на всех).
debugInfoCache sync.Map
captchaHeaderOrder = []string{
"host",
"content-length",
@@ -80,15 +75,6 @@ var (
captchaPHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
)
type captchaInit struct {
Data captchaInitData `json:"data"`
}
type captchaInitData struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings []captchaInitSetting `json:"captcha_settings"`
}
type captchaInitSetting struct {
Type string `json:"type"`
Settings string `json:"settings"`
@@ -99,7 +85,6 @@ type captchaPage struct {
PowInput string
PowDifficulty int
ScriptURL string
Init *captchaInit
}
type captchaCheck struct {
@@ -117,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 {
@@ -139,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 == "" {
@@ -148,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)
@@ -184,60 +167,39 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
page, err := parseCaptchaPage(html)
if err != nil {
s.dumpChunks("html", html)
return "", err
}
s.logCaptchaPage(page)
if page.PowInput == "" {
return "", errors.New("failed to find PoW settings")
}
sliderContent := captchaContentRef{}
if page.Init != nil {
for _, setting := range page.Init.Data.CaptchaSettings {
if setting.Type == "slider" {
sliderContent = setting.contentRef()
}
}
}
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderContent.Value == "" {
return "", errors.New("failed to find slider captcha settings key")
}
s.logger().Debugf("[Captcha] solving pow difficulty=%d", page.PowDifficulty)
hash := solveCaptchaPoW(s.ctx, page.PowInput, page.PowDifficulty)
if hash == "" {
return "", errors.New("captcha pow failed")
}
s.logger().Debugf("[Captcha] pow solved hash_len=%d", len(hash))
debugInfo := s.resolveDebugInfo(page.ScriptURL)
browserFP, err := captchaBrowserFP()
if err != nil {
return "", err
}
base := s.captchaBaseValues(captchaErr.SessionToken)
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
}
browserFP, err := captchaBrowserFP()
initResp, err := s.captchaRequest("captchaNotRobot.initSession", [][2]string{
{"session_token", captchaErr.SessionToken},
{"domain", s.domain},
{"lang", "0"},
})
if err != nil {
return "", err
return "", fmt.Errorf("captcha initSession failed: %w", err)
}
browserFPSource := "generated"
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
browserFP = s.savedProfile.BrowserFp
browserFPSource = "saved"
}
s.logger().Debugf("[Captcha] browser_fp source=%s len=%d", browserFPSource, len(browserFP))
showType, sliderContent := parseCaptchaInitSession(initResp)
s.logger().Debugf("[Captcha] initSession show_type=%q slider_len=%d", showType, len(sliderContent.Value))
s.warnVersionDrift(page.ScriptURL)
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
if err != nil {
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaScriptVersion)
}
showType := ""
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
}
s.logger().Debugf("[Captcha] solving show_type=%s", showType)
var token string
switch showType {
case "slider":
@@ -257,7 +219,36 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
return token, nil
}
// captchaContentRef несёт значение настроек слайдера.
func parseCaptchaInitSession(raw map[string]any) (string, captchaContentRef) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return "", captchaContentRef{}
}
showType := captchaStringifyAny(resp["show_captcha_type"])
content := captchaContentRef{}
if data, err := json.Marshal(resp["content_settings"]); err == nil {
var settings []captchaInitSetting
if json.Unmarshal(data, &settings) == nil {
for _, setting := range settings {
if setting.Type == "slider" {
content = setting.contentRef()
}
}
}
}
return showType, content
}
func newCaptchaUUID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
type captchaContentRef struct {
Source string
Value string
@@ -315,12 +306,25 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) {
return string(body), nil
}
func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
if cached, ok := captchaDebugCache.Load(scriptURL); ok {
if cachedDebugInfo, ok := cached.(string); ok {
return cachedDebugInfo, nil
func (s *captchaSession) resolveDebugInfo(scriptURL string) string {
if scriptURL != "" {
v, err := s.fetchDebugInfoJS(scriptURL)
if err == nil {
s.logger().Debugf("[Captcha] debug_info from JS len=%d", len(v))
return v
}
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
}
captchaDebugCache.Delete(scriptURL)
}
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
"Accept": "text/javascript,*/*",
@@ -329,87 +333,47 @@ func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
if err != nil {
return "", err
}
v, usedFallback, err := extractDebugInfo(body)
if err != nil {
return "", err
m := reCaptchaDebugInfo.FindSubmatch(body)
if len(m) < 2 {
return "", errors.New("debug_info constant not found in JS")
}
if usedFallback {
s.logger().Warnf("[Captcha] debug_info primary pattern missed; used windowed fallback (script format drift)")
}
captchaDebugCache.Store(scriptURL, v)
s.logger().Debugf("[Captcha] debug_info fetched url=%s", scriptURL)
v := string(m[1])
debugInfoCache.Store(scriptURL, v)
return v, nil
}
// warnVersionDrift предупреждает один раз на версию, если VK отдал скрипт новее
// оттестированного baseline: wire мог измениться, надо перепроверить против live.
func (s *captchaSession) warnVersionDrift(scriptURL string) {
m := reCaptchaVersion.FindStringSubmatch(scriptURL)
if len(m) < 2 || m[1] == "" || m[1] == captchaScriptVersion {
return
func (s *captchaSession) dumpChunks(label, body string) {
const chunk = 1800
total := (len(body) + chunk - 1) / chunk
l := s.logger()
l.Debugf("[Captcha][DUMP] %s bytes=%d chunks=%d", label, len(body), total)
for i := range total {
start := i * chunk
end := min(start+chunk, len(body))
l.Debugf("[Captcha][DUMP %s %d/%d] %s", label, i+1, total, body[start:end])
}
if _, seen := captchaVersionSeen.LoadOrStore(m[1], struct{}{}); seen {
return
}
s.logger().Warnf("[Captcha] script version %s differs from tested %s; wire unverified - re-check if BOT-rejections rise", m[1], captchaScriptVersion)
}
// extractDebugInfo достаёт fallback-константу debug_info из тела скрипта.
// VK-формат: `debug_info:(...window.vk.X)||"<64hex>"`; имя window.vk.X плавает
// от билда к билду, поэтому основной паттерн ловит только hash-литерал.
// usedFallback=true - основной паттерн промазал, взят первый 64-hex в окне после
// `debug_info` (страховка смены обёртки). Иначе - ошибка со снапшотом окна.
func extractDebugInfo(body []byte) (value string, usedFallback bool, err error) {
if m := reCaptchaDebugInfo.FindSubmatch(body); len(m) >= 2 {
return string(m[1]), false, nil
}
idx := bytes.Index(body, []byte("debug_info"))
if idx < 0 {
return "", false, errors.New("debug_info marker not found in script")
}
end := idx + 256
if end > len(body) {
end = len(body)
}
if m := reCaptchaHex64.FindSubmatch(body[idx:end]); len(m) >= 2 {
return string(m[1]), true, nil
}
return "", false, fmt.Errorf("debug_info match not found near: %q", body[idx:end])
}
func parseCaptchaPage(html string) (*captchaPage, error) {
page := &captchaPage{}
match := reCaptchaWindowInit.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha init json not found")
}
var init captchaInit
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
return nil, fmt.Errorf("captcha init json parse: %w", err)
}
page.Init = &init
match = reCaptchaScriptSrc.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha script url not found")
}
page.ScriptURL = match[1]
if m := reCaptchaPowInput.FindStringSubmatch(html); len(m) >= 2 {
page.PowInput = m[1]
}
if page.PowInput == "" {
return page, nil
if m := reCaptchaScriptSrc.FindStringSubmatch(html); len(m) >= 2 {
page.ScriptURL = m[1]
}
match = reCaptchaDifficulty.FindStringSubmatch(html)
if len(match) < 2 {
m := reCaptchaPowInput.FindStringSubmatch(html)
if len(m) < 2 {
return nil, errors.New("captcha powInput not found")
}
page.PowInput = m[1]
dm := reCaptchaDifficulty.FindStringSubmatch(html)
if len(dm) < 2 {
return nil, errors.New("captcha difficulty const not found")
}
difficulty, err := strconv.Atoi(match[1])
difficulty, err := strconv.Atoi(dm[1])
if err != nil || difficulty <= 0 {
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
return nil, fmt.Errorf("invalid captcha difficulty %q", dm[1])
}
page.PowDifficulty = difficulty
return page, nil
@@ -507,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},
@@ -572,26 +531,12 @@ func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
sum := sha256.Sum256(buf)
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return encodeCaptchaPoW(hashHex, nonce)
return hashHex
}
}
return ""
}
func encodeCaptchaPoW(hash string, nonce int) string {
payload, err := json.Marshal(struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
}{
Hash: hash,
Nonce: nonce,
})
if err != nil {
return ""
}
return "v2." + base64.RawURLEncoding.EncodeToString(payload)
}
func (s *captchaSession) doRaw(
method string,
endpoint string,
@@ -638,31 +583,6 @@ func (s *captchaSession) doRaw(
return data, readErr
}
func (s *captchaSession) logCaptchaPage(page *captchaPage) {
showType := ""
settingsSummary := "none"
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
parts := make([]string, 0, len(page.Init.Data.CaptchaSettings))
for _, setting := range page.Init.Data.CaptchaSettings {
ref := setting.contentRef()
valueLen := 0
if ref.Value != "" {
valueLen = len(ref.Value)
}
parts = append(parts, fmt.Sprintf("%s:%s:%d", setting.Type, ref.Source, valueLen))
}
if len(parts) > 0 {
settingsSummary = strings.Join(parts, ",")
}
}
scriptVersion := ""
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
scriptVersion = m[1]
}
s.logger().Debugf("[Captcha] page parsed show_type=%q settings=%s pow_difficulty=%d script_version=%q script_url=%s", showType, settingsSummary, page.PowDifficulty, scriptVersion, captchaSafeURL(page.ScriptURL))
}
func captchaAPIResponseSummary(raw map[string]any) string {
if errData, ok := raw["error"].(map[string]any); ok {
return fmt.Sprintf("error code=%s msg=%q keys=%s", captchaStringifyAny(errData["error_code"]), captchaStringifyAny(errData["error_msg"]), captchaMapKeys(errData))
@@ -1,7 +1,7 @@
package captcha
import (
"encoding/base64"
"context"
"encoding/json"
"strings"
"testing"
@@ -34,99 +34,73 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
}
}
func TestParseCaptchaPageSettingsKey(t *testing.T) {
html := `<html><head><script src="https://static.vk.ru/vkid/1.1.1359/not_robot_captcha.js"></script></head><body>
<script>
window.init = {"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"abc123"}]}};
const powInput = "input";
func TestParseCaptchaPageSPA(t *testing.T) {
html := `<html><head><script>
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script>
</body></html>`
<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 {
t.Fatal(err)
}
if page.Init == nil || len(page.Init.Data.CaptchaSettings) != 1 {
t.Fatalf("captcha settings missing: %+v", page.Init)
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
}
got := page.Init.Data.CaptchaSettings[0].contentRef()
if got.Source != "settings_key" || got.Value != "abc123" {
t.Fatalf("contentRef = %+v, want settings_key/abc123", got)
if page.ScriptURL != "https://id.vk.ru/static/vkid/1.1.1374/not_robot_captcha.js" {
t.Fatalf("script url = %q", page.ScriptURL)
}
}
func TestEncodeCaptchaPoW(t *testing.T) {
got := encodeCaptchaPoW("00abcdef", 147)
if !strings.HasPrefix(got, "v2.") {
t.Fatalf("pow = %q, want v2 prefix", got)
}
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got, "v2."))
if err != nil {
t.Fatal(err)
}
var decoded struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Hash != "00abcdef" || decoded.Nonce != 147 {
t.Fatalf("decoded = %+v, want hash/nonce", decoded)
func TestParseCaptchaPageMissingPoW(t *testing.T) {
if _, err := parseCaptchaPage(`<html><body><div id="spa_root"></div></body></html>`); err == nil {
t.Fatal("expected error when powInput/difficulty absent")
}
}
func TestExtractDebugInfo(t *testing.T) {
const hash = "59f60d917b13be6a22c076adb2c17df37302c5314d8353a27e72f9fbcc9b4838"
tests := []struct {
name string
body string
want string
wantFallback bool
fail bool
}{
{
name: "primary vk format",
body: `x,debug_info:(null===(r=window.vk)||void 0===r?void 0:r.brlefapmjnpg)||"` + hash + `"});var a`,
want: hash,
},
{
name: "windowed fallback no or-wrapper",
body: `debug_info: someRuntimeCall(),foo:"` + hash + `"`,
want: hash,
wantFallback: true,
},
{
name: "no hash near marker",
body: `debug_info:window.vk.x||"tooshort"`,
fail: true,
},
{
name: "no marker",
body: `nothing here`,
fail: true,
},
func TestSolveCaptchaPoWRawHex(t *testing.T) {
got := solveCaptchaPoW(context.Background(), "input", 1)
if len(got) != 64 {
t.Fatalf("pow = %q, want 64-hex", got)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, usedFallback, err := extractDebugInfo([]byte(tt.body))
if tt.fail {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("extractDebugInfo = %q, want %q", got, tt.want)
}
if usedFallback != tt.wantFallback {
t.Fatalf("usedFallback = %v, want %v", usedFallback, tt.wantFallback)
}
})
if !strings.HasPrefix(got, "0") {
t.Fatalf("pow = %q, want leading zero for difficulty 1", got)
}
if again := solveCaptchaPoW(context.Background(), "input", 1); again != got {
t.Fatalf("pow not deterministic: %q vs %q", got, again)
}
}
func TestParseCaptchaInitSession(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"show_captcha_type": "slider",
"captcha_id": "cid",
"content_settings": []any{
map[string]any{"type": "slider", "settings_key": "sliderkey"},
map[string]any{"type": "sound", "settings_key": "soundkey"},
},
}}
showType, content := parseCaptchaInitSession(raw)
if showType != "slider" {
t.Fatalf("show_type = %q, want slider", showType)
}
if content.Value != "sliderkey" || content.Source != "settings_key" {
t.Fatalf("content = %+v, want sliderkey/settings_key", content)
}
}
func TestParseCaptchaInitSessionCheckbox(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"show_captcha_type": "checkbox",
"content_settings": []any{
map[string]any{"type": "slider", "settings_key": "k"},
},
}}
showType, _ := parseCaptchaInitSession(raw)
if showType != "checkbox" {
t.Fatalf("show_type = %q, want checkbox", showType)
}
}
@@ -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"
@@ -11,7 +10,7 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
func (c *Client) defaultAutoSolve(
func (*Client) defaultAutoSolve(
ctx context.Context,
captchaErr *captcha.Error,
streamID int,
@@ -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
+13 -23
View File
@@ -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)
+2 -18
View File
@@ -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 {
+5 -30
View File
@@ -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,9 @@ type TurnCredentials struct {
Link string
}
// DefaultCredentials - публичные app_id/secret официальных VK-клиентов. Это НЕ
// приватные креды пользователя - VK раздаёт их в JS-бандле страницы калла.
// Клиент перебирает по порядку при ошибках авторизации. В списке только app_id
// с доступом к calls.getAnonymousToken (проверено живым звонком); приложения
// без calls-scope ("Unknown method passed") исключены.
// Публичные app_id/secret штатных VK-клиентов (зашиты в сами приложения), не наши приватные креды.
//
//nolint:gosec // public VK SDK app credentials, not user secrets
//nolint:gosec // G101: публичные VK app secrets
var DefaultCredentials = []VKCredentials{
{ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
@@ -37,8 +31,6 @@ var DefaultCredentials = []VKCredentials{
{ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"}, // VK_IPHONE_APP
}
// APIVersion - версия VK API во всех calls.* запросах. Держать единой и близкой
// к версии живого web-клиента звонка (рассинхрон = fingerprint-аномалия).
const APIVersion = "5.282"
const (
@@ -50,28 +42,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"))
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
)
+7 -23
View File
@@ -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")