diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3e4d7de..ecd517f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,6 +69,10 @@ jobs:
goarch: arm64
cgo: 1
api: 21
+ - goos: android
+ goarch: arm
+ cgo: 1
+ api: 21
# you can add more (android/arm, windows, etc.) later
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f4247cd..306a2c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,6 +27,21 @@ jobs:
goarch: arm
goarm: '7'
cgo: 0
+ - goos: linux
+ goarch: mipsle
+ gomips: softfloat
+ cgo: 0
+ - goos: linux
+ goarch: mips
+ gomips: softfloat
+ cgo: 0
+ - goos: linux
+ goarch: mips64le
+ gomips: softfloat
+ cgo: 0
+ - goos: linux
+ goarch: riscv64
+ cgo: 0
- goos: darwin
goarch: amd64
cgo: 0
@@ -36,6 +51,9 @@ jobs:
- goos: windows
goarch: amd64
cgo: 0
+ - goos: windows
+ goarch: arm64
+ cgo: 0
- goos: windows
goarch: 386
cgo: 0
@@ -43,6 +61,10 @@ jobs:
goarch: arm64
cgo: 1
api: 21
+ - goos: android
+ goarch: arm
+ cgo: 1
+ api: 21
- goos: freebsd
goarch: amd64
cgo: 0
@@ -69,6 +91,7 @@ jobs:
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
+ GOMIPS: ${{ matrix.gomips }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: ${{ matrix.cgo }}
ANDROID_API: ${{ matrix.api }}
@@ -87,6 +110,8 @@ jobs:
if [ "$GOARCH" = "arm64" ]; then
export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang"
+ elif [ "$GOARCH" = "arm" ]; then
+ export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang"
else
echo "Unsupported ANDROID GOARCH=$GOARCH"
exit 1
@@ -104,12 +129,12 @@ jobs:
EXT=
fi
- GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \
+ GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \
go build -ldflags "-s -w -checklinkname=0" -trimpath \
-o "dist/client-${GOOS}-${GOARCH}${EXT}" \
./client
- GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \
+ GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \
go build -ldflags "-s -w -checklinkname=0" -trimpath \
-o "dist/server-${GOOS}-${GOARCH}${EXT}" \
./server
diff --git a/README.en.md b/README.en.md
new file mode 100644
index 0000000..f665b81
--- /dev/null
+++ b/README.en.md
@@ -0,0 +1,269 @@
+# VK/WB TURN Proxy
+[Russian version](README.md)
+
+Tunnels WireGuard/Hysteria traffic through VK Calls or WB Stream TURN servers. Packets are encrypted with DTLS 1.2 and then sent in parallel streams via TCP or UDP to the TURN server using the STUN ChannelData protocol. From there, they are forwarded via UDP to your server, decrypted, and passed to WireGuard. TURN credentials are generated from the meeting link.
+
+## Features
+
+- **VK Calls** — TURN credentials from VK API with automatic captcha solving (Not Robot)
+- **WB Stream** — TURN credentials from WB Stream API (LiveKit ICE)
+- **Caching** — 10 minute TTL with shared cache across 4 streams
+- **DTLS obfuscation** — DPI bypass via DTLS tunnel
+- **Multiple connections** — up to N parallel connections to TURN
+
+**Update: Multi-user Proxy Server**
+The current implementation supports multiple simultaneous users through a single proxy server.
+- **Session Identification:** The client generates a unique 16-byte UUID at startup.
+- **Stream Aggregation:** The server groups all incoming DTLS connections from a single client by its UUID.
+- **Stable Backend:** For each session, exactly one UDP connection is created to the WireGuard server. This prevents the "endpoint thrashing" issue and increases stability.
+- **Load Balancing:** Outgoing traffic from the server to the client is distributed among all active DTLS streams of the user (Round-Robin).
+
+For educational purposes only!
+
+## Client Structure
+
+```
+client/
+├── credentials.go # Shared credentials cache, request serialization
+├── vk.go # VK API: Token 1→4 chain, HTTP requests
+├── vk_captcha.go # VK Captcha: PoW solving, Not Robot flow
+├── wb.go # WB Stream: guest register → room → LiveKit ICE
+└── main.go # DTLS/TURN connections, CLI, main loop
+```
+
+## Setup
+
+You will need:
+1. A link to an active VK call: create your own (requires a VK account) or search for `"https://vk.com/call/join/"`. Links are valid forever unless "end call for all" is clicked.
+2. A VPS with WireGuard installed.
+3. For Android: Download Termux from F-Droid.
+
+### Server
+
+```bash
+./server -listen 0.0.0.0:56000 -connect 127.0.0.1:
+```
+
+### Client
+
+#### Android
+
+**Recommended method:**
+Use the native Android app [wireguard-turn-android](https://github.com/kiper292/wireguard-turn-android). This is a modified WireGuard client with built-in TURN support.
+
+**Alternative method (via Termux):**
+- In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280.
+- **Add Termux to WireGuard exceptions. Click "Save".**
+
+In Termux:
+```bash
+termux-wake-lock
+```
+The phone will not enter deep sleep. To disable:
+```bash
+termux-wake-unlock
+```
+Copy the binary to a local folder and grant execution rights:
+```bash
+cp /sdcard/Download/client-android ./
+chmod 777 ./client-android
+```
+
+**VK mode:**
+```bash
+./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000
+```
+
+**WB mode:**
+```bash
+./client-android -wb -peer :56000 -listen 127.0.0.1:9000
+```
+
+Additional flags:
+- `-session-id `: set a fixed session ID (32 hex characters).
+- `-n `: number of connections to TURN (default 4).
+- `-udp`: use UDP for TURN (default TCP).
+- `-turn `: override TURN server address.
+- `-port `: override TURN server port.
+- `-no-dtls`: without DTLS obfuscation (may result in a ban).
+- `-v1`: use v1 protocol (no session_id and stream_id sent). For legacy servers.
+
+#### Linux
+
+In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280.
+
+The script will add routes to the necessary IPs:
+
+```bash
+./client-linux -peer :56000 -vk-link -listen 127.0.0.1:9000 | sudo routes.sh
+```
+
+```bash
+./client-linux -wb -peer :56000 -listen 127.0.0.1:9000 | sudo routes.sh
+```
+
+⚠️ Do not enable the VPN until the program has established a connection! Unlike Android, some requests will go through the VPN here (DNS and TURN connection requests).
+
+#### Windows
+
+In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280.
+
+In PowerShell as Administrator (so the script can add routes):
+
+```powershell
+./client.exe -peer :56000 -vk-link -listen 127.0.0.1:9000 | routes.ps1
+```
+
+```powershell
+./client.exe -wb -peer :56000 -listen 127.0.0.1:9000 | routes.ps1
+```
+
+⚠️ Do not enable the VPN until the program has established a connection! Unlike Android, some requests will go through the VPN here (DNS and TURN connection requests).
+
+### If it doesn't work
+
+Use the `-turn` option to manually specify a TURN server address. This should be a VK, Max, or Odnoklassniki server (VK link) or WB Stream (WB mode).
+
+If TCP doesn't work, try adding the `-udp` flag.
+
+Add `-n 1` for a more stable single-stream connection (limited to 5 Mbps for VK).
+
+## VK Auth Flow
+
+1. **Token 1** — anonymous token (`login.vk.ru`)
+2. **getCallPreview** — call preview (optional)
+3. **Token 2** — anonymous token for the call (`api.vk.ru`)
+ - On captcha → PoW solving → retry
+4. **Token 3** — OK session key (`calls.okcdn.ru`)
+5. **Token 4** — TURN credentials (`calls.okcdn.ru`)
+
+## WB Auth Flow
+
+1. **Guest register** — guest registration (`stream.wb.ru`)
+2. **Create room** — create a room
+3. **Join room** — join the room
+4. **Get token** — get roomToken
+5. **LiveKit ICE** — WebSocket to LiveKit, protobuf TURN parsing
+
+## Caching
+
+- TTL: **10 minutes** (safety margin 60 seconds)
+- One cache per **4 streams** (`streamID / 4`)
+- Fast path via `RLock`
+- Fetch serialization via global `fetchMu`
+
+## v2ray
+
+Instead of WireGuard, you can use any V2Ray core that supports it (e.g., xray or sing-box) and any V2Ray client that uses this core (e.g., v2rayN or v2rayNG). This allows you to add more inbound interfaces (e.g., SOCKS) and implement fine-grained routing.
+
+Example configs:
+
+
+
+
+Client
+
+
+```json
+{
+ "inbounds": [
+ {
+ "protocol": "socks",
+ "listen": "127.0.0.1",
+ "port": 1080,
+ "settings": {
+ "udp": true
+ },
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ]
+ }
+ },
+ {
+ "protocol": "http",
+ "listen": "127.0.0.1",
+ "port": 8080,
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ]
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "wireguard",
+ "settings": {
+ "secretKey": "",
+ "peers": [
+ {
+ "endpoint": "127.0.0.1:9000",
+ "publicKey": ""
+ }
+ ],
+ "domainStrategy": "ForceIPv4",
+ "mtu": 1280
+ }
+ }
+ ]
+}
+```
+
+
+
+
+
+
+Server
+
+
+```json
+{
+ "inbounds": [
+ {
+ "protocol": "wireguard",
+ "listen": "0.0.0.0",
+ "port": 51820,
+ "settings": {
+ "secretKey": "",
+ "peers": [
+ {
+ "publicKey": ""
+ }
+ ],
+ "mtu": 1280
+ },
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ]
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {
+ "domainStrategy": "UseIPv4"
+ }
+ }
+ ]
+}
+```
+
+
+
+## Direct mode
+
+With the `-no-dtls` flag, you can send packets without DTLS obfuscation and connect to regular WireGuard servers. This may result in a ban from VK/WB.
+
+Thanks to https://github.com/KillTheCensorship/Turnel for part of the code :)
+
+WB Stream functionality is based on https://github.com/jaykaiperson/lionheart
diff --git a/README.md b/README.md
index 8d56e74..54155fd 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,61 @@
-# Good TURN
-Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или Яндекс телемоста. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок.
+# VK/WB TURN Proxy
+[English version](README.en.md)
+
+Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или WB Stream. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок.
+
+## Возможности
+
+- **VK Calls** — TURN credentials от VK API с автоматическим решением капчи (Not Robot)
+- **WB Stream** — TURN credentials от WB Stream API (LiveKit ICE)
+- **Кеширование** — 10 минут с общим кешем на 4 потока
+- **DTLS-обфускация** — обход DPI через DTLS-туннель
+- **Множественные соединения** — до N параллельных подключений к TURN
+
+**Обновление: Многопользовательский прокси-сервер**
+Текущая реализация поддерживает одновременную работу нескольких пользователей через один прокси-сервер.
+- **Идентификация сессий:** Клиент генерирует уникальный 16-байтный UUID при старте.
+- **Агрегация потоков:** Сервер группирует все входящие DTLS-соединения от одного клиента по его UUID.
+- **Стабильный бэкенд:** Для каждой сессии создается ровно одно UDP-соединение с WireGuard-сервером. Это предотвращает проблему "прыгающих портов" (endpoint thrashing) и повышает стабильность.
+- **Балансировка:** Исходящий трафик от сервера к клиенту распределяется между всеми активными DTLS-потоками пользователя (Round-Robin).
Только для учебных целей!
+
+## Структура клиента
+
+```
+client/
+├── credentials.go # Общий кеш credentials, сериализация запросов
+├── vk.go # VK API: Token 1→4 chain, HTTP-запросы
+├── vk_captcha.go # VK Captcha: PoW solving, Not Robot flow
+├── wb.go # WB Stream: guest register → room → LiveKit ICE
+└── main.go # DTLS/TURN соединения, CLI, основной цикл
+```
+
## Настройка
+
Нам понадобится:
1. Ссылка на действующий ВК звонок: создаём свой (нужен аккаунт вк), или гуглим `"https://vk.com/call/join/"`.
-Ссылка действительна вечно, если не нажимать "завершить звонок для всех"
-2. Или ссыска на звонок Яндекс телемоста: `"https://telemost.yandex.ru/j/"`. Её лучше не гуглить, так как видно подключение к конференции
-3. VPS с установленным WireGuard
-4. Для андроида: скачать Termux из F-Droid
+ Ссылка действительна вечно, если не нажимать "завершить звонок для всех"
+2. VPS с установленным WireGuard
+3. Для андроида: скачать Termux из F-Droid
+
### Сервер
+
```
./server -listen 0.0.0.0:56000 -connect 127.0.0.1:<порт wg>
```
+
### Клиент
+
#### Android
**Рекомендуемый способ:**
-Использовать нативное Android-приложение [vk-turn-proxy-android](https://github.com/MYSOREZ/vk-turn-proxy-android).
-- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
-- **Добавляем приложение в исключения WireGuard. Нажимаем "сохранить".**
+Использовать нативное Android-приложение [wireguard-turn-android](https://github.com/kiper292/wireguard-turn-android). Это модифицированный WireGuard клиент со встроенным TURN.
**Альтернативный способ (через Termux):**
- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
-- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".**
+- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".**
+
В Termux:
```
termux-wake-lock
@@ -37,17 +69,28 @@ termux-wake-unlock
cp /sdcard/Download/client-android ./
chmod 777 ./client-android
```
-Запускаем:
+
+**VK режим:**
```
./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000
```
-Или
+
+**WB режим:**
```
-./client-android -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000
+./client-android -wb -peer :56000 -listen 127.0.0.1:9000
```
-**Если после включения VPN в терминале вылезают ошибки DNS, попробуйте в Wireguard включить VPN только для нужных приложений.**
+Дополнительные флаги:
+- `-session-id `: установить фиксированный ID сессии (32 символа hex).
+- `-n <число>`: количество подключений к TURN (по умолчанию 4).
+- `-udp`: использовать UDP для TURN (по умолчанию TCP).
+- `-turn `: переопределить адрес TURN сервера.
+- `-port `: переопределить порт TURN сервера.
+- `-no-dtls`: без DTLS-обфускации (может привести к бану).
+- `-v1`: использовать протокол v1 (без отправки session_id и stream_id). Для серверов старой версии.
+
#### Linux
+
В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
Скрипт будет добавлять маршруты к нужным ip:
@@ -57,11 +100,13 @@ chmod 777 ./client-android
```
```
-./client-linux -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | sudo routes.sh
+./client-linux -wb -peer :56000 -listen 127.0.0.1:9000 | sudo routes.sh
```
Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
+
#### Windows
+
В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
В PowerShell от Администратора (чтобы скрипт прописывал маршруты):
@@ -71,40 +116,42 @@ chmod 777 ./client-android
```
```
-./client.exe -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | routes.ps1
+./client.exe -wb -peer :56000 -listen 127.0.0.1:9000 | routes.ps1
```
-Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
+Не включайте впн, пока программа не установит соединение! В отличие от андродида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
+
### Если не работает
-С помощью опции `-turn` можно указать адрес TURN сервера вручную. Это должен быть сервер ВК, Макса или Одноклассников (ссылка вк) или Яндекса (ссылка яндекса). Возможно потом составлю список.
+
+С помощью опции `-turn` можно указать адрес TURN сервера вручную. Это должен быть сервер ВК, Макса или Одноклассников (ссылка вк) или WB Stream (режим -wb).
Если не работает TCP, попробуйте добавить флаг `-udp`.
Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 Мбит/с для ВК)
-## Яндекс телемост
-**UPD. ТЕЛЕМОСТ ЗАКРЫЛИ**
+## VK Auth Flow
-В отличие от ВК, сервера яндекса не ограничивают скорость, так что по умолчанию стоит `-n 1`. Увеличение этого числа может привести к временной блокировке по IP из-за переполнения конференции фейковыми участниками.
+1. **Token 1** — анонимный токен (`login.vk.ru`)
+2. **getCallPreview** — превью звонка (опционально)
+3. **Token 2** — анонимный токен для звонка (`api.vk.ru`)
+ - При капче → PoW solving → retry
+4. **Token 3** — OK session key (`calls.okcdn.ru`)
+5. **Token 4** — TURN credentials (`calls.okcdn.ru`)
-В режиме `-udp` скорость обычно больше
+## WB Auth Flow
-Большинство диапазонов IP TURN серверов Яндекса не работают, указывайте вручную через `-turn`
-
-
- Рабочие IP
-
+1. **Guest register** — регистрация гостя (`stream.wb.ru`)
+2. **Create room** — создание комнаты
+3. **Join room** — подключение к комнате
+4. **Get token** — получение roomToken
+5. **LiveKit ICE** — WebSocket к LiveKit, protobuf парсинг TURN
+## Кеширование
- 5.255.211.241
- 5.255.211.242
- 5.255.211.243
- 5.255.211.245
- 5.255.211.246
-
-
-
-Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :)
+- TTL: **10 минут** (safety margin 60 секунд)
+- Один кеш на **4 потока** (`streamID / 4`)
+- Fast path через `RLock`
+- Сериализация fetch через глобальный `fetchMu`
## v2ray
@@ -215,4 +262,9 @@ chmod 777 ./client-android
## Direct mode
-С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от вк/яндекса.
+
+С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от ВК/WB.
+
+Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :)
+
+Функционал WB Stream основан на проекте https://github.com/jaykaiperson/lionheart
diff --git a/client/credentials.go b/client/credentials.go
new file mode 100644
index 0000000..9b0a2e2
--- /dev/null
+++ b/client/credentials.go
@@ -0,0 +1,151 @@
+// SPDX-FileCopyrightText: 2023 The Pion community
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "context"
+ "log"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// getCredsFunc is the signature for credential retrieval functions
+type getCredsFunc func(context.Context, string, int) (string, string, string, error)
+
+// TurnCredentials stores cached TURN credentials
+type TurnCredentials struct {
+ Username string
+ Password string
+ ServerAddr string
+ ExpiresAt time.Time
+ Link string
+}
+
+// StreamCredentialsCache holds credentials cache for a single stream
+type StreamCredentialsCache struct {
+ creds TurnCredentials
+ mutex sync.RWMutex
+ errorCount atomic.Int32
+ lastErrorTime atomic.Int64
+}
+
+const (
+ credentialLifetime = 10 * time.Minute
+ cacheSafetyMargin = 60 * time.Second
+ maxCacheErrors = 3
+ errorWindow = 10 * time.Second
+ streamsPerCache = 4 // Number of streams sharing one credentials cache
+)
+
+// getCacheID returns the shared cache ID for a given stream ID
+func getCacheID(streamID int) int {
+ return streamID / streamsPerCache
+}
+
+// credentialsStore manages per-stream credentials caches
+var credentialsStore = struct {
+ mu sync.RWMutex
+ caches map[int]*StreamCredentialsCache
+}{
+ caches: make(map[int]*StreamCredentialsCache),
+}
+
+// getStreamCache returns or creates a shared cache for the given stream ID
+func getStreamCache(streamID int) *StreamCredentialsCache {
+ cacheID := getCacheID(streamID)
+
+ // Try read lock first for fast path
+ credentialsStore.mu.RLock()
+ cache, exists := credentialsStore.caches[cacheID]
+ credentialsStore.mu.RUnlock()
+
+ if exists {
+ return cache
+ }
+
+ // Need to create new cache
+ credentialsStore.mu.Lock()
+ defer credentialsStore.mu.Unlock()
+
+ // Double-check after acquiring write lock
+ if cache, exists = credentialsStore.caches[cacheID]; exists {
+ return cache
+ }
+
+ cache = &StreamCredentialsCache{}
+ credentialsStore.caches[cacheID] = cache
+ return cache
+}
+
+// invalidate invalidates the credentials cache for this stream
+func (c *StreamCredentialsCache) invalidate(streamID int) {
+ c.mutex.Lock()
+ c.creds = TurnCredentials{}
+ c.mutex.Unlock()
+
+ // Reset auth error counter
+ c.errorCount.Store(0)
+ c.lastErrorTime.Store(0)
+
+ log.Printf("[Auth] Credentials cache invalidated for stream %d", streamID)
+}
+
+// fetchMu serializes credential fetching to avoid API rate limiting
+var fetchMu sync.Mutex
+
+// fetchFunc is the signature for credential retrieval functions (without cache logic)
+type fetchFunc func(ctx context.Context, link string) (string, string, string, error)
+
+// serializeFetch wraps a fetch call with the global fetchMu to avoid API rate limiting
+func serializeFetch(ctx context.Context, link string, storeFn fetchFunc) (string, string, string, error) {
+ fetchMu.Lock()
+ defer fetchMu.Unlock()
+ return storeFn(ctx, link)
+}
+
+// getCredsCached checks cache before fetching credentials.
+// This is the general entry point for credential retrieval with caching.
+func getCredsCached(ctx context.Context, link string, streamID int, storeFn fetchFunc) (string, string, string, error) {
+ cache := getStreamCache(streamID)
+ cacheID := getCacheID(streamID)
+
+ cache.mutex.Lock()
+ defer cache.mutex.Unlock()
+
+ // Check cache - another stream may have populated it while waiting
+ if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) {
+ expires := time.Until(cache.creds.ExpiresAt)
+ log.Printf("[Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires)
+ return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil
+ }
+
+ log.Printf("[Auth] Cache miss (cache=%d), starting credential fetch...", cacheID)
+
+ // Check context before long fetch
+ select {
+ case <-ctx.Done():
+ return "", "", "", ctx.Err()
+ default:
+ }
+
+ // Fetch credentials with global mutex to avoid API rate limiting
+ user, pass, addr, err := serializeFetch(ctx, link, storeFn)
+
+ if err != nil {
+ return "", "", "", err
+ }
+
+ // Store in cache
+ cache.creds = TurnCredentials{
+ Username: user,
+ Password: pass,
+ ServerAddr: addr,
+ ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin),
+ Link: link,
+ }
+
+ log.Printf("[Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID)
+ return user, pass, addr, nil
+}
diff --git a/client/main.go b/client/main.go
index e4de0f9..d971129 100644
--- a/client/main.go
+++ b/client/main.go
@@ -6,428 +6,886 @@ package main
import (
"bytes"
"context"
+ "crypto/md5"
+ "crypto/sha256"
"crypto/tls"
+ "encoding/base64"
+ "encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
+ "math/rand"
"net"
- "net/http"
+ neturl "net/url"
"os"
"os/signal"
+ "strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
+ fhttp "github.com/bogdanfinn/fhttp"
+ tlsclient "github.com/bogdanfinn/tls-client"
+ "github.com/bogdanfinn/tls-client/profiles"
+
+ "github.com/bschaatsbergen/dnsdialer"
"github.com/cbeuw/connutil"
"github.com/google/uuid"
- "github.com/gorilla/websocket"
"github.com/pion/dtls/v3"
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
"github.com/pion/logging"
"github.com/pion/turn/v5"
)
-type getCredsFunc func(string) (string, string, string, error)
+// Global state trackers
+var (
+ globalCaptchaLockout atomic.Int64
+ isDebug bool
+ manualCaptcha bool
+ autoCaptchaSliderPOC bool
+ globalAppCancel context.CancelFunc
+)
-func getVkCreds(link string) (string, string, string, error) {
- doRequest := func(data string, url string) (resp map[string]interface{}, err error) {
- client := &http.Client{
- Timeout: 20 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 100,
- IdleConnTimeout: 90 * time.Second,
+type captchaSolveMode int
+
+const (
+ captchaSolveModeAuto captchaSolveMode = iota
+ captchaSolveModeSliderPOC
+ captchaSolveModeManual
+)
+
+func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
+ if manualOnly {
+ return captchaSolveModeManual, attempt == 0
+ }
+
+ switch attempt {
+ case 0:
+ return captchaSolveModeAuto, true
+ case 1:
+ if enableSliderPOC {
+ return captchaSolveModeSliderPOC, true
+ }
+ return captchaSolveModeManual, true
+ case 2:
+ if enableSliderPOC {
+ return captchaSolveModeManual, true
+ }
+ }
+
+ return 0, false
+}
+
+func captchaSolveModeLabel(mode captchaSolveMode) string {
+ switch mode {
+ case captchaSolveModeAuto:
+ return "auto captcha"
+ case captchaSolveModeSliderPOC:
+ return "auto captcha slider POC"
+ case captchaSolveModeManual:
+ return "manual captcha"
+ default:
+ return "captcha"
+ }
+}
+
+// region Helper: HTTP Headers Injection
+
+func applyBrowserProfileFhttp(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")
+ req.Header.Set("DNT", "1")
+}
+
+func generateBrowserFp(profile Profile) string {
+ data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10)
+ h := md5.Sum([]byte(data))
+ return hex.EncodeToString(h[:])
+}
+
+func getCustomNetDialer() net.Dialer {
+ return net.Dialer{
+ Timeout: 20 * time.Second,
+ KeepAlive: 30 * time.Second,
+ Resolver: &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ var d net.Dialer
+ dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
+ var lastErr error
+ for _, dns := range dnsServers {
+ conn, err := d.DialContext(ctx, "udp", dns)
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ return nil, lastErr
},
+ },
+ }
+}
+
+func generateFakeCursor() string {
+ startX := 600 + rand.Intn(400)
+ startY := 300 + rand.Intn(200)
+ startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000)
+ var points []string
+ for i := 0; i < 15+rand.Intn(10); i++ {
+ startX += rand.Intn(15) - 5
+ startY += rand.Intn(15) + 2
+ startTime += int64(rand.Intn(40) + 10)
+ points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime))
+ }
+ return "[" + strings.Join(points, ",") + "]"
+}
+
+// endregion
+
+// region Automatic Captcha Solver & Authentication
+
+type VkCaptchaError struct {
+ ErrorCode int
+ ErrorMsg string
+ CaptchaSid string
+ CaptchaImg string
+ RedirectURI string
+ IsSoundCaptchaAvailable bool
+ SessionToken string
+ CaptchaTs string
+ CaptchaAttempt string
+}
+
+func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
+ // Extract error_code
+ codeFloat, ok := errData["error_code"].(float64)
+ if !ok {
+ log.Printf("missing error_code in captcha error data")
+ return nil
+ }
+ code := int(codeFloat)
+
+ // Extract redirect_uri
+ RedirectURI, ok := errData["redirect_uri"].(string)
+ if !ok {
+ log.Printf("missing redirect_uri in captcha error data")
+ return nil
+ }
+
+ // Extract captcha_sid
+ captchaSid, ok := errData["captcha_sid"].(string)
+ if !ok {
+ // try numeric
+ if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
+ captchaSid = fmt.Sprintf("%.0f", sidNum)
+ } else {
+ log.Printf("missing captcha_sid in captcha error data")
+ return nil
}
- defer client.CloseIdleConnections()
- req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data)))
+ }
+
+ // Extract captcha_img
+ captchaImg, ok := errData["captcha_img"].(string)
+ if !ok {
+ log.Printf("missing captcha_img in captcha error data")
+ return nil
+ }
+
+ // Extract error_msg
+ errorMsg, ok := errData["error_msg"].(string)
+ if !ok {
+ log.Printf("missing error_msg in captcha error data")
+ return nil
+ }
+
+ // Extract session token if redirect_uri present
+ var sessionToken string
+ if RedirectURI != "" {
+ if parsed, err := neturl.Parse(RedirectURI); err == nil {
+ sessionToken = parsed.Query().Get("session_token")
+ } else {
+ log.Printf("failed to parse redirect_uri: %v", err)
+ return nil
+ }
+ }
+
+ // Extract is_sound_captcha_available
+ isSound, ok := errData["is_sound_captcha_available"].(bool)
+ if !ok {
+ isSound = false
+ }
+
+ // Extract captcha_ts
+ var captchaTs string
+ if tsFloat, ok := errData["captcha_ts"].(float64); ok {
+ captchaTs = fmt.Sprintf("%.0f", tsFloat)
+ } else if tsStr, ok := errData["captcha_ts"].(string); ok {
+ captchaTs = tsStr
+ }
+
+ // Extract captcha_attempt
+ var captchaAttempt string
+ if attFloat, ok := errData["captcha_attempt"].(float64); ok {
+ captchaAttempt = fmt.Sprintf("%.0f", attFloat)
+ } else if attStr, ok := errData["captcha_attempt"].(string); ok {
+ captchaAttempt = attStr
+ }
+
+ // Build VkCaptchaError
+ return &VkCaptchaError{
+ ErrorCode: code,
+ ErrorMsg: errorMsg,
+ CaptchaSid: captchaSid,
+ CaptchaImg: captchaImg,
+ RedirectURI: RedirectURI,
+ IsSoundCaptchaAvailable: isSound,
+ SessionToken: sessionToken,
+ CaptchaTs: captchaTs,
+ CaptchaAttempt: captchaAttempt,
+ }
+}
+
+func (e *VkCaptchaError) IsCaptchaError() bool {
+ return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != ""
+}
+
+func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) {
+ if useSliderPOC {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID)
+ } else {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID)
+ }
+
+ if captchaErr.SessionToken == "" {
+ return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
+ }
+ if captchaErr.RedirectURI == "" {
+ return "", fmt.Errorf("no redirect_uri for auto-solve")
+ }
+
+ bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
+ if err != nil {
+ return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
+ }
+
+ log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
+
+ hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
+ log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash)
+
+ var successToken string
+ if useSliderPOC {
+ successToken, err = callCaptchaNotRobotWithSliderPOC(
+ ctx,
+ captchaErr.SessionToken,
+ hash,
+ streamID,
+ client,
+ profile,
+ bootstrap.Settings,
+ )
+ } else {
+ successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile)
+ }
+ if err != nil {
+ return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
+ }
+
+ log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID)
+ return successToken, nil
+}
+
+func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) {
+ parsedURL, err := neturl.Parse(redirectURI)
+ if err != nil {
+ return nil, err
+ }
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Sec-Fetch-Site", "none")
+ req.Header.Set("Sec-Fetch-Mode", "navigate")
+ req.Header.Set("Sec-Fetch-Dest", "document")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return parseCaptchaBootstrapHTML(string(body))
+}
+
+func solvePoW(powInput string, difficulty int) string {
+ target := strings.Repeat("0", difficulty)
+ for nonce := 1; nonce <= 10000000; nonce++ {
+ data := powInput + strconv.Itoa(nonce)
+ hash := sha256.Sum256([]byte(data))
+ hexHash := hex.EncodeToString(hash[:])
+ if strings.HasPrefix(hexHash, target) {
+ return hexHash
+ }
+ }
+ return ""
+}
+
+func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) {
+ vkReq := func(method string, postData string) (map[string]interface{}, error) {
+ reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
+ parsedURL, err := neturl.Parse(reqURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse request URL: %w", err)
+ }
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData))
if err != nil {
return nil, err
}
- req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0")
- req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Origin", "https://id.vk.ru")
+ req.Header.Set("Referer", "https://id.vk.ru/")
+ req.Header.Set("Sec-Fetch-Site", "same-site")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Sec-GPC", "1")
+ req.Header.Set("Priority", "u=1, i")
httpResp, err := client.Do(req)
if err != nil {
return nil, err
}
- defer httpResp.Body.Close()
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(httpResp.Body)
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
-
- err = json.Unmarshal(body, &resp)
- if err != nil {
+ var resp map[string]interface{}
+ if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
-
return resp, nil
}
- var resp map[string]interface{}
- defer func() {
- if r := recover(); r != nil {
- log.Panicf("get TURN creds error: %v\n\n", resp)
- }
- }()
+ baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken))
- data := "client_secret=QbYic1K3lEV5kTGiqlq2&client_id=6287487&scopes=audio_anonymous%2Cvideo_anonymous%2Cphotos_anonymous%2Cprofile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=6287487"
- url := "https://login.vk.ru/?act=get_anonym_token"
-
- resp, err := doRequest(data, url)
- if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
+ if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
+ return "", fmt.Errorf("settings failed: %w", err)
}
- token1 := resp["data"].(map[string]interface{})["access_token"].(string)
+ time.Sleep(200 * time.Millisecond)
- data = fmt.Sprintf("access_token=%s", token1)
- url = "https://api.vk.ru/method/calls.getAnonymousAccessTokenPayload?v=5.264&client_id=6287487"
+ log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
+ browserFp := generateBrowserFp(profile)
+ deviceJSON := buildCaptchaDeviceJSON(profile)
+ componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
- resp, err = doRequest(data, url)
- if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
+ return "", fmt.Errorf("componentDone failed: %w", err)
}
- token2 := resp["response"].(map[string]interface{})["payload"].(string)
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
+ cursorJSON := generateFakeCursor()
+ answer := base64.StdEncoding.EncodeToString([]byte("{}"))
- data = fmt.Sprintf("client_id=6287487&token_type=messages&payload=%s&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487", token2)
- url = "https://login.vk.ru/?act=get_anonym_token"
+ // Dynamically generate debug_info to avoid static fingerprint bans
+ debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10)))
+ debugInfo := hex.EncodeToString(debugInfoBytes[:])
- resp, err = doRequest(data, url)
+ connectionRtt := "[50,50,50,50,50,50,50,50,50,50]"
+ connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]"
+
+ checkData := baseParams + fmt.Sprintf(
+ "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s",
+ neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), neturl.QueryEscape("[]"),
+ neturl.QueryEscape(cursorJSON), neturl.QueryEscape("[]"), neturl.QueryEscape(connectionRtt),
+ neturl.QueryEscape(connectionDownlink),
+ browserFp, hash, answer, debugInfo,
+ )
+
+ checkResp, err := vkReq("captchaNotRobot.check", checkData)
if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ return "", fmt.Errorf("check failed: %w", err)
}
- token3 := resp["data"].(map[string]interface{})["access_token"].(string)
+ respObj, ok := checkResp["response"].(map[string]interface{})
+ if !ok {
+ return "", fmt.Errorf("invalid check response: %v", checkResp)
+ }
+ status, ok := respObj["status"].(string)
+ if !ok || status != "OK" {
+ return "", fmt.Errorf("check status: %s", status)
+ }
+ successToken, ok := respObj["success_token"].(string)
+ if !ok || successToken == "" {
+ return "", fmt.Errorf("success_token not found")
+ }
- data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=123&access_token=%s", link, token3)
- url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.264"
+ time.Sleep(200 * time.Millisecond)
- resp, err = doRequest(data, url)
+ log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", streamID)
+ _, err = vkReq("captchaNotRobot.endSession", baseParams)
if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", streamID, err)
+ }
+
+ return successToken, nil
+}
+
+func isAuthError(err error) bool {
+ if err == nil {
+ return false
}
+ errStr := err.Error()
+ return strings.Contains(errStr, "401") ||
+ strings.Contains(errStr, "Unauthorized") ||
+ strings.Contains(errStr, "authentication") ||
+ strings.Contains(errStr, "invalid credential") ||
+ strings.Contains(errStr, "stale nonce")
+}
- token4 := resp["response"].(map[string]interface{})["token"].(string)
+func handleAuthError(streamID int) bool {
+ cache := getStreamCache(streamID)
+ cacheID := getCacheID(streamID)
- data = fmt.Sprintf("%s%s%s", "session_data=%7B%22version%22%3A2%2C%22device_id%22%3A%22", uuid.New(), "%22%2C%22client_version%22%3A1.1%2C%22client_type%22%3A%22SDK_JS%22%7D&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA")
- url = "https://calls.okcdn.ru/fb.do"
+ now := time.Now().Unix()
- resp, err = doRequest(data, url)
- if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) {
+ cache.errorCount.Store(0)
}
- token5 := resp["session_key"].(string)
+ count := cache.errorCount.Add(1)
+ cache.lastErrorTime.Store(now)
- data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token4, token5)
- url = "https://calls.okcdn.ru/fb.do"
+ log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors)
- resp, err = doRequest(data, url)
- if err != nil {
- return "", "", "", fmt.Errorf("request error:%s", err)
+ if count >= maxCacheErrors {
+ log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID)
+ cache.invalidate(streamID)
+ return true
}
+ return false
+}
- user := resp["turn_server"].(map[string]interface{})["username"].(string)
- pass := resp["turn_server"].(map[string]interface{})["credential"].(string)
- turn := resp["turn_server"].(map[string]interface{})["urls"].([]interface{})[0].(string)
+// region VK Credentials Layer
- clean := strings.Split(turn, "?")[0]
- address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
+type VKCredentials struct {
+ ClientID string
+ ClientSecret string
+}
- return user, pass, address, nil
+var vkCredentialsList = []VKCredentials{
+ {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
+ //{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
+ //{ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID
+ //{ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID
+ //{ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP
}
-func getYandexCreds(link string) (string, string, string, error) {
- const debug = false
- const telemostConfHost = "cloud-api.yandex.ru"
- telemostConfPath := fmt.Sprintf("%s%s%s", "/telemost_front/v2/telemost/conferences/https%3A%2F%2Ftelemost.yandex.ru%2Fj%2F", link, "/connection?next_gen_media_platform_allowed=false")
- const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
-
- type ConferenceResponse struct {
- URI string `json:"uri"`
- RoomID string `json:"room_id"`
- PeerID string `json:"peer_id"`
- ClientConfiguration struct {
- MediaServerURL string `json:"media_server_url"`
- } `json:"client_configuration"`
- Credentials string `json:"credentials"`
- }
-
- type PartMeta struct {
- Name string `json:"name"`
- Role string `json:"role"`
- Description string `json:"description"`
- SendAudio bool `json:"sendAudio"`
- SendVideo bool `json:"sendVideo"`
- }
-
- type PartAttrs struct {
- Name string `json:"name"`
- Role string `json:"role"`
- Description string `json:"description"`
- }
-
- type SdkInfo struct {
- Implementation string `json:"implementation"`
- Version string `json:"version"`
- UserAgent string `json:"userAgent"`
- HwConcurrency int `json:"hwConcurrency"`
- }
-
- type Capabilities struct {
- OfferAnswerMode []string `json:"offerAnswerMode"`
- InitialSubscriberOffer []string `json:"initialSubscriberOffer"`
- SlotsMode []string `json:"slotsMode"`
- SimulcastMode []string `json:"simulcastMode"`
- SelfVadStatus []string `json:"selfVadStatus"`
- DataChannelSharing []string `json:"dataChannelSharing"`
- VideoEncoderConfig []string `json:"videoEncoderConfig"`
- DataChannelVideoCodec []string `json:"dataChannelVideoCodec"`
- BandwidthLimitationReason []string `json:"bandwidthLimitationReason"`
- SdkDefaultDeviceManagement []string `json:"sdkDefaultDeviceManagement"`
- JoinOrderLayout []string `json:"joinOrderLayout"`
- PinLayout []string `json:"pinLayout"`
- SendSelfViewVideoSlot []string `json:"sendSelfViewVideoSlot"`
- ServerLayoutTransition []string `json:"serverLayoutTransition"`
- SdkPublisherOptimizeBitrate []string `json:"sdkPublisherOptimizeBitrate"`
- SdkNetworkLostDetection []string `json:"sdkNetworkLostDetection"`
- SdkNetworkPathMonitor []string `json:"sdkNetworkPathMonitor"`
- PublisherVp9 []string `json:"publisherVp9"`
- SvcMode []string `json:"svcMode"`
- SubscriberOfferAsyncAck []string `json:"subscriberOfferAsyncAck"`
- SvcModes []string `json:"svcModes"`
- ReportTelemetryModes []string `json:"reportTelemetryModes"`
- KeepDefaultDevicesModes []string `json:"keepDefaultDevicesModes"`
- }
-
- type HelloPayload struct {
- ParticipantMeta PartMeta `json:"participantMeta"`
- ParticipantAttributes PartAttrs `json:"participantAttributes"`
- SendAudio bool `json:"sendAudio"`
- SendVideo bool `json:"sendVideo"`
- SendSharing bool `json:"sendSharing"`
- ParticipantID string `json:"participantId"`
- RoomID string `json:"roomId"`
- ServiceName string `json:"serviceName"`
- Credentials string `json:"credentials"`
- CapabilitiesOffer Capabilities `json:"capabilitiesOffer"`
- SdkInfo SdkInfo `json:"sdkInfo"`
- SdkInitializationID string `json:"sdkInitializationId"`
- DisablePublisher bool `json:"disablePublisher"`
- DisableSubscriber bool `json:"disableSubscriber"`
- DisableSubscriberAudio bool `json:"disableSubscriberAudio"`
- }
-
- type HelloRequest struct {
- UID string `json:"uid"`
- Hello HelloPayload `json:"hello"`
- }
-
- type FlexUrls []string
-
- type WSSResponse struct {
- UID string `json:"uid"`
- ServerHello struct {
- RtcConfiguration struct {
- IceServers []struct {
- Urls FlexUrls `json:"urls"`
- Username string `json:"username,omitempty"`
- Credential string `json:"credential,omitempty"`
- } `json:"iceServers"`
- } `json:"rtcConfiguration"`
- } `json:"serverHello"`
- }
-
- type WSSAck struct {
- Uid string `json:"uid"`
- Ack struct {
- Status struct {
- Code string `json:"code"`
- } `json:"status"`
- } `json:"ack"`
- }
-
- type WSSData struct {
- ParticipantId string
- RoomId string
- Credentials string
- Wss string
- }
-
- endpoint := "https://" + telemostConfHost + telemostConfPath
- client := &http.Client{
- Timeout: 20 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 100,
- IdleConnTimeout: 90 * time.Second,
- },
+func vkDelayRandom(minMs, maxMs int) {
+ ms := minMs + rand.Intn(maxMs-minMs+1)
+ time.Sleep(time.Duration(ms) * time.Millisecond)
+}
+
+func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
+ cache := getStreamCache(streamID)
+ cacheID := getCacheID(streamID)
+
+ cache.mutex.RLock()
+ if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) {
+ expires := time.Until(cache.creds.ExpiresAt)
+ u, p, a := cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr
+ cache.mutex.RUnlock()
+ if isDebug {
+ log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v)", streamID, cacheID, expires)
+ }
+ return u, p, a, nil
}
- defer client.CloseIdleConnections()
- req, err := http.NewRequest("GET", endpoint, nil)
- if err != nil {
- return "", "", "", err
+ cache.mutex.RUnlock()
+
+ cache.mutex.Lock()
+ defer cache.mutex.Unlock()
+
+ // Double-check inside lock
+ if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) {
+ return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil
}
- req.Header.Set("User-Agent", userAgent)
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Referer", "https://telemost.yandex.ru/")
- req.Header.Set("Origin", "https://telemost.yandex.ru")
- req.Header.Set("Client-Instance-Id", uuid.New().String())
- resp, err := client.Do(req)
+ user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer)
if err != nil {
return "", "", "", err
}
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(body))
- }
- var result ConferenceResponse
- if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return "", "", "", fmt.Errorf("decode conf: %v", err)
+ cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddr: addr, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link}
+ return user, pass, addr, nil
+}
+
+var (
+ vkRequestMu sync.Mutex
+ globalLastVkFetchTime time.Time
+)
+
+func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
+ vkRequestMu.Lock()
+ defer vkRequestMu.Unlock()
+
+ // Ensure a minimum cooldown between credential requests to avoid VK rate limits
+ minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond
+ elapsed := time.Since(globalLastVkFetchTime)
+
+ if !globalLastVkFetchTime.IsZero() && elapsed < minInterval {
+ wait := minInterval - elapsed
+ log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond))
+ select {
+ case <-ctx.Done():
+ return "", "", "", ctx.Err()
+ case <-time.After(wait):
+ }
}
- data := WSSData{
- ParticipantId: result.PeerID,
- RoomId: result.RoomID,
- Credentials: result.Credentials,
- Wss: result.ClientConfiguration.MediaServerURL,
+
+ defer func() {
+ globalLastVkFetchTime = time.Now()
+ }()
+
+ return fetchVkCreds(ctx, link, streamID, dialer)
+}
+
+func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
+ // Check Global Lockout to prevent API bans
+ if time.Now().Unix() < globalCaptchaLockout.Load() {
+ return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
}
- h := http.Header{}
- h.Set("Origin", "https://telemost.yandex.ru")
- h.Set("User-Agent", userAgent)
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
+ var lastErr error
+ jar := tlsclient.NewCookieJar()
- dialer := websocket.Dialer{}
- conn, _, err := dialer.DialContext(ctx, data.Wss, h)
- if err != nil {
- return "", "", "", fmt.Errorf("ws dial: %w", err)
- }
- defer conn.Close()
-
- req1 := HelloRequest{
- UID: uuid.New().String(),
- Hello: HelloPayload{
- ParticipantMeta: PartMeta{
- Name: "Гость",
- Role: "SPEAKER",
- Description: "",
- SendAudio: false,
- SendVideo: false,
- },
- ParticipantAttributes: PartAttrs{
- Name: "Гость",
- Role: "SPEAKER",
- Description: "",
- },
- SendAudio: false,
- SendVideo: false,
- SendSharing: false,
-
- ParticipantID: data.ParticipantId,
- RoomID: data.RoomId,
- ServiceName: "telemost",
- Credentials: data.Credentials,
- SdkInfo: SdkInfo{
- Implementation: "browser",
- Version: "5.15.0",
- UserAgent: userAgent,
- HwConcurrency: 4,
- },
- SdkInitializationID: uuid.New().String(),
- DisablePublisher: false,
- DisableSubscriber: false,
- DisableSubscriberAudio: false,
- CapabilitiesOffer: Capabilities{
- OfferAnswerMode: []string{"SEPARATE"},
- InitialSubscriberOffer: []string{"ON_HELLO"},
- SlotsMode: []string{"FROM_CONTROLLER"},
- SimulcastMode: []string{"DISABLED"},
- SelfVadStatus: []string{"FROM_SERVER"},
- DataChannelSharing: []string{"TO_RTP"},
- VideoEncoderConfig: []string{"NO_CONFIG"},
- DataChannelVideoCodec: []string{"VP8"},
- BandwidthLimitationReason: []string{"BANDWIDTH_REASON_DISABLED"},
- SdkDefaultDeviceManagement: []string{"SDK_DEFAULT_DEVICE_MANAGEMENT_DISABLED"},
- JoinOrderLayout: []string{"JOIN_ORDER_LAYOUT_DISABLED"},
- PinLayout: []string{"PIN_LAYOUT_DISABLED"},
- SendSelfViewVideoSlot: []string{"SEND_SELF_VIEW_VIDEO_SLOT_DISABLED"},
- ServerLayoutTransition: []string{"SERVER_LAYOUT_TRANSITION_DISABLED"},
- SdkPublisherOptimizeBitrate: []string{"SDK_PUBLISHER_OPTIMIZE_BITRATE_DISABLED"},
- SdkNetworkLostDetection: []string{"SDK_NETWORK_LOST_DETECTION_DISABLED"},
- SdkNetworkPathMonitor: []string{"SDK_NETWORK_PATH_MONITOR_DISABLED"},
- PublisherVp9: []string{"PUBLISH_VP9_DISABLED"},
- SvcMode: []string{"SVC_MODE_DISABLED"},
- SubscriberOfferAsyncAck: []string{"SUBSCRIBER_OFFER_ASYNC_ACK_DISABLED"},
- SvcModes: []string{"FALSE"},
- ReportTelemetryModes: []string{"TRUE"},
- KeepDefaultDevicesModes: []string{"TRUE"},
- },
- },
+ for _, creds := range vkCredentialsList {
+ log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
+
+ user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, dialer, jar)
+
+ if err == nil {
+ log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
+ return user, pass, addr, nil
+ }
+
+ lastErr = err
+ log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
+
+ // Hard abort on captcha/fatal conditions instead of trying next creds
+ if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") {
+ return "", "", "", err
+ }
+
+ if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") {
+ log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID)
+ }
}
- if debug {
- b, _ := json.MarshalIndent(req1, "", " ")
- log.Printf("Sending HELLO:\n%s", string(b))
+ return "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr)
+}
+
+func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, error) {
+ profile := 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"`,
}
- if err := conn.WriteJSON(req1); err != nil {
- return "", "", "", fmt.Errorf("ws write: %w", err)
+ client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
+ tlsclient.WithTimeoutSeconds(20),
+ tlsclient.WithClientProfile(profiles.Chrome_146),
+ tlsclient.WithCookieJar(jar),
+ tlsclient.WithDialer(getCustomNetDialer()),
+ )
+ if err != nil {
+ return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err)
}
- conn.SetReadDeadline(time.Now().Add(15 * time.Second))
+ name := generateName()
+ escapedName := neturl.QueryEscape(name)
- for {
- _, msg, err := conn.ReadMessage()
+ log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
+
+ doRequest := func(data string, url string) (resp map[string]interface{}, err error) {
+ parsedURL, err := neturl.Parse(url)
if err != nil {
- return "", "", "", fmt.Errorf("ws read: %w", err)
+ return nil, fmt.Errorf("parse request URL: %w", err)
}
- if debug {
- s := string(msg)
- if len(s) > 800 {
- s = s[:800] + "...(truncated)"
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Origin", "https://vk.ru")
+ req.Header.Set("Referer", "https://vk.ru/")
+ req.Header.Set("Sec-Fetch-Site", "same-site")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Priority", "u=1, i")
+
+ httpResp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := httpResp.Body.Close(); closeErr != nil {
+ log.Printf("close response body: %s", closeErr)
}
- log.Printf("WSS recv: %s", s)
+ }()
+
+ body, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, err
}
- var ack WSSAck
- if err := json.Unmarshal(msg, &ack); err == nil && ack.Ack.Status.Code != "" {
- continue
+ err = json.Unmarshal(body, &resp)
+ if err != nil {
+ return nil, err
}
+ return resp, nil
+ }
- var resp WSSResponse
- if err := json.Unmarshal(msg, &resp); err == nil {
- ice := resp.ServerHello.RtcConfiguration.IceServers
- for _, s := range ice {
- for _, u := range s.Urls {
- if !strings.HasPrefix(u, "turn:") && !strings.HasPrefix(u, "turns:") {
- continue
+ // Token 1
+ data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID)
+ resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token")
+ if err != nil {
+ return "", "", "", err
+ }
+ dataMap, ok := resp["data"].(map[string]interface{})
+ if !ok {
+ return "", "", "", fmt.Errorf("unexpected anon token response: %v", resp)
+ }
+ token1, ok := dataMap["access_token"].(string)
+ if !ok {
+ return "", "", "", fmt.Errorf("missing access_token in response: %v", resp)
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 1 -> getCallPreview
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1)
+ _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID)
+ if err != nil {
+ log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err)
+ }
+
+ vkDelayRandom(200, 400)
+
+ // Token 2
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1)
+ urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID)
+
+ var token2 string
+ for attempt := 0; ; attempt++ {
+ resp, err = doRequest(data, urlAddr)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr {
+ captchaErr := ParseVkCaptchaError(errObj)
+ if captchaErr != nil && captchaErr.IsCaptchaError() {
+ solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC)
+ if !hasSolveMode {
+ log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1)
+
+ // Engage global lockout to protect API
+ globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
+
+ return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
+ }
+
+ var successToken string
+ var captchaKey string
+ var solveErr error
+
+ switch solveMode {
+ case captchaSolveModeAuto:
+ if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr)
+ }
+ } else {
+ solveErr = fmt.Errorf("missing fields for auto solve")
+ }
+ case captchaSolveModeSliderPOC:
+ if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true)
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr)
+ }
+ } else {
+ solveErr = fmt.Errorf("missing fields for slider POC auto solve")
+ }
+ case captchaSolveModeManual:
+ log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
+ manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second)
+
+ type manualRes struct {
+ token string
+ key string
+ err error
+ }
+ resCh := make(chan manualRes, 1)
+
+ go func() {
+ var t, k string
+ var e error
+ if captchaErr.RedirectURI != "" {
+ t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer)
+ } else if captchaErr.CaptchaImg != "" {
+ k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg)
+ } else {
+ e = fmt.Errorf("no redirect_uri or captcha_img")
+ }
+ resCh <- manualRes{t, k, e}
+ }()
+
+ select {
+ case res := <-resCh:
+ successToken = res.token
+ captchaKey = res.key
+ solveErr = res.err
+ case <-manualCtx.Done():
+ solveErr = fmt.Errorf("manual captcha timed out after 60s")
}
- if strings.Contains(u, "transport=tcp") {
+ manualCancel()
+ }
+
+ // If solving failed (auto or manual) or timed out
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr)
+
+ nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha, autoCaptchaSliderPOC)
+ if hasNextSolveMode {
+ log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode))
continue
}
- clean := strings.Split(u, "?")[0]
- address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
- return s.Username, s.Credential, address, nil
+ // Engage global lockout to protect API
+ globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
+
+ return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
}
+
+ if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" {
+ captchaErr.CaptchaAttempt = "1"
+ }
+
+ if captchaKey != "" {
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
+ link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1)
+ } else {
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s",
+ link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1)
+ }
+ continue
}
+ return "", "", "", fmt.Errorf("VK API error: %v", errObj)
}
+
+ respMap, okLoop := resp["response"].(map[string]interface{})
+ if !okLoop {
+ return "", "", "", fmt.Errorf("unexpected getAnonymousToken response: %v", resp)
+ }
+ token2, okLoop = respMap["token"].(string)
+ if !okLoop {
+ return "", "", "", fmt.Errorf("missing token in response: %v", resp)
+ }
+ break
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 3
+ sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
+ data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData))
+ resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
+ if err != nil {
+ return "", "", "", err
+ }
+ token3, ok := resp["session_key"].(string)
+ if !ok {
+ return "", "", "", fmt.Errorf("missing session_key in response: %v", resp)
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 4 -> TURN Creds
+ data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3)
+ resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
+ if err != nil {
+ return "", "", "", err
+ }
+
+ tsRaw, ok := resp["turn_server"].(map[string]interface{})
+ if !ok {
+ return "", "", "", fmt.Errorf("missing turn_server in response: %v", resp)
+ }
+ user, ok := tsRaw["username"].(string)
+ if !ok {
+ return "", "", "", fmt.Errorf("missing username in turn_server")
+ }
+ pass, ok := tsRaw["credential"].(string)
+ if !ok {
+ return "", "", "", fmt.Errorf("missing credential in turn_server")
}
+ urlsRaw, ok := tsRaw["urls"].([]interface{})
+ if !ok || len(urlsRaw) == 0 {
+ return "", "", "", fmt.Errorf("missing or empty urls in turn_server")
+ }
+ urlStr, ok := urlsRaw[0].(string)
+ if !ok {
+ return "", "", "", fmt.Errorf("turn server url is not a string")
+ }
+
+ clean := strings.Split(urlStr, "?")[0]
+ address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
+
+ return user, pass, address, nil
}
+// endregion
+
func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) {
certificate, err := selfsign.GenerateSelfSigned()
if err != nil {
@@ -440,7 +898,8 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.
CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
ConnectionIDGenerator: dtls.OnlySendCIDGenerator(),
}
- ctx1, cancel := context.WithTimeout(ctx, 30*time.Second)
+ // Extended timeout to accommodate serialized credential fetching via mutex
+ ctx1, cancel := context.WithTimeout(ctx, 120*time.Second)
defer cancel()
dtlsConn, err := dtls.Client(conn, peer, config)
if err != nil {
@@ -453,7 +912,7 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.
return dtlsConn, nil
}
-func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error) {
+func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte, streamID byte, v1 bool) {
var err error = nil
defer func() { c <- err }()
dtlsctx, dtlscancel := context.WithCancel(ctx)
@@ -481,7 +940,21 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa
}
log.Printf("Closed DTLS connection\n")
}()
- log.Printf("Established DTLS connection!\n")
+
+ // Phase 1: Send Session ID + Stream ID (17 bytes) - only for v2 protocol
+ if !v1 {
+ dtlsConn.SetWriteDeadline(time.Now().Add(time.Second * 5))
+ idBuf := make([]byte, 17)
+ copy(idBuf[:16], sessionID)
+ idBuf[16] = streamID
+ if _, err1 = dtlsConn.Write(idBuf); err1 != nil {
+ err = fmt.Errorf("failed to send session ID: %s", err1)
+ return
+ }
+ log.Printf("Established DTLS connection and sent session ID with stream %d!\n", streamID)
+ } else {
+ log.Printf("Established DTLS connection (v1 protocol, no session ID)!\n")
+ }
go func() {
for {
select {
@@ -574,13 +1047,14 @@ type turnParams struct {
port string
link string
udp bool
+ streamID int
getCreds getCredsFunc
}
func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, c chan<- error) {
var err error = nil
defer func() { c <- err }()
- user, pass, url, err1 := turnParams.getCreds(turnParams.link)
+ user, pass, url, err1 := turnParams.getCreds(ctx, turnParams.link, turnParams.streamID)
if err1 != nil {
err = fmt.Errorf("failed to get TURN credentials: %s", err1)
return
@@ -758,14 +1232,14 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
conn2.SetDeadline(time.Time{})
}
-func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}) {
+func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte, streamID byte, v1 bool) {
for {
select {
case <-ctx.Done():
return
case listenConn := <-listenConnChan:
c := make(chan error)
- go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c)
+ go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID, streamID, v1)
if err := <-c; err != nil {
log.Printf("%s", err)
}
@@ -773,7 +1247,11 @@ func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnCha
}
}
-func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time) {
+func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) {
+ // Create a copy of turnParams with the streamID
+ tp := *turnParams
+ tp.streamID = streamID
+
for {
select {
case <-ctx.Done():
@@ -782,9 +1260,39 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne
select {
case <-t:
c := make(chan error)
- go oneTurnConnection(ctx, turnParams, peer, conn2, c)
+ go oneTurnConnection(ctx, &tp, peer, conn2, c)
if err := <-c; err != nil {
- log.Printf("%s", err)
+ if strings.Contains(err.Error(), "FATAL_CAPTCHA") {
+ log.Printf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID)
+ if globalAppCancel != nil {
+ globalAppCancel()
+ }
+ return
+ }
+ if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") {
+ if !strings.Contains(err.Error(), "global lockout active") {
+ log.Printf("[STREAM %d] Backing off for 60 seconds to avoid IP ban...", streamID)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(60 * time.Second):
+ }
+ } else {
+ lockoutEnd := globalCaptchaLockout.Load()
+ sleepDuration := time.Until(time.Unix(lockoutEnd, 0))
+ if sleepDuration < 0 {
+ sleepDuration = 5 * time.Second
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(sleepDuration):
+ }
+ }
+ } else {
+ log.Printf("[STREAM %d] %s", streamID, err)
+ time.Sleep(2 * time.Second)
+ }
}
default:
}
@@ -794,6 +1302,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne
func main() { //nolint:cyclop
ctx, cancel := context.WithCancel(context.Background())
+ globalAppCancel = cancel
defer cancel()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
@@ -812,11 +1321,15 @@ func main() { //nolint:cyclop
port := flag.String("port", "", "override TURN port")
listen := flag.String("listen", "127.0.0.1:9000", "listen on ip:port")
vklink := flag.String("vk-link", "", "VK calls invite link \"https://vk.com/call/join/...\"")
- yalink := flag.String("yandex-link", "", "Yandex telemost invite link \"https://telemost.yandex.ru/j/...\"")
+ wb := flag.Bool("wb", false, "use WB Stream instead of VK")
peerAddr := flag.String("peer", "", "peer server address (host:port)")
- n := flag.Int("n", 0, "connections to TURN (default 16 for VK, 1 for Yandex)")
+ n := flag.Int("n", 0, "connections to TURN (default 4)")
udp := flag.Bool("udp", false, "connect to TURN with UDP")
direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE")
+ v1 := flag.Bool("v1", false, "use v1 server protocol (no session_id and stream_id)")
+ sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)")
+ debugFlag := flag.Bool("debug", false, "enable debug logging")
+ manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
flag.Parse()
if *peerAddr == "" {
log.Panicf("Need peer address!")
@@ -825,36 +1338,61 @@ func main() { //nolint:cyclop
if err != nil {
panic(err)
}
- if (*vklink == "") == (*yalink == "") {
- log.Panicf("Need either vk-link or yandex-link!")
+ if !*wb && *vklink == "" {
+ log.Panicf("Need either -wb or -vk-link!")
}
+
+ isDebug = *debugFlag
+ manualCaptcha = *manualCaptchaFlag
+ autoCaptchaSliderPOC = !manualCaptcha
+
var link string
var getCreds getCredsFunc
- if *vklink != "" {
- parts := strings.Split(*vklink, "join/")
- link = parts[len(parts)-1]
- getCreds = getVkCreds
- if *n <= 0 {
- *n = 16
+
+ dialer := dnsdialer.New(
+ dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"),
+ dnsdialer.WithStrategy(dnsdialer.Fallback{}),
+ dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour),
+ )
+
+ if *wb {
+ link = "wb"
+ getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) {
+ return getCredsCached(ctx, lk, streamID, wbFetch)
}
} else {
- parts := strings.Split(*yalink, "j/")
+ parts := strings.Split(*vklink, "join/")
link = parts[len(parts)-1]
- getCreds = getYandexCreds
- if *n <= 0 {
- *n = 1
+ getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) {
+ return getVkCredsCached(ctx, lk, streamID, dialer)
}
}
+
+ if *n <= 0 {
+ *n = 4
+ }
if idx := strings.IndexAny(link, "/?#"); idx != -1 {
link = link[:idx]
}
params := &turnParams{
- *host,
- *port,
- link,
- *udp,
- getCreds,
+ host: *host,
+ port: *port,
+ link: link,
+ udp: *udp,
+ streamID: 0,
+ getCreds: getCreds,
+ }
+
+ var sessionID []byte
+ if *sessionIDFlag != "" {
+ sessionID = make([]byte, 16)
+ if _, err := fmt.Sscanf(*sessionIDFlag, "%x", &sessionID); err != nil {
+ log.Panicf("Invalid session ID: %v", err)
+ }
+ } else {
+ sessionID, _ = uuid.New().MarshalBinary()
}
+ log.Printf("Session ID: %x", sessionID)
listenConnChan := make(chan net.PacketConn)
listenConn, err := net.ListenPacket("udp", *listen) // nolint: noctx
@@ -880,21 +1418,28 @@ func main() { //nolint:cyclop
t := time.Tick(100 * time.Millisecond)
if *direct {
for i := 0; i < *n; i++ {
- wg1.Go(func() {
- oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t)
- })
+ wg1.Add(1)
+ streamID := i
+ go func() {
+ defer wg1.Done()
+ oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t, streamID)
+ }()
}
} else {
okchan := make(chan struct{})
connchan := make(chan net.PacketConn)
- wg1.Go(func() {
- oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan)
- })
+ wg1.Add(1)
+ go func() {
+ defer wg1.Done()
+ oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID, 0, *v1)
+ }()
- wg1.Go(func() {
- oneTurnConnectionLoop(ctx, params, peer, connchan, t)
- })
+ wg1.Add(1)
+ go func() {
+ defer wg1.Done()
+ oneTurnConnectionLoop(ctx, params, peer, connchan, t, 0)
+ }()
select {
case <-okchan:
@@ -902,12 +1447,17 @@ func main() { //nolint:cyclop
}
for i := 0; i < *n-1; i++ {
connchan := make(chan net.PacketConn)
- wg1.Go(func() {
- oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil)
- })
- wg1.Go(func() {
- oneTurnConnectionLoop(ctx, params, peer, connchan, t)
- })
+ streamID := i + 1
+ wg1.Add(1)
+ go func(sID byte) {
+ defer wg1.Done()
+ oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, sID, *v1)
+ }(byte(streamID))
+ wg1.Add(1)
+ go func() {
+ defer wg1.Done()
+ oneTurnConnectionLoop(ctx, params, peer, connchan, t, streamID)
+ }()
}
}
diff --git a/client/manual_captcha.go b/client/manual_captcha.go
new file mode 100644
index 0000000..826478c
--- /dev/null
+++ b/client/manual_captcha.go
@@ -0,0 +1,617 @@
+package main
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ neturl "net/url"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/bschaatsbergen/dnsdialer"
+)
+
+const captchaListenPort = "8765"
+
+type browserCommand struct {
+ name string
+ args []string
+}
+
+func localCaptchaOrigin() string {
+ return "http://localhost:" + captchaListenPort
+}
+
+func localCaptchaListenAddrs() []string {
+ return []string{
+ "127.0.0.1:" + captchaListenPort,
+ "[::1]:" + captchaListenPort,
+ }
+}
+
+func localCaptchaHosts() []string {
+ return []string{
+ "localhost:" + captchaListenPort,
+ "127.0.0.1:" + captchaListenPort,
+ "[::1]:" + captchaListenPort,
+ }
+}
+
+func isLocalCaptchaHost(host string) bool {
+ for _, localHost := range localCaptchaHosts() {
+ if strings.EqualFold(host, localHost) {
+ return true
+ }
+ }
+ return false
+}
+
+func localCaptchaURLForTarget(targetURL *neturl.URL) string {
+ localURL := &neturl.URL{
+ Scheme: "http",
+ Host: "localhost:" + captchaListenPort,
+ Path: targetURL.Path,
+ RawPath: targetURL.RawPath,
+ RawQuery: targetURL.RawQuery,
+ }
+ if localURL.Path == "" {
+ localURL.Path = "/"
+ }
+ return localURL.String()
+}
+
+func targetOrigin(targetURL *neturl.URL) string {
+ return targetURL.Scheme + "://" + targetURL.Host
+}
+
+func isSafeLocalRedirectPath(raw string) bool {
+ if raw == "" || raw[0] != '/' {
+ return false
+ }
+ if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
+ return false
+ }
+ return true
+}
+
+func rewriteProxyRedirectLocation(raw string, targetURL *neturl.URL) (string, bool) {
+ if isSafeLocalRedirectPath(raw) {
+ return raw, true
+ }
+
+ parsed, err := neturl.Parse(raw)
+ if err != nil {
+ return "", false
+ }
+ if !strings.EqualFold(parsed.Scheme, targetURL.Scheme) || !strings.EqualFold(parsed.Host, targetURL.Host) {
+ return "", false
+ }
+
+ return localCaptchaURLForTarget(parsed), true
+}
+
+func rewriteProxyHeaderURL(raw string, targetURL *neturl.URL) string {
+ if raw == "" {
+ return raw
+ }
+ parsed, err := neturl.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ if parsed.Scheme != "http" || !isLocalCaptchaHost(parsed.Host) {
+ return raw
+ }
+ parsed.Scheme = targetURL.Scheme
+ parsed.Host = targetURL.Host
+ return parsed.String()
+}
+
+func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) {
+ req.URL.Scheme = targetURL.Scheme
+ req.URL.Host = targetURL.Host
+ if req.URL.Path == "" {
+ req.URL.Path = targetURL.Path
+ }
+ req.Host = targetURL.Host
+
+ req.Header.Del("Accept-Encoding")
+ req.Header.Del("TE") // Disable transfer encoding compression
+ for _, headerName := range []string{"Origin", "Referer"} {
+ if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" {
+ req.Header.Set(headerName, rewritten)
+ } else {
+ req.Header.Del(headerName)
+ }
+ }
+}
+
+func extractSuccessToken(body []byte) string {
+ var payload struct {
+ Response struct {
+ SuccessToken string `json:"success_token"`
+ } `json:"response"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return ""
+ }
+ return payload.Response.SuccessToken
+}
+
+func rewriteProxyCookies(header http.Header) {
+ cookies := (&http.Response{Header: header}).Cookies()
+ if len(cookies) == 0 {
+ return
+ }
+ header.Del("Set-Cookie")
+ for _, cookie := range cookies {
+ cookie.Domain = ""
+ cookie.Secure = false
+ cookie.Partitioned = false
+ if cookie.SameSite == http.SameSiteNoneMode || cookie.SameSite == http.SameSiteStrictMode {
+ cookie.SameSite = http.SameSiteLaxMode
+ }
+ header.Add("Set-Cookie", cookie.String())
+ }
+}
+
+func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
+ localOrigin := localCaptchaOrigin()
+ upstreamOrigin := targetOrigin(targetURL)
+ html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
+
+ script := fmt.Sprintf(`
+
+`, localOrigin, upstreamOrigin)
+
+ switch {
+ case strings.Contains(html, ""):
+ return strings.Replace(html, "", script+"", 1)
+ case strings.Contains(html, "
+Solve the Captcha
+
+"):
+ return strings.Replace(html, "", script+"", 1)
+ default:
+ return html + script
+ }
+}
+
+func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport {
+ transport := &http.Transport{
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ ForceAttemptHTTP2: false,
+ }
+ if dialer != nil {
+ transport.DialContext = dialer.DialContext
+ }
+ return transport
+}
+
+func startCaptchaServer(srv *http.Server, logPrefix string) error {
+ var listenErrs []string
+ var listening bool
+
+ for _, addr := range localCaptchaListenAddrs() {
+ listener, err := net.Listen("tcp", addr)
+ if err != nil {
+ listenErrs = append(listenErrs, fmt.Sprintf("%s (%v)", addr, err))
+ continue
+ }
+ listening = true
+ go func(listener net.Listener) {
+ if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("%s: %s", logPrefix, err)
+ }
+ }(listener)
+ }
+
+ if listening {
+ return nil
+ }
+
+ return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; "))
+}
+
+// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token.
+func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) {
+ srv := &http.Server{Handler: handler}
+
+ if err := startCaptchaServer(srv, logPrefix); err != nil {
+ return "", err
+ }
+
+ fmt.Println("\n==============================================")
+ fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED")
+ fmt.Println("Open this URL in your browser: " + localCaptchaOrigin())
+ fmt.Println("==============================================")
+ fmt.Println()
+
+ openBrowser(captchaURL)
+
+ key := <-keyCh
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ return "", err
+ }
+
+ return key, nil
+}
+
+// notifyKey pushes the key string to the given channel without blocking
+func notifyKey(keyCh chan<- string, key string) {
+ if key != "" {
+ select {
+ case keyCh <- key:
+ default:
+ }
+ }
+}
+
+func solveCaptchaViaHTTP(captchaImg string) (string, error) {
+ keyCh := make(chan string, 1)
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprintf(w, `
+