Browse Source

wb mode

pull/26/head
kiper292 2 months ago
parent
commit
b01249e8be
  1. 210
      README.en.md
  2. 113
      README.md
  3. 151
      client/credentials.go
  4. 691
      client/main.go
  5. 258
      client/vk.go
  6. 358
      client/wb.go

210
README.en.md

@ -1,7 +1,15 @@
# Good TURN
# VK/WB TURN Proxy
[Russian version](README.md)
Tunnels WireGuard/Hysteria traffic through VK Calls or Yandex Telemost 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.
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.
@ -12,19 +20,32 @@ The current implementation supports multiple simultaneous users through a single
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. Or a link to a Yandex Telemost call: `"https://telemost.yandex.ru/j/"`. Better not to search for these as conference participants are visible.
3. A VPS with WireGuard installed.
4. For Android: Download Termux from F-Droid.
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:<wg_port>
```
### Client
#### Android
**Recommended method:**
@ -47,42 +68,201 @@ Copy the binary to a local folder and grant execution rights:
cp /sdcard/Download/client-android ./
chmod 777 ./client-android
```
Run:
**VK mode:**
```bash
./client-android -peer <wg_server_ip>:56000 -vk-link <VK_link> -listen 127.0.0.1:9000
```
Additional flags:
- `-session-id <hex>`: set a fixed session ID (32 hex characters).
Or:
**WB mode:**
```bash
./client-android -udp -turn 5.255.211.241 -peer <wg_server_ip>:56000 -yandex-link <Ya_link> -listen 127.0.0.1:9000
./client-android -wb -peer <wg_server_ip>:56000 -listen 127.0.0.1:9000
```
Additional flags:
- `-session-id <hex>`: set a fixed session ID (32 hex characters).
- `-n <num>`: number of connections to TURN (default 4).
- `-udp`: use UDP for TURN (default TCP).
- `-turn <ip>`: override TURN server address.
- `-port <port>`: override TURN server port.
- `-no-dtls`: without DTLS obfuscation (may result in a ban).
#### 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 <wg_server_ip>:56000 -vk-link <VK_link> -listen 127.0.0.1:9000 | sudo routes.sh
```
```bash
./client-linux -wb -peer <wg_server_ip>: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 <wg_server_ip>:56000 -vk-link <VK_link> -listen 127.0.0.1:9000 | routes.ps1
```
```powershell
./client.exe -wb -peer <wg_server_ip>: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.
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).
## Yandex Telemost
**UPD. TELEMOST IS CLOSED**
Unlike VK, Yandex servers do not limit speed, so the default is `-n 1`.
## 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:
<details>
<summary>
Client
</summary>
```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": "<client secret key>",
"peers": [
{
"endpoint": "127.0.0.1:9000",
"publicKey": "<server public key>"
}
],
"domainStrategy": "ForceIPv4",
"mtu": 1280
}
}
]
}
```
</details>
<details>
<summary>
Server
</summary>
```json
{
"inbounds": [
{
"protocol": "wireguard",
"listen": "0.0.0.0",
"port": 51820,
"settings": {
"secretKey": "<server secret key>",
"peers": [
{
"publicKey": "<client public key>"
}
],
"mtu": 1280
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIPv4"
}
}
]
}
```
</details>
## 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/Yandex.
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

113
README.md

@ -1,7 +1,15 @@
# Good TURN
# VK/WB TURN Proxy
[English version](README.en.md)
Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или Яндекс телемоста. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок.
Проброс трафика 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
**Обновление: Многопользовательский прокси-сервер**
Текущая реализация поддерживает одновременную работу нескольких пользователей через один прокси-сервер.
@ -11,18 +19,34 @@
- **Балансировка:** Исходящий трафик от сервера к клиенту распределяется между всеми активными 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
**Рекомендуемый способ:**
@ -30,7 +54,8 @@
**Альтернативный способ (через Termux):**
- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".**
- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".**
В Termux:
```
termux-wake-lock
@ -44,20 +69,27 @@ termux-wake-unlock
cp /sdcard/Download/client-android ./
chmod 777 ./client-android
```
Запускаем:
**VK режим:**
```
./client-android -peer <ip сервера wg>:56000 -vk-link <VK ссылка> -listen 127.0.0.1:9000
```
Дополнительные флаги:
- `-session-id <hex>`: установить фиксированный ID сессии (32 символа hex).
Или
**WB режим:**
```
./client-android -udp -turn 5.255.211.241 -peer <ip сервера wg>:56000 -yandex-link <Ya ссылка> -listen 127.0.0.1:9000
./client-android -wb -peer <ip сервера wg>:56000 -listen 127.0.0.1:9000
```
**Если после включения VPN в терминале вылезают ошибки DNS, попробуйте в Wireguard включить VPN только для нужных приложений.**
Дополнительные флаги:
- `-session-id <hex>`: установить фиксированный ID сессии (32 символа hex).
- `-n <число>`: количество подключений к TURN (по умолчанию 4).
- `-udp`: использовать UDP для TURN (по умолчанию TCP).
- `-turn <ip>`: переопределить адрес TURN сервера.
- `-port <port>`: переопределить порт TURN сервера.
- `-no-dtls`: без DTLS-обфускации (может привести к бану).
#### Linux
В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
Скрипт будет добавлять маршруты к нужным ip:
@ -67,11 +99,13 @@ chmod 777 ./client-android
```
```
./client-linux -udp -turn 5.255.211.241 -peer <ip сервера wg>:56000 -yandex-link <Ya ссылка> -listen 127.0.0.1:9000 | sudo routes.sh
./client-linux -wb -peer <ip сервера wg>:56000 -listen 127.0.0.1:9000 | sudo routes.sh
```
Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
#### Windows
В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
В PowerShell от Администратора (чтобы скрипт прописывал маршруты):
@ -81,40 +115,42 @@ chmod 777 ./client-android
```
```
./client.exe -udp -turn 5.255.211.241 -peer <ip сервера wg>:56000 -yandex-link <Ya ссылка> -listen 127.0.0.1:9000 | routes.ps1
./client.exe -wb -peer <ip сервера wg>: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`
<details>
<summary>
Рабочие IP
</summary>
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
</details>
Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :)
- TTL: **10 минут** (safety margin 60 секунд)
- Один кеш на **4 потока** (`streamID / 4`)
- Fast path через `RLock`
- Сериализация fetch через глобальный `fetchMu`
## v2ray
@ -225,4 +261,9 @@ chmod 777 ./client-android
</details>
## Direct mode
С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от вк/яндекса.
С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от ВК/WB.
Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :)
Функционал WB Stream основан на проекте https://github.com/jaykaiperson/lionheart

151
client/credentials.go

@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// 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
}

691
client/main.go

@ -4,18 +4,12 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
@ -26,666 +20,12 @@ import (
"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(context.Context, string, int) (string, string, string, error)
const vkClientID = "6287487"
const vkClientSecret = "QbYic1K3lEV5kTGiqlq2"
const vkAPIVersion = "5.275"
// 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("[VK Auth] Credentials cache invalidated for stream %d", streamID)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// vkDelay sleeps for a random duration between minMs and maxMs to avoid bot detection
func vkDelay(minMs, maxMs int) {
ms := minMs + rand.Intn(maxMs-minMs+1)
time.Sleep(time.Duration(ms) * time.Millisecond)
}
// vkCredsMu serializes VK credential fetching to avoid BOT detection from parallel requests
var vkCredsMu sync.Mutex
// getVkCredsCached checks cache before fetching credentials
func getVkCredsCached(ctx context.Context, link string, streamID int) (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("[VK Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires)
return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil
}
log.Printf("[VK 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 mutex to avoid VK flood control
user, pass, addr, err := getVkCredsSafe(ctx, link, streamID)
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("[VK Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID)
return user, pass, addr, nil
}
// getVkCredsSafe wraps getVkCreds with mutex to avoid VK flood control
func getVkCredsSafe(ctx context.Context, link string, streamID int) (string, string, string, error) {
vkCredsMu.Lock()
defer vkCredsMu.Unlock()
return getVkCreds(ctx, link)
}
func vkHTTPPost(ctx context.Context, data string, url string) (map[string]interface{}, error) {
client := &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
},
}
defer client.CloseIdleConnections()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
if err != nil {
return nil, err
}
// Headers matching HAR capture exactly
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Origin", "https://vk.ru")
req.Header.Set("Referer", "https://vk.ru/")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("sec-ch-ua", `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
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("DNT", "1")
req.Header.Set("Priority", "u=1, i")
httpResp, err := client.Do(req)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
// Handle HTTP errors (redirects, rate limits, etc.)
if httpResp.StatusCode >= 400 {
body, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("HTTP %d from %s: %s", httpResp.StatusCode, req.URL, string(body[:min(len(body), 500)]))
}
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
// Check content type - VK may return HTML instead of JSON (captcha page, redirect, etc.)
contentType := httpResp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "text/javascript") {
// Log first 500 chars of non-JSON response for debugging
logPreview := string(body)
if len(logPreview) > 500 {
logPreview = logPreview[:500] + "...(truncated)"
}
return nil, fmt.Errorf("unexpected content-type %s, status %d, body: %s", contentType, httpResp.StatusCode, logPreview)
}
var resp map[string]interface{}
if err = json.Unmarshal(body, &resp); err != nil {
// Log the raw body for debugging
logPreview := string(body)
if len(logPreview) > 500 {
logPreview = logPreview[:500] + "...(truncated)"
}
return nil, fmt.Errorf("JSON parse error: %w, body: %s", err, logPreview)
}
return resp, nil
}
func getVkCreds(ctx context.Context, link string) (string, string, string, error) {
// Token 1 (messages)
log.Println("[VK Auth] Getting Token 1...")
data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s",
vkClientID, vkClientSecret, vkClientID)
resp, err := vkHTTPPost(ctx, data, "https://login.vk.ru/?act=get_anonym_token")
if err != nil {
return "", "", "", fmt.Errorf("Token 1 request error: %w", err)
}
if errMsg, ok := resp["error"].(map[string]interface{}); ok {
return "", "", "", fmt.Errorf("Token 1 VK error: %v", errMsg)
}
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("invalid Token 1 response: %v", resp)
}
token1, ok := dataObj["access_token"].(string)
if !ok {
return "", "", "", fmt.Errorf("access_token not found in Token 1 response")
}
log.Println("[VK Auth] Token 1 received")
vkDelay(100, 200) // Token 1 → getCallPreview
// getCallPreview (optional, like browser)
log.Println("[VK Auth] Getting call preview...")
cpData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s",
url.QueryEscape(link), token1)
cpURL := fmt.Sprintf("https://api.vk.ru/method/calls.getCallPreview?v=%s&client_id=%s", vkAPIVersion, vkClientID)
_, _ = vkHTTPPost(ctx, cpData, cpURL) // non-critical
vkDelay(500, 1000) // getCallPreview → Token 2
// Token 2 (may require captcha)
log.Println("[VK Auth] Getting Token 2...")
t2Data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=123&access_token=%s",
url.QueryEscape(link), token1)
t2URL := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=%s&client_id=%s", vkAPIVersion, vkClientID)
resp, err = vkHTTPPost(ctx, t2Data, t2URL)
if err != nil {
return "", "", "", fmt.Errorf("Token 2 request error: %w", err)
}
// Check for captcha error
if errMsg, ok := resp["error"].(map[string]interface{}); ok {
captchaData, isCaptcha := ExtractCaptchaData(errMsg)
if !isCaptcha {
return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg)
}
log.Printf("[VK Auth] Captcha detected, solving...")
successToken, solveErr := SolveVkCaptcha(ctx, captchaData)
if solveErr != nil {
return "", "", "", fmt.Errorf("captcha solving failed: %w", solveErr)
}
// Delay before retry (endSession → Token 2 retry)
vkDelay(100, 200)
// Retry Token 2 with captcha solution
log.Println("[VK Auth] Retrying Token 2 with captcha solution...")
t2Data = fmt.Sprintf(
"vk_join_link=https://vk.ru/call/join/%s&name=123"+
"&captcha_key=&captcha_sid=%s&is_sound_captcha=0"+
"&success_token=%s&captcha_ts=%s&captcha_attempt=%s"+
"&access_token=%s",
url.QueryEscape(link),
captchaData.CaptchaSid,
successToken,
captchaData.CaptchaTs,
captchaData.CaptchaAttempt,
token1,
)
resp, err = vkHTTPPost(ctx, t2Data, t2URL)
if err != nil {
return "", "", "", fmt.Errorf("Token 2 retry request error: %w", err)
}
if errMsg2, ok := resp["error"].(map[string]interface{}); ok {
return "", "", "", fmt.Errorf("Token 2 retry VK error: %v", errMsg2)
}
// Token 2 retry → Token 3
vkDelay(100, 200)
}
token2Obj, ok := resp["response"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("invalid Token 2 response: %v", resp)
}
token2, ok := token2Obj["token"].(string)
if !ok {
return "", "", "", fmt.Errorf("token not found in Token 2 response")
}
log.Println("[VK Auth] Token 2 received")
// Token 2 → Token 3
vkDelay(100, 200)
// Token 3 (OK auth.anonymLogin)
log.Println("[VK Auth] Getting Token 3...")
sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
t3Data := fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA",
url.QueryEscape(sessionData))
resp, err = vkHTTPPost(ctx, t3Data, "https://calls.okcdn.ru/fb.do")
if err != nil {
return "", "", "", fmt.Errorf("Token 3 request error: %w", err)
}
if errMsg, ok := resp["error"].(string); ok && errMsg != "" {
return "", "", "", fmt.Errorf("Token 3 API error: %s", errMsg)
}
token3, ok := resp["session_key"].(string)
if !ok {
return "", "", "", fmt.Errorf("session_key not found in Token 3 response")
}
log.Println("[VK Auth] Token 3 received")
// Token 3 → Final (TURN)
vkDelay(100, 200)
// Final: vchat.joinConversationByLink (Token 4)
log.Println("[VK Auth] Getting TURN credentials (Token 4)...")
finalData := fmt.Sprintf(
"joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s",
url.QueryEscape(link), token2, token3)
resp, err = vkHTTPPost(ctx, finalData, "https://calls.okcdn.ru/fb.do")
if err != nil {
return "", "", "", fmt.Errorf("Final request error: %w", err)
}
if errMsg, ok := resp["error"].(string); ok && errMsg != "" {
return "", "", "", fmt.Errorf("Final API error: %s", errMsg)
}
ts, ok := resp["turn_server"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("turn_server not found in response: %v", resp)
}
urls, _ := ts["urls"].([]interface{})
if len(urls) == 0 {
return "", "", "", fmt.Errorf("urls not found in turn_server")
}
urlStr, _ := urls[0].(string)
clean := strings.Split(urlStr, "?")[0]
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
username, _ := ts["username"].(string)
credential, _ := ts["credential"].(string)
if username == "" || credential == "" {
return "", "", "", fmt.Errorf("username or credential not found in turn_server")
}
log.Println("[VK Auth] TURN credentials received")
vkDelay(1500, 2500) // Final delay before exit
return username, credential, address, nil
}
func getYandexCreds(ctx context.Context, link string, streamID int) (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,
},
}
defer client.CloseIdleConnections()
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", "", "", err
}
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)
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)
}
data := WSSData{
ParticipantId: result.PeerID,
RoomId: result.RoomID,
Credentials: result.Credentials,
Wss: result.ClientConfiguration.MediaServerURL,
}
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()
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"},
},
},
}
if debug {
b, _ := json.MarshalIndent(req1, "", " ")
log.Printf("Sending HELLO:\n%s", string(b))
}
if err := conn.WriteJSON(req1); err != nil {
return "", "", "", fmt.Errorf("ws write: %w", err)
}
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return "", "", "", fmt.Errorf("ws read: %w", err)
}
if debug {
s := string(msg)
if len(s) > 800 {
s = s[:800] + "...(truncated)"
}
log.Printf("WSS recv: %s", s)
}
var ack WSSAck
if err := json.Unmarshal(msg, &ack); err == nil && ack.Ack.Status.Code != "" {
continue
}
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
}
if strings.Contains(u, "transport=tcp") {
continue
}
clean := strings.Split(u, "?")[0]
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
return s.Username, s.Credential, address, nil
}
}
}
}
}
func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) {
certificate, err := selfsign.GenerateSelfSigned()
if err != nil {
@ -1087,9 +427,9 @@ 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")
sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)")
@ -1101,26 +441,29 @@ 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!")
}
var link string
var getCreds getCredsFunc
if *vklink != "" {
parts := strings.Split(*vklink, "join/")
link = parts[len(parts)-1]
getCreds = getVkCredsCached
if *n <= 0 {
*n = 4
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 getCredsCached(ctx, lk, streamID, getVkCreds)
}
}
if *n <= 0 {
*n = 4
}
if idx := strings.IndexAny(link, "/?#"); idx != -1 {
link = link[:idx]
}

258
client/vk.go

@ -0,0 +1,258 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
const vkClientID = "6287487"
const vkClientSecret = "QbYic1K3lEV5kTGiqlq2"
const vkAPIVersion = "5.275"
func min(a, b int) int {
if a < b {
return a
}
return b
}
// vkDelay sleeps for a random duration between minMs and maxMs to avoid bot detection
func vkDelay(minMs, maxMs int) {
ms := minMs + rand.Intn(maxMs-minMs+1)
time.Sleep(time.Duration(ms) * time.Millisecond)
}
func vkHTTPPost(ctx context.Context, data string, url string) (map[string]interface{}, error) {
client := &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
},
}
defer client.CloseIdleConnections()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
if err != nil {
return nil, err
}
// Headers matching HAR capture exactly
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Origin", "https://vk.ru")
req.Header.Set("Referer", "https://vk.ru/")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("sec-ch-ua", `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
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("DNT", "1")
req.Header.Set("Priority", "u=1, i")
httpResp, err := client.Do(req)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
// Handle HTTP errors (redirects, rate limits, etc.)
if httpResp.StatusCode >= 400 {
body, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("HTTP %d from %s: %s", httpResp.StatusCode, req.URL, string(body[:min(len(body), 500)]))
}
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
// Check content type - VK may return HTML instead of JSON (captcha page, redirect, etc.)
contentType := httpResp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "text/javascript") {
// Log first 500 chars of non-JSON response for debugging
logPreview := string(body)
if len(logPreview) > 500 {
logPreview = logPreview[:500] + "...(truncated)"
}
return nil, fmt.Errorf("unexpected content-type %s, status %d, body: %s", contentType, httpResp.StatusCode, logPreview)
}
var resp map[string]interface{}
if err = json.Unmarshal(body, &resp); err != nil {
// Log the raw body for debugging
logPreview := string(body)
if len(logPreview) > 500 {
logPreview = logPreview[:500] + "...(truncated)"
}
return nil, fmt.Errorf("JSON parse error: %w, body: %s", err, logPreview)
}
return resp, nil
}
func getVkCreds(ctx context.Context, link string) (string, string, string, error) {
// Token 1 (messages)
log.Println("[VK Auth] Getting Token 1...")
data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s",
vkClientID, vkClientSecret, vkClientID)
resp, err := vkHTTPPost(ctx, data, "https://login.vk.ru/?act=get_anonym_token")
if err != nil {
return "", "", "", fmt.Errorf("Token 1 request error: %w", err)
}
if errMsg, ok := resp["error"].(map[string]interface{}); ok {
return "", "", "", fmt.Errorf("Token 1 VK error: %v", errMsg)
}
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("invalid Token 1 response: %v", resp)
}
token1, ok := dataObj["access_token"].(string)
if !ok {
return "", "", "", fmt.Errorf("access_token not found in Token 1 response")
}
log.Println("[VK Auth] Token 1 received")
vkDelay(100, 200) // Token 1 → getCallPreview
// getCallPreview (optional, like browser)
log.Println("[VK Auth] Getting call preview...")
cpData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s",
url.QueryEscape(link), token1)
cpURL := fmt.Sprintf("https://api.vk.ru/method/calls.getCallPreview?v=%s&client_id=%s", vkAPIVersion, vkClientID)
_, _ = vkHTTPPost(ctx, cpData, cpURL) // non-critical
vkDelay(500, 1000) // getCallPreview → Token 2
// Token 2 (may require captcha)
log.Println("[VK Auth] Getting Token 2...")
t2Data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=123&access_token=%s",
url.QueryEscape(link), token1)
t2URL := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=%s&client_id=%s", vkAPIVersion, vkClientID)
resp, err = vkHTTPPost(ctx, t2Data, t2URL)
if err != nil {
return "", "", "", fmt.Errorf("Token 2 request error: %w", err)
}
// Check for captcha error
if errMsg, ok := resp["error"].(map[string]interface{}); ok {
captchaData, isCaptcha := ExtractCaptchaData(errMsg)
if !isCaptcha {
return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg)
}
log.Printf("[VK Auth] Captcha detected, solving...")
successToken, solveErr := SolveVkCaptcha(ctx, captchaData)
if solveErr != nil {
return "", "", "", fmt.Errorf("captcha solving failed: %w", solveErr)
}
// Delay before retry (endSession → Token 2 retry)
vkDelay(100, 200)
// Retry Token 2 with captcha solution
log.Println("[VK Auth] Retrying Token 2 with captcha solution...")
t2Data = fmt.Sprintf(
"vk_join_link=https://vk.ru/call/join/%s&name=123"+
"&captcha_key=&captcha_sid=%s&is_sound_captcha=0"+
"&success_token=%s&captcha_ts=%s&captcha_attempt=%s"+
"&access_token=%s",
url.QueryEscape(link),
captchaData.CaptchaSid,
successToken,
captchaData.CaptchaTs,
captchaData.CaptchaAttempt,
token1,
)
resp, err = vkHTTPPost(ctx, t2Data, t2URL)
if err != nil {
return "", "", "", fmt.Errorf("Token 2 retry request error: %w", err)
}
if errMsg2, ok := resp["error"].(map[string]interface{}); ok {
return "", "", "", fmt.Errorf("Token 2 retry VK error: %v", errMsg2)
}
// Token 2 retry → Token 3
vkDelay(100, 200)
}
token2Obj, ok := resp["response"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("invalid Token 2 response: %v", resp)
}
token2, ok := token2Obj["token"].(string)
if !ok {
return "", "", "", fmt.Errorf("token not found in Token 2 response")
}
log.Println("[VK Auth] Token 2 received")
// Token 2 → Token 3
vkDelay(100, 200)
// Token 3 (OK auth.anonymLogin)
log.Println("[VK Auth] Getting Token 3...")
sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
t3Data := fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA",
url.QueryEscape(sessionData))
resp, err = vkHTTPPost(ctx, t3Data, "https://calls.okcdn.ru/fb.do")
if err != nil {
return "", "", "", fmt.Errorf("Token 3 request error: %w", err)
}
if errMsg, ok := resp["error"].(string); ok && errMsg != "" {
return "", "", "", fmt.Errorf("Token 3 API error: %s", errMsg)
}
token3, ok := resp["session_key"].(string)
if !ok {
return "", "", "", fmt.Errorf("session_key not found in Token 3 response")
}
log.Println("[VK Auth] Token 3 received")
// Token 3 → Final (TURN)
vkDelay(100, 200)
// Final: vchat.joinConversationByLink (Token 4)
log.Println("[VK Auth] Getting TURN credentials (Token 4)...")
finalData := fmt.Sprintf(
"joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s",
url.QueryEscape(link), token2, token3)
resp, err = vkHTTPPost(ctx, finalData, "https://calls.okcdn.ru/fb.do")
if err != nil {
return "", "", "", fmt.Errorf("Final request error: %w", err)
}
if errMsg, ok := resp["error"].(string); ok && errMsg != "" {
return "", "", "", fmt.Errorf("Final API error: %s", errMsg)
}
ts, ok := resp["turn_server"].(map[string]interface{})
if !ok {
return "", "", "", fmt.Errorf("turn_server not found in response: %v", resp)
}
urls, _ := ts["urls"].([]interface{})
if len(urls) == 0 {
return "", "", "", fmt.Errorf("urls not found in turn_server")
}
urlStr, _ := urls[0].(string)
clean := strings.Split(urlStr, "?")[0]
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
username, _ := ts["username"].(string)
credential, _ := ts["credential"].(string)
if username == "" || credential == "" {
return "", "", "", fmt.Errorf("username or credential not found in turn_server")
}
log.Println("[VK Auth] TURN credentials received")
vkDelay(1500, 2500) // Final delay before exit
return username, credential, address, nil
}

358
client/wb.go

@ -0,0 +1,358 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package main
import (
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
)
const (
wbBase = "https://stream.wb.ru"
wbUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
// WbTurnCred stores a single TURN credential
type WbTurnCred struct {
URL string
Username string
Password string
}
// wbFetch adapts fetchWbCreds to the fetchFunc signature
func wbFetch(ctx context.Context, link string) (string, string, string, error) {
_ = link // WB doesn't use link parameter
creds, err := fetchWbCreds(ctx)
if err != nil {
return "", "", "", err
}
if len(creds) > 0 {
// Clean URL: "turn:host:port?transport=udp" -> "host:port"
clean := strings.Split(creds[0].URL, "?")[0]
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
return creds[0].Username, creds[0].Password, address, nil
}
return "", "", "", fmt.Errorf("no TURN credentials received from WB")
}
// wbHTTPClient creates a WB HTTP client
func wbHTTPClient() *http.Client {
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: &tls.Config{},
},
}
}
// wbReq makes an HTTP request to WB API
func wbReq(ctx context.Context, client *http.Client, method, ep string, body []byte, tok string) ([]byte, error) {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
rq, err := http.NewRequestWithContext(ctx, method, wbBase+ep, rd)
if err != nil {
return nil, err
}
rq.Header.Set("User-Agent", wbUA)
rq.Header.Set("Accept", "application/json")
rq.Header.Set("Accept-Language", "en-US,en;q=0.9")
rq.Header.Set("Origin", wbBase)
rq.Header.Set("Referer", wbBase+"/")
if body != nil {
rq.Header.Set("Content-Type", "application/json")
}
if tok != "" {
rq.Header.Set("Authorization", "Bearer "+tok)
}
rs, err := client.Do(rq)
if err != nil {
return nil, err
}
defer rs.Body.Close()
var r io.Reader = rs.Body
if rs.Header.Get("Content-Encoding") == "gzip" {
if g, e := gzip.NewReader(rs.Body); e == nil {
defer g.Close()
r = g
}
}
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
if rs.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d: %s", rs.StatusCode, string(b))
}
return b, nil
}
// fetchWbCreds performs the full WB credential acquisition flow
func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) {
client := wbHTTPClient()
defer client.CloseIdleConnections()
nm := fmt.Sprintf("lh_%d", time.Now().UnixMilli()%100000)
log.Println("[WB Auth] Step 1: Guest registration...")
rr, err := wbReq(ctx, client, "POST", "/auth/api/v1/auth/user/guest-register",
[]byte(`{"displayName":"`+nm+`"}`), "")
if err != nil {
return nil, fmt.Errorf("guest register: %w", err)
}
var reg struct {
AccessToken string `json:"accessToken"`
}
if err = json.Unmarshal(rr, &reg); err != nil {
return nil, fmt.Errorf("parse register response: %w", err)
}
if reg.AccessToken == "" {
return nil, fmt.Errorf("no access token in response")
}
log.Println("[WB Auth] Guest registered")
log.Println("[WB Auth] Step 2: Create room...")
rr, err = wbReq(ctx, client, "POST", "/api-room/api/v2/room",
[]byte(`{"roomType":"ROOM_TYPE_ALL_ON_SCREEN","roomPrivacy":"ROOM_PRIVACY_FREE"}`),
reg.AccessToken)
if err != nil {
return nil, fmt.Errorf("create room: %w", err)
}
var room struct {
RoomID string `json:"roomId"`
}
if err = json.Unmarshal(rr, &room); err != nil {
return nil, fmt.Errorf("parse room response: %w", err)
}
if room.RoomID == "" {
return nil, fmt.Errorf("no room ID in response")
}
roomPreview := room.RoomID
if len(roomPreview) > 8 {
roomPreview = roomPreview[:8]
}
log.Printf("[WB Auth] Room created: %s", roomPreview)
log.Println("[WB Auth] Step 3: Join room...")
_, err = wbReq(ctx, client, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", room.RoomID),
[]byte("{}"), reg.AccessToken)
if err != nil {
return nil, fmt.Errorf("join room: %w", err)
}
log.Println("[WB Auth] Step 4: Get room token...")
rr, err = wbReq(ctx, client, "GET", fmt.Sprintf(
"/api-room-manager/api/v1/room/%s/token?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=%s",
room.RoomID, url.QueryEscape(nm)), nil, reg.AccessToken)
if err != nil {
return nil, fmt.Errorf("get token: %w", err)
}
var tok struct {
RoomToken string `json:"roomToken"`
}
if err = json.Unmarshal(rr, &tok); err != nil {
return nil, fmt.Errorf("parse token response: %w", err)
}
if tok.RoomToken == "" {
return nil, fmt.Errorf("no room token in response")
}
log.Println("[WB Auth] Step 5: Negotiating ICE (LiveKit)...")
creds, err := wbLkICE(ctx, tok.RoomToken)
if err != nil {
return nil, fmt.Errorf("livekit ICE: %w", err)
}
for _, c := range creds {
log.Printf("[WB Auth] → %s", c.URL)
}
return creds, nil
}
// wbLkICE connects to LiveKit WebSocket and extracts TURN credentials
func wbLkICE(ctx context.Context, token string) ([]WbTurnCred, error) {
u := "wss://wbstream01-el.wb.ru:7880/rtc?access_token=" + url.QueryEscape(token) +
"&auto_subscribe=1&sdk=js&version=2.15.3&protocol=16&adaptive_stream=1"
conn, _, err := (&websocket.Dialer{
TLSClientConfig: &tls.Config{},
HandshakeTimeout: 10 * time.Second,
}).DialContext(ctx, u, http.Header{
"User-Agent": {wbUA},
"Origin": {wbBase},
})
if err != nil {
return nil, err
}
defer conn.Close()
for i := 0; i < 15; i++ {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
if creds := wbPbICE(msg); len(creds) > 0 {
return wbDedup(creds), nil
}
}
return nil, fmt.Errorf("TURN credentials not found in LiveKit response")
}
// PbVar reads protobuf varint
func wbPbVar(d []byte, o int) (uint64, int) {
var v uint64
for s := 0; o < len(d) && s < 64; s += 7 {
b := d[o]
o++
v |= uint64(b&0x7f) << s
if b < 0x80 {
return v, o
}
}
return 0, o
}
// PbAll finds all fields with given tag number in protobuf data
func wbPbAll(d []byte, f uint64) (r [][]byte) {
for o := 0; o < len(d); {
t, n := wbPbVar(d, o)
if n == o {
break
}
o = n
switch t & 7 {
case 0:
_, o = wbPbVar(d, o)
case 2:
l, n := wbPbVar(d, o)
o = n
e := o + int(l)
if e > len(d) || e < o {
return
}
if t>>3 == f {
r = append(r, d[o:e])
}
o = e
case 1:
o += 8
case 5:
o += 4
default:
return
}
}
return
}
// PbStr extracts string field with given tag number
func wbPbStr(d []byte, f uint64) string {
if a := wbPbAll(d, f); len(a) > 0 {
return string(a[0])
}
return ""
}
// PbICE extracts TURN/STUN credentials from protobuf message
func wbPbICE(d []byte) (res []WbTurnCred) {
for o := 0; o < len(d); {
t, n := wbPbVar(d, o)
if n == o {
break
}
o = n
switch t & 7 {
case 0:
_, o = wbPbVar(d, o)
case 2:
l, n := wbPbVar(d, o)
o = n
e := o + int(l)
if e > len(d) || e < o {
return
}
inner := d[o:e]
for _, f := range []uint64{5, 9} {
for _, blk := range wbPbAll(inner, f) {
urls := wbPbAll(blk, 1)
hit := false
for _, u := range urls {
s := string(u)
if strings.HasPrefix(s, "turn") || strings.HasPrefix(s, "stun") {
hit = true
break
}
}
if !hit {
continue
}
un, pw := wbPbStr(blk, 2), wbPbStr(blk, 3)
for _, u := range urls {
res = append(res, WbTurnCred{string(u), un, pw})
}
for _, blk2 := range wbPbAll(inner, f) {
if len(blk2) > 0 && len(blk) > 0 && &blk2[0] == &blk[0] {
continue
}
u2, p2 := wbPbStr(blk2, 2), wbPbStr(blk2, 3)
for _, u := range wbPbAll(blk2, 1) {
res = append(res, WbTurnCred{string(u), u2, p2})
}
}
return
}
}
o = e
case 1:
o += 8
case 5:
o += 4
default:
return
}
}
return
}
// wbDedup removes duplicate credentials
func wbDedup(cc []WbTurnCred) (r []WbTurnCred) {
seen := map[string]bool{}
for _, c := range cc {
k := c.URL + "|" + c.Username
if !seen[k] {
seen[k] = true
r = append(r, c)
}
}
return
}
Loading…
Cancel
Save