Browse Source

Merge d8c30b4ae9 into fbdd1e2738

pull/26/merge
kiper292 3 months ago
committed by GitHub
parent
commit
86b8cbf250
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .github/workflows/ci.yml
  2. 29
      .github/workflows/release.yml
  3. 269
      README.en.md
  4. 126
      README.md
  5. 151
      client/credentials.go
  6. 1262
      client/main.go
  7. 617
      client/manual_captcha.go
  8. 187
      client/namegen.go
  9. 82
      client/profiles.go
  10. 934
      client/slider_captcha.go
  11. 361
      client/wb.go
  12. 19
      go.mod
  13. 54
      go.sum
  14. 294
      server/main.go

4
.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:

29
.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

269
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:<wg_port>
```
### 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 <wg_server_ip>:56000 -vk-link <VK_link> -listen 127.0.0.1:9000
```
**WB mode:**
```bash
./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).
- `-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 <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. 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:
<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/WB.
Thanks to https://github.com/KillTheCensorship/Turnel for part of the code :)
WB Stream functionality is based on https://github.com/jaykaiperson/lionheart

126
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 <ip сервера wg>:56000 -vk-link <VK ссылка> -listen 127.0.0.1:9000
```
Или
**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-обфускации (может привести к бану).
- `-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 <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 от Администратора (чтобы скрипт прописывал маршруты):
@ -71,40 +116,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
@ -215,4 +262,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
}

1262
client/main.go

File diff suppressed because it is too large

617
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(`
<script>
(function() {
var localOrigin = %q;
var upstreamOrigin = %q;
function rewriteUrl(urlStr) {
if (!urlStr || typeof urlStr !== 'string') return urlStr;
if (urlStr.indexOf(localOrigin) === 0) return urlStr;
if (urlStr.indexOf(upstreamOrigin) === 0) return localOrigin + urlStr.slice(upstreamOrigin.length);
if (urlStr.indexOf('//') === 0) {
return '/generic_proxy?proxy_url=' + encodeURIComponent(window.location.protocol + urlStr);
}
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
return '/generic_proxy?proxy_url=' + encodeURIComponent(urlStr);
}
return urlStr;
}
function rewriteElementAttr(el, attr) {
if (!el || !el.getAttribute) return;
var value = el.getAttribute(attr);
if (!value) return;
var rewritten = rewriteUrl(value);
if (rewritten !== value) {
el.setAttribute(attr, rewritten);
}
}
function rewriteDocument(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('[href]').forEach(function(el) { rewriteElementAttr(el, 'href'); });
root.querySelectorAll('[src]').forEach(function(el) { rewriteElementAttr(el, 'src'); });
root.querySelectorAll('form[action]').forEach(function(el) { rewriteElementAttr(el, 'action'); });
}
function handleSuccessToken(token) {
if (!token) return;
fetch('/local-captcha-result', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'token=' + encodeURIComponent(token)
}).then(function() {
document.body.innerHTML = '<h2 style="text-align:center;margin-top:20vh">Done! You can close the page.</h2>';
setTimeout(function() { window.close(); }, 300);
}).catch(function() {});
}
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
if (arguments[1] && typeof arguments[1] === 'string') {
this._origUrl = arguments[1];
arguments[1] = rewriteUrl(arguments[1]);
}
return origOpen.apply(this, arguments);
};
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function() {
var xhr = this;
if (this._origUrl && this._origUrl.indexOf('captchaNotRobot.check') !== -1) {
xhr.addEventListener('load', function() {
try {
var data = JSON.parse(xhr.responseText);
if (data.response && data.response.success_token) {
handleSuccessToken(data.response.success_token);
}
} catch (e) {}
});
}
return origSend.apply(this, arguments);
};
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function() {
var url = arguments[0];
var isObj = (typeof url === 'object' && url && url.url);
var urlStr = isObj ? url.url : url;
var origUrlStr = urlStr;
if (typeof urlStr === 'string') {
urlStr = rewriteUrl(urlStr);
arguments[0] = urlStr;
}
var p = origFetch.apply(this, arguments);
if (typeof origUrlStr === 'string' && origUrlStr.indexOf('captchaNotRobot.check') !== -1) {
p.then(function(response) {
return response.clone().json();
}).then(function(data) {
if (data.response && data.response.success_token) {
handleSuccessToken(data.response.success_token);
}
}).catch(function() {});
}
return p;
};
}
document.addEventListener('submit', function(event) {
if (event.target && event.target.action) {
event.target.action = rewriteUrl(event.target.action);
}
}, true);
document.addEventListener('click', function(event) {
var target = event.target && event.target.closest ? event.target.closest('a[href]') : null;
if (target && target.href) {
target.href = rewriteUrl(target.href);
}
}, true);
var origFormSubmit = HTMLFormElement.prototype.submit;
HTMLFormElement.prototype.submit = function() {
if (this.action) {
this.action = rewriteUrl(this.action);
}
return origFormSubmit.apply(this, arguments);
};
var origWindowOpen = window.open;
if (origWindowOpen) {
window.open = function(url) {
if (typeof url === 'string') {
arguments[0] = rewriteUrl(url);
}
return origWindowOpen.apply(this, arguments);
};
}
rewriteDocument(document);
if (document.documentElement && window.MutationObserver) {
new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.target) {
rewriteElementAttr(mutation.target, mutation.attributeName);
return;
}
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
rewriteDocument(node);
}
});
});
}).observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['href', 'src', 'action']
});
}
})();
</script>
`, localOrigin, upstreamOrigin)
switch {
case strings.Contains(html, "</head>"):
return strings.Replace(html, "</head>", script+"</head>", 1)
case strings.Contains(html, "</body>"):
return strings.Replace(html, "</body>", script+"</body>", 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, `<!DOCTYPE html>
<html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:sans-serif;text-align:center;padding:20px}
img{max-width:100%%;margin:16px 0}
input{font-size:24px;padding:12px;width:80%%;box-sizing:border-box}
button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
</head><body>
<h2>Solve the Captcha</h2>
<img src="%s" alt="captcha"/>
<form onsubmit="fetch('/solve?key='+encodeURIComponent(document.getElementById('k').value)).then(()=>{document.body.innerHTML='<h2>Done!</h2>';setTimeout(function(){window.close();}, 300);});return false;">
<br><input id="k" type="text" autofocus placeholder="Text from image"/>
<br><button type="submit">Submit</button>
</form></body></html>`, captchaImg)
})
mux.HandleFunc("/solve", func(w http.ResponseWriter, r *http.Request) {
notifyKey(keyCh, r.URL.Query().Get("key"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Done!</h2></body></html>`)
})
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
}
func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) {
keyCh := make(chan string, 1)
targetURL, err := neturl.Parse(redirectURI)
if err != nil {
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := newCaptchaProxyTransport(dialer)
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(req *httputil.ProxyRequest) {
rewriteProxyRequest(req.Out, targetURL)
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[Captcha Proxy] ERROR for %s %s: %v", r.Method, r.URL.String(), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:20px"><h2>Captcha proxy error</h2><p>%s %s</p><p>%v</p></body></html>`, r.Method, r.URL.String(), err)
},
ModifyResponse: func(res *http.Response) error {
rewriteProxyCookies(res.Header)
if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" {
log.Printf("[Captcha Proxy] Redirecting to: %s", loc)
if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
res.Header.Set("Location", rewritten)
} else {
res.Header.Del("Location")
}
}
}
contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding")
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
shouldInspectBody := strings.Contains(contentType, "text/html") ||
strings.Contains(contentType, "application/xhtml+xml") ||
strings.Contains(res.Request.URL.Path, "captchaNotRobot.check")
if !shouldInspectBody {
return nil
}
reader := res.Body
if res.Header.Get("Content-Encoding") == "gzip" {
gzReader, err := gzip.NewReader(res.Body)
if err == nil {
reader = gzReader
defer func() {
if err := gzReader.Close(); err != nil {
log.Printf("failed to close gzip reader: %v", err)
}
}()
}
}
bodyBytes, err := io.ReadAll(reader)
if err != nil {
return err
}
if err := res.Body.Close(); err != nil {
return err
}
if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") {
notifyKey(keyCh, extractSuccessToken(bodyBytes))
}
if strings.Contains(contentType, "text/html") {
for _, headerName := range []string{
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"X-Content-Security-Policy",
"X-WebKit-CSP",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Embedder-Policy",
"Cross-Origin-Resource-Policy",
"X-Frame-Options",
"Strict-Transport-Security",
"Alt-Svc",
} {
res.Header.Del(headerName)
}
bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL))
res.Header.Del("Content-Encoding")
}
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
res.ContentLength = int64(len(bodyBytes))
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
return nil
},
}
mux := http.NewServeMux()
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form
w.Header().Set("Access-Control-Allow-Origin", "*")
_, _ = fmt.Fprint(w, "ok")
})
mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) {
targetAuthURL := r.URL.Query().Get("proxy_url")
targetParsed, err := neturl.Parse(targetAuthURL)
if err != nil || targetParsed.Host == "" {
http.Error(w, "Bad URL", http.StatusBadRequest)
return
}
genericReverse := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(req *httputil.ProxyRequest) {
req.Out.URL.Path = targetParsed.Path
req.Out.URL.RawQuery = targetParsed.RawQuery
rewriteProxyRequest(req.Out, targetParsed)
},
}
genericReverse.ServeHTTP(w, r)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.String())
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL))
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
return
}
proxy.ServeHTTP(w, r)
})
return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error")
}
func openBrowser(url string) {
for _, cmd := range browserOpenCommands(runtime.GOOS, url) {
if err := exec.Command(cmd.name, cmd.args...).Start(); err == nil {
return
}
}
}
func browserOpenCommands(goos string, url string) []browserCommand {
switch goos {
case "windows":
return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}}
case "darwin":
return []browserCommand{{name: "open", args: []string{url}}}
case "linux":
return []browserCommand{
{name: "xdg-open", args: []string{url}},
{name: "gio", args: []string{"open", url}},
}
case "android":
return []browserCommand{
{name: "termux-open-url", args: []string{url}},
{name: "/system/bin/am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}},
{name: "am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}},
{name: "xdg-open", args: []string{url}},
}
case "ios":
return []browserCommand{
{name: "open", args: []string{url}},
{name: "uiopen", args: []string{url}},
}
}
return nil
}

187
client/namegen.go

@ -0,0 +1,187 @@
package main
import (
"fmt"
"math/rand"
"strings"
)
var maleFirstNames = []string{
"Александр",
"Алексей",
"Андрей",
"Антон",
"Арсений",
"Артур",
"Артём",
"Богдан",
"Валерий",
"Василий",
"Виктор",
"Владислав",
"Глеб",
"Григорий",
"Даниил",
"Денис",
"Дмитрий",
"Евгений",
"Егор",
"Иван",
"Игорь",
"Илья",
"Кирилл",
"Леонид",
"Максим",
"Марк",
"Матвей",
"Михаил",
"Никита",
"Николай",
"Олег",
"Павел",
"Пётр",
"Роман",
"Руслан",
"Сергей",
"Станислав",
"Тимофей",
"Фёдор",
}
var femaleFirstNames = []string{
"Алина",
"Алёна",
"Анастасия",
"Ангелина",
"Анна",
"Вера",
"Вероника",
"Виктория",
"Дарья",
"Ева",
"Екатерина",
"Елена",
"Елизавета",
"Ирина",
"Кира",
"Кристина",
"Ксения",
"Любовь",
"Маргарита",
"Марина",
"Мария",
"Милана",
"Надежда",
"Наталья",
"Ольга",
"Полина",
"Светлана",
"София",
"Татьяна",
"Юлия",
"Яна",
}
var lastNames = []string{
"Алексеев",
"Андреев",
"Антонов",
"Баранов",
"Белов",
"Белый",
"Бельский",
"Беляев",
"Борисов",
"Васильев",
"Великий",
"Волков",
"Воробьёв",
"Григорьев",
"Давыдов",
"Егоров",
"Жуков",
"Зайцев",
"Захаров",
"Иванов",
"Калинин",
"Ковалёв",
"Козлов",
"Комаров",
"Крамской",
"Кузнецов",
"Кузьмин",
"Лебедев",
"Макаров",
"Медведев",
"Михайлов",
"Морозов",
"Никитин",
"Николаев",
"Новиков",
"Орлов",
"Островский",
"Павлов",
"Петров",
"Покровский",
"Попов",
"Раевский",
"Романов",
"Семёнов",
"Сергеев",
"Смирнов",
"Соколов",
"Соловьёв",
"Степанов",
"Тарасов",
"Титов",
"Толстой",
"Трубецкой",
"Филиппов",
"Фролов",
"Фёдоров",
"Чайковский",
"Черный",
"Яковлев",
}
// convertToFemaleSurname handles Russian suffix rules
func convertToFemaleSurname(surname string) string {
// Handle adjective-style surnames:
if strings.HasSuffix(surname, "ий") || strings.HasSuffix(surname, "ый") || strings.HasSuffix(surname, "ой") {
return surname[:len(surname)-4] + "ая"
}
// Handle standard possessive surnames:
if strings.HasSuffix(surname, "ов") || strings.HasSuffix(surname, "ев") ||
strings.HasSuffix(surname, "ин") || strings.HasSuffix(surname, "ын") ||
strings.HasSuffix(surname, "ёв") {
return surname + "а"
}
// Foreign or unchangeable
return surname
}
func generateName() string {
// Decide gender first
isFemale := rand.Intn(2) == 0
var fn string
if isFemale {
fn = femaleFirstNames[rand.Intn(len(femaleFirstNames))]
} else {
fn = maleFirstNames[rand.Intn(len(maleFirstNames))]
}
// 70% chance to have a last name
if rand.Float32() < 0.3 {
return fn
}
ln := lastNames[rand.Intn(len(lastNames))]
if isFemale {
ln = convertToFemaleSurname(ln)
}
return fmt.Sprintf("%s %s", fn, ln)
}

82
client/profiles.go

@ -0,0 +1,82 @@
package main
import (
"math/rand"
)
type Profile struct {
UserAgent string
SecChUa string
SecChUaMobile string
SecChUaPlatform string
}
// profiles contain paired User-Agent and Client Hints strings to harden bot detection.
var profile = []Profile{
// Windows Chrome
{
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: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
},
{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
},
{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
},
// Windows Edge
{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
},
{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0",
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
},
// macOS Chrome
{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"macOS"`,
},
{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"macOS"`,
},
// Linux Chrome
{
UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Linux"`,
},
{
UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`,
SecChUaMobile: "?0",
SecChUaPlatform: `"Linux"`,
},
}
// getRandomProfile returns a paired User-Agent and Client Hints profile.
func getRandomProfile() Profile {
return profile[rand.Intn(len(profile))]
}

934
client/slider_captcha.go

@ -0,0 +1,934 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/color"
_ "image/jpeg"
"io"
"log"
neturl "net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider"
defaultSliderAttempts = 4
)
type captchaNotRobotSession struct {
ctx context.Context
sessionToken string
hash string
streamID int
client tlsclient.HttpClient
profile Profile
browserFp string
}
type captchaSettingsResponse struct {
ShowCaptchaType string
SettingsByType map[string]string
}
type captchaCheckResult struct {
Status string
SuccessToken string
ShowCaptchaType string
}
type sliderCaptchaContent struct {
Image image.Image
Size int
Steps []int
Attempts int
}
type sliderCandidate struct {
Index int
ActiveSteps []int
Score int64
}
type captchaBootstrap struct {
PowInput string
Difficulty int
Settings *captchaSettingsResponse
}
func newCaptchaNotRobotSession(
ctx context.Context,
sessionToken string,
hash string,
streamID int,
client tlsclient.HttpClient,
profile Profile,
) *captchaNotRobotSession {
return &captchaNotRobotSession{
ctx: ctx,
sessionToken: sessionToken,
hash: hash,
streamID: streamID,
client: client,
profile: profile,
browserFp: generateBrowserFp(profile),
}
}
func (s *captchaNotRobotSession) baseValues() neturl.Values {
values := neturl.Values{}
values.Set("session_token", s.sessionToken)
values.Set("domain", "vk.com")
values.Set("adFp", "")
values.Set("access_token", "")
return values
}
func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) {
reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
httpResp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = httpResp.Body.Close()
}()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
var resp map[string]interface{}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) {
resp, err := s.request("captchaNotRobot.settings", s.baseValues())
if err != nil {
return nil, fmt.Errorf("settings failed: %w", err)
}
return parseCaptchaSettingsResponse(resp)
}
func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues()
values.Set("browser_fp", s.browserFp)
values.Set("device", buildCaptchaDeviceJSON(s.profile))
resp, err := s.request("captchaNotRobot.componentDone", values)
if err != nil {
return fmt.Errorf("componentDone failed: %w", err)
}
respObj, ok := resp["response"].(map[string]interface{})
if ok {
if status, _ := respObj["status"].(string); status != "" && status != "OK" {
return fmt.Errorf("componentDone status: %s", status)
}
}
return nil
}
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}")))
}
func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
values := s.baseValues()
if sliderSettings != "" {
values.Set("captcha_settings", sliderSettings)
}
resp, err := s.request("captchaNotRobot.getContent", values)
if err != nil {
return nil, fmt.Errorf("getContent failed: %w", err)
}
return parseSliderCaptchaContentResponse(resp)
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
answer, err := encodeSliderAnswer(activeSteps)
if err != nil {
return nil, err
}
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
}
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
values := s.baseValues()
values.Set("accelerometer", "[]")
values.Set("gyroscope", "[]")
values.Set("motion", "[]")
values.Set("cursor", cursor)
values.Set("taps", "[]")
values.Set("connectionRtt", "[]")
values.Set("connectionDownlink", "[]")
values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash)
values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo)
resp, err := s.request("captchaNotRobot.check", values)
if err != nil {
return nil, fmt.Errorf("check failed: %w", err)
}
return parseCaptchaCheckResult(resp)
}
func (s *captchaNotRobotSession) requestEndSession() {
log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID)
if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err)
}
}
func callCaptchaNotRobotWithSliderPOC(
ctx context.Context,
sessionToken string,
hash string,
streamID int,
client tlsclient.HttpClient,
profile Profile,
initialSettings *captchaSettingsResponse,
) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings()
if err != nil {
return "", err
}
settingsResp = mergeCaptchaSettings(settingsResp, initialSettings)
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
if err := session.requestComponentDone(); err != nil {
return "", err
}
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
initialCheck, err := session.requestCheckboxCheck()
if err != nil {
return "", err
}
if initialCheck.Status == "OK" {
if initialCheck.SuccessToken == "" {
return "", fmt.Errorf("success_token not found")
}
session.requestEndSession()
return initialCheck.SuccessToken, nil
}
sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType]
log.Printf(
"[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)",
streamID,
initialCheck.Status,
settingsResp.ShowCaptchaType,
initialCheck.ShowCaptchaType,
describeCaptchaTypes(settingsResp.SettingsByType),
)
if !hasSlider {
log.Printf(
"[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...",
streamID,
)
} else {
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID)
}
sliderContent, err := session.requestSliderContent(sliderSettings)
if err != nil {
log.Printf(
"[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...",
streamID,
err,
)
// Fallback: maybe it's just a checkbox that needs a human-like check
time.Sleep(300 * time.Millisecond)
finalCheck, err2 := session.requestCheckboxCheck()
if err2 == nil && finalCheck.Status == "OK" {
if finalCheck.SuccessToken == "" {
return "", fmt.Errorf("success_token not found in fallback check")
}
log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID)
session.requestEndSession()
return finalCheck.SuccessToken, nil
}
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
}
candidates, err := rankSliderCandidates(sliderContent.Image, sliderContent.Size, sliderContent.Steps)
if err != nil {
return "", err
}
log.Printf(
"[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d",
streamID,
len(candidates),
minInt(sliderContent.Attempts, len(candidates)),
sliderContent.Attempts,
)
successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) {
log.Printf(
"[STREAM %d] [Captcha] Slider guess position=%d score=%d",
streamID,
candidate.Index,
candidate.Score,
)
return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates))
})
if err != nil {
return "", err
}
session.requestEndSession()
return successToken, nil
}
func buildCaptchaDeviceJSON(profile Profile) string {
return fmt.Sprintf(
`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`,
profile.UserAgent,
)
}
func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) {
respObj, ok := resp["response"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid settings response: %v", resp)
}
settings := &captchaSettingsResponse{
SettingsByType: make(map[string]string),
}
settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"])
if !ok {
return settings, nil
}
for _, rawItem := range rawSettings {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
captchaType, _ := item["type"].(string)
if captchaType == "" {
continue
}
normalized, err := normalizeCaptchaSettings(item["settings"])
if err != nil {
return nil, fmt.Errorf("invalid captcha_settings for %s: %w", captchaType, err)
}
settings.SettingsByType[captchaType] = normalized
}
return settings, nil
}
func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) {
powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
powInputMatch := powInputRe.FindStringSubmatch(html)
if len(powInputMatch) < 2 {
return nil, fmt.Errorf("powInput not found in captcha HTML")
}
difficulty := 2
for _, expr := range []*regexp.Regexp{
regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`),
regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`),
} {
if match := expr.FindStringSubmatch(html); len(match) >= 2 {
if parsed, err := strconv.Atoi(match[1]); err == nil {
difficulty = parsed
break
}
}
}
settings, err := parseCaptchaSettingsFromHTML(html)
if err != nil {
return nil, err
}
return &captchaBootstrap{
PowInput: powInputMatch[1],
Difficulty: difficulty,
Settings: settings,
}, nil
}
func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) {
initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`)
initMatch := initRe.FindStringSubmatch(html)
if len(initMatch) < 2 {
return &captchaSettingsResponse{SettingsByType: make(map[string]string)}, nil
}
var initPayload struct {
Data struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings interface{} `json:"captcha_settings"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(initMatch[1]), &initPayload); err != nil {
return nil, fmt.Errorf("parse window.init captcha data: %w", err)
}
return parseCaptchaSettingsResponse(map[string]interface{}{
"response": map[string]interface{}{
"show_captcha_type": initPayload.Data.ShowCaptchaType,
"captcha_settings": initPayload.Data.CaptchaSettings,
},
})
}
func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse {
if primary == nil {
return cloneCaptchaSettings(fallback)
}
if primary.SettingsByType == nil {
primary.SettingsByType = make(map[string]string)
}
if fallback == nil {
return primary
}
if primary.ShowCaptchaType == "" {
primary.ShowCaptchaType = fallback.ShowCaptchaType
}
for captchaType, settings := range fallback.SettingsByType {
if _, exists := primary.SettingsByType[captchaType]; !exists {
primary.SettingsByType[captchaType] = settings
}
}
return primary
}
func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse {
if src == nil {
return nil
}
cloned := &captchaSettingsResponse{
ShowCaptchaType: src.ShowCaptchaType,
SettingsByType: make(map[string]string, len(src.SettingsByType)),
}
for captchaType, settings := range src.SettingsByType {
cloned.SettingsByType[captchaType] = settings
}
return cloned
}
func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) {
switch value := raw.(type) {
case nil:
return nil, false
case []interface{}:
return value, true
case map[string]interface{}:
items := make([]interface{}, 0, len(value))
for captchaType, settings := range value {
items = append(items, map[string]interface{}{
"type": captchaType,
"settings": settings,
})
}
return items, true
case string:
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, false
}
var items []interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err == nil {
return items, true
}
var mapping map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &mapping); err == nil {
return expandCaptchaSettings(mapping)
}
}
return nil, false
}
func normalizeCaptchaSettings(raw interface{}) (string, error) {
switch value := raw.(type) {
case nil:
return "", nil
case string:
return value, nil
default:
data, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(data), nil
}
}
func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) {
respObj, ok := resp["response"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid check response: %v", resp)
}
result := &captchaCheckResult{}
result.Status, _ = respObj["status"].(string)
result.SuccessToken, _ = respObj["success_token"].(string)
result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
if result.Status == "" {
return nil, fmt.Errorf("check status missing: %v", resp)
}
return result, nil
}
func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) {
respObj, ok := resp["response"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid slider content response: %v", resp)
}
status, _ := respObj["status"].(string)
if status != "OK" {
return nil, fmt.Errorf("slider getContent status: %s", status)
}
extension, _ := respObj["extension"].(string)
extension = strings.ToLower(extension)
if extension != "jpeg" && extension != "jpg" {
return nil, fmt.Errorf("unsupported slider image format: %s", extension)
}
rawImage, _ := respObj["image"].(string)
if rawImage == "" {
return nil, fmt.Errorf("slider image missing")
}
rawSteps, ok := respObj["steps"].([]interface{})
if !ok {
return nil, fmt.Errorf("slider steps missing")
}
steps, err := parseIntSlice(rawSteps)
if err != nil {
return nil, err
}
size, swaps, attempts, err := parseSliderSteps(steps)
if err != nil {
return nil, err
}
img, err := decodeSliderImage(rawImage)
if err != nil {
return nil, err
}
return &sliderCaptchaContent{
Image: img,
Size: size,
Steps: swaps,
Attempts: attempts,
}, nil
}
func parseIntSlice(raw []interface{}) ([]int, error) {
values := make([]int, 0, len(raw))
for _, item := range raw {
number, err := parseIntValue(item)
if err != nil {
return nil, err
}
values = append(values, number)
}
return values, nil
}
func parseIntValue(raw interface{}) (int, error) {
switch value := raw.(type) {
case float64:
return int(value), nil
case int:
return value, nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("invalid numeric value: %v", raw)
}
return parsed, nil
default:
return 0, fmt.Errorf("invalid numeric value: %v", raw)
}
}
func parseSliderSteps(steps []int) (int, []int, int, error) {
if len(steps) < 3 {
return 0, nil, 0, fmt.Errorf("slider steps payload too short")
}
size := steps[0]
if size <= 0 {
return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
}
remaining := append([]int(nil), steps[1:]...)
attempts := defaultSliderAttempts
if len(remaining)%2 != 0 {
attempts = remaining[len(remaining)-1]
remaining = remaining[:len(remaining)-1]
}
if attempts <= 0 {
attempts = defaultSliderAttempts
}
if len(remaining) == 0 || len(remaining)%2 != 0 {
return 0, nil, 0, fmt.Errorf("invalid slider swap payload")
}
return size, remaining, attempts, nil
}
func decodeSliderImage(rawImage string) (image.Image, error) {
decoded, err := base64.StdEncoding.DecodeString(rawImage)
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(decoded))
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
return img, nil
}
func encodeSliderAnswer(activeSteps []int) (string, error) {
payload := struct {
Value []int `json:"value"`
}{
Value: activeSteps,
}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(data), nil
}
func buildSliderActiveSteps(swaps []int, candidateIndex int) []int {
if candidateIndex <= 0 {
return []int{}
}
end := candidateIndex * 2
if end > len(swaps) {
end = len(swaps)
}
return append([]int(nil), swaps[:end]...)
}
func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) {
tileCount := gridSize * gridSize
if tileCount <= 0 {
return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
}
if len(activeSteps)%2 != 0 {
return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps))
}
mapping := make([]int, tileCount)
for i := range mapping {
mapping[i] = i
}
for idx := 0; idx < len(activeSteps); idx += 2 {
left := activeSteps[idx]
right := activeSteps[idx+1]
if left < 0 || right < 0 || left >= tileCount || right >= tileCount {
return nil, fmt.Errorf("slider step out of range: %d,%d", left, right)
}
mapping[left], mapping[right] = mapping[right], mapping[left]
}
return mapping, nil
}
func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) {
candidateCount := len(swaps) / 2
if candidateCount == 0 {
return nil, fmt.Errorf("slider has no candidates")
}
candidates := make([]sliderCandidate, 0, candidateCount)
for idx := 1; idx <= candidateCount; idx++ {
activeSteps := buildSliderActiveSteps(swaps, idx)
mapping, err := buildSliderTileMapping(gridSize, activeSteps)
if err != nil {
return nil, err
}
score, err := scoreSliderCandidate(img, gridSize, mapping)
if err != nil {
return nil, err
}
candidates = append(candidates, sliderCandidate{
Index: idx,
ActiveSteps: activeSteps,
Score: score,
})
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score == candidates[j].Score {
return candidates[i].Index < candidates[j].Index
}
return candidates[i].Score < candidates[j].Score
})
return candidates, nil
}
func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
rendered, err := renderSliderCandidate(img, gridSize, mapping)
if err != nil {
return 0, err
}
return scoreRenderedSliderImage(rendered, gridSize), nil
}
func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
if gridSize <= 0 {
return nil, fmt.Errorf("invalid grid size: %d", gridSize)
}
tileCount := gridSize * gridSize
if len(mapping) != tileCount {
return nil, fmt.Errorf("unexpected tile mapping length: %d", len(mapping))
}
bounds := img.Bounds()
rendered := image.NewRGBA(bounds)
for dstIndex, srcIndex := range mapping {
srcRect := sliderTileRect(bounds, gridSize, srcIndex)
dstRect := sliderTileRect(bounds, gridSize, dstIndex)
copyScaledTile(rendered, dstRect, img, srcRect)
}
return rendered, nil
}
func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
height := minInt(leftRect.Dy(), rightRect.Dy())
for offset := 0; offset < height; offset++ {
score += pixelDiff(
img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
img.At(rightRect.Min.X, rightRect.Min.Y+offset),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
width := minInt(topRect.Dx(), bottomRect.Dx())
for offset := 0; offset < width; offset++ {
score += pixelDiff(
img.At(topRect.Min.X+offset, topRect.Max.Y-1),
img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
)
}
}
}
return score
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
x0 := bounds.Min.X + col*bounds.Dx()/gridSize
x1 := bounds.Min.X + (col+1)*bounds.Dx()/gridSize
y0 := bounds.Min.Y + row*bounds.Dy()/gridSize
y1 := bounds.Min.Y + (row+1)*bounds.Dy()/gridSize
return image.Rect(x0, y0, x1, y1)
}
func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) {
if dstRect.Empty() || srcRect.Empty() {
return
}
dstWidth := dstRect.Dx()
dstHeight := dstRect.Dy()
srcWidth := srcRect.Dx()
srcHeight := srcRect.Dy()
for y := 0; y < dstHeight; y++ {
sy := srcRect.Min.Y + y*srcHeight/dstHeight
for x := 0; x < dstWidth; x++ {
sx := srcRect.Min.X + x*srcWidth/dstWidth
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, src.At(sx, sy))
}
}
}
func pixelDiff(left color.Color, right color.Color) int64 {
lr, lg, lb, _ := left.RGBA()
rr, rg, rb, _ := right.RGBA()
return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb)
}
func absDiff(left uint32, right uint32) int64 {
if left > right {
return int64(left - right)
}
return int64(right - left)
}
func generateSliderCursor(candidateIndex int, candidateCount int) string {
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
}
func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string {
if candidateCount <= 0 {
return "[]"
}
type cursorPoint struct {
X int `json:"x"`
Y int `json:"y"`
T int64 `json:"t"`
}
startX := 140
endX := startX + 620*candidateIndex/candidateCount
startY := 430
points := make([]cursorPoint, 0, 12)
for step := 0; step < 12; step++ {
x := startX + (endX-startX)*step/11
y := startY + ((step % 3) - 1)
points = append(points, cursorPoint{
X: x,
Y: y,
T: startTime + int64(step*18),
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}
func trySliderCaptchaCandidates(
candidates []sliderCandidate,
maxAttempts int,
check func(candidate sliderCandidate) (*captchaCheckResult, error),
) (string, error) {
if len(candidates) == 0 {
return "", fmt.Errorf("slider has no ranked candidates")
}
limit := minInt(maxAttempts, len(candidates))
if limit <= 0 {
return "", fmt.Errorf("slider has no attempts available")
}
for idx := 0; idx < limit; idx++ {
result, err := check(candidates[idx])
if err != nil {
return "", err
}
switch result.Status {
case "OK":
if result.SuccessToken == "" {
return "", fmt.Errorf("success_token not found")
}
return result.SuccessToken, nil
case "ERROR_LIMIT":
return "", fmt.Errorf("slider check status: %s", result.Status)
default:
continue
}
}
return "", fmt.Errorf("slider guesses exhausted")
}
func minInt(left int, right int) int {
if left < right {
return left
}
return right
}
func describeCaptchaTypes(settingsByType map[string]string) string {
if len(settingsByType) == 0 {
return "none"
}
types := make([]string, 0, len(settingsByType))
for captchaType := range settingsByType {
types = append(types, captchaType)
}
sort.Strings(types)
return strings.Join(types, ",")
}

361
client/wb.go

@ -0,0 +1,361 @@
// 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"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"github.com/gorilla/websocket"
)
const (
wbBase = "https://stream.wb.ru"
)
// 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")
}
// wbReq makes an HTTP request to WB API using tls-client
func wbReq(ctx context.Context, client tlsclient.HttpClient, profile Profile, method, ep string, body []byte, tok string) ([]byte, error) {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
rq, err := fhttp.NewRequestWithContext(ctx, method, wbBase+ep, rd)
if err != nil {
return nil, err
}
applyBrowserProfileFhttp(rq, profile)
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) {
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"`,
}
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
tlsclient.WithTimeoutSeconds(20),
tlsclient.WithClientProfile(profiles.Chrome_146),
tlsclient.WithDialer(getCustomNetDialer()),
)
if err != nil {
return nil, fmt.Errorf("failed to initialize tls_client: %w", err)
}
nm := fmt.Sprintf("lh_%d", time.Now().UnixMilli()%100000)
log.Println("[WB Auth] Step 1: Guest registration...")
rr, err := wbReq(ctx, client, profile, "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, profile, "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, profile, "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, profile, "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, profile.UserAgent)
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, userAgent 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"
header := http.Header{}
header.Set("User-Agent", userAgent)
header.Set("Origin", wbBase)
conn, _, err := (&websocket.Dialer{
TLSClientConfig: &tls.Config{},
HandshakeTimeout: 10 * time.Second,
}).DialContext(ctx, u, header)
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
}

19
go.mod

@ -3,6 +3,9 @@ module github.com/cacggghp/vk-turn-proxy
go 1.25.5
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.14.0
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45
github.com/cbeuw/connutil v1.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@ -12,10 +15,26 @@ require (
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/miekg/dns v1.1.69 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

54
go.sum

@ -1,11 +1,37 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A=
github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5TvZm8FVcuHP1288k+DEu1XM26DtRjcidOxpGXs=
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU=
github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA=
github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@ -20,17 +46,45 @@ github.com/pion/turn/v5 v5.0.2 h1:GHlDk+fiegz+yibb3ch+tK+iPFokoVWiM+aVJakySqA=
github.com/pion/turn/v5 v5.0.2/go.mod h1:cumcsSEF2ytAtDhDwkYgYhv1uJ3AOP7a4pFt0NL/snY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

294
server/main.go

@ -5,6 +5,7 @@ import (
"crypto/tls"
"flag"
"fmt"
"io"
"log"
"net"
"os"
@ -17,6 +18,136 @@ import (
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
)
type streamEntry struct {
id byte
conn net.Conn
}
type UserSession struct {
ID string
Conns []streamEntry
BackendConn net.Conn
Lock sync.RWMutex
Ctx context.Context
Cancel context.CancelFunc
Manager *SessionManager
}
type SessionManager struct {
Sessions map[string]*UserSession
Lock sync.RWMutex
}
func (s *SessionManager) GetOrCreate(ctx context.Context, id string, connectAddr string) (*UserSession, error) {
s.Lock.Lock()
defer s.Lock.Unlock()
if session, ok := s.Sessions[id]; ok {
return session, nil
}
backendConn, err := net.Dial("udp", connectAddr)
if err != nil {
return nil, err
}
sessionCtx, cancel := context.WithCancel(ctx)
session := &UserSession{
ID: id,
Conns: make([]streamEntry, 0),
BackendConn: backendConn,
Manager: s,
Ctx: sessionCtx,
Cancel: cancel,
}
s.Sessions[id] = session
go session.backendReaderLoop()
return session, nil
}
func (s *UserSession) backendReaderLoop() {
defer s.Cleanup()
buf := make([]byte, 1600)
var lastUsed uint32 = 0
for {
select {
case <-s.Ctx.Done():
return
default:
}
s.BackendConn.SetReadDeadline(time.Now().Add(time.Minute * 5))
n, err := s.BackendConn.Read(buf)
if err != nil {
log.Printf("Session %s backend read error: %v", s.ID, err)
return
}
s.Lock.RLock()
nConns := uint32(len(s.Conns))
if nConns == 0 {
s.Lock.RUnlock()
continue
}
// Fast Round-robin selection using local variable
lastUsed = (lastUsed + 1) % nConns
conn := s.Conns[lastUsed].conn
s.Lock.RUnlock()
conn.SetWriteDeadline(time.Now().Add(time.Second * 10))
_, err = conn.Write(buf[:n])
if err != nil {
log.Printf("Session %s DTLS write error: %v", s.ID, err)
conn.Close()
}
}
}
func (s *UserSession) AddConn(id byte, conn net.Conn) {
s.Lock.Lock()
defer s.Lock.Unlock()
// Evict existing connection with same ID
for i, entry := range s.Conns {
if entry.id == id {
//log.Printf("Session %s: Evicting old stream %d", s.ID, id)
entry.conn.Close()
s.Conns[i].conn = conn
return
}
}
s.Conns = append(s.Conns, streamEntry{id: id, conn: conn})
}
func (s *UserSession) RemoveConn(id byte, conn net.Conn) {
s.Lock.Lock()
defer s.Lock.Unlock()
for i, entry := range s.Conns {
if entry.id == id && entry.conn == conn {
s.Conns = append(s.Conns[:i], s.Conns[i+1:]...)
break
}
}
}
func (s *UserSession) Cleanup() {
s.Cancel()
s.BackendConn.Close()
s.Manager.Lock.Lock()
delete(s.Manager.Sessions, s.ID)
s.Manager.Lock.Unlock()
s.Lock.Lock()
for _, entry := range s.Conns {
entry.conn.Close()
}
s.Conns = nil
s.Lock.Unlock()
}
func main() {
listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port")
connect := flag.String("connect", "", "connect to ip:port")
@ -41,17 +172,12 @@ func main() {
if len(*connect) == 0 {
log.Panicf("server address is required")
}
// Generate a certificate and private key to secure the connection
certificate, genErr := selfsign.GenerateSelfSigned()
if genErr != nil {
panic(err)
panic(genErr)
}
//
// Everything below is the pion-DTLS API! Thanks for using it ❤️.
//
// Prepare the configuration of the DTLS connection
config := &dtls.Config{
Certificates: []tls.Certificate{certificate},
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
@ -59,131 +185,87 @@ func main() {
ConnectionIDGenerator: dtls.RandomCIDGenerator(8),
}
// Connect to a DTLS server
listener, err := dtls.Listen("udp", addr, config)
if err != nil {
panic(err)
}
context.AfterFunc(ctx, func() {
if err = listener.Close(); err != nil {
panic(err)
}
listener.Close()
})
fmt.Println("Listening")
manager := &SessionManager{
Sessions: make(map[string]*UserSession),
}
log.Printf("Listening on %s, forwarding to %s", *listen, *connect)
wg1 := sync.WaitGroup{}
for {
select {
case <-ctx.Done():
wg1.Wait()
return
default:
}
// Wait for a connection.
conn, err := listener.Accept()
if err != nil {
log.Println(err)
continue
select {
case <-ctx.Done():
return
default:
log.Println("Accept error:", err)
continue
}
}
wg1.Add(1)
go func(conn net.Conn) {
defer wg1.Done()
defer conn.Close() // graceful shutdown
var err error = nil
log.Printf("Connection from %s\n", conn.RemoteAddr())
// `conn` is of type `net.Conn` but may be casted to `dtls.Conn`
// using `dtlsConn := conn.(*dtls.Conn)` in order to to expose
// functions like `ConnectionState` etc.
// Perform the handshake with a 30-second timeout
ctx1, cancel1 := context.WithTimeout(ctx, 30*time.Second)
defer conn.Close()
dtlsConn, ok := conn.(*dtls.Conn)
if !ok {
log.Println("Type error")
cancel1()
return
}
log.Println("Start handshake")
if err = dtlsConn.HandshakeContext(ctx1); err != nil {
log.Println(err)
cancel1()
handshakeCtx, hCancel := context.WithTimeout(ctx, 30*time.Second)
defer hCancel()
if err := dtlsConn.HandshakeContext(handshakeCtx); err != nil {
log.Println("Handshake failed:", err)
return
}
cancel1()
log.Println("Handshake done")
serverConn, err := net.Dial("udp", *connect)
// Phase 1: Read Session ID + Stream ID (17 bytes)
idBuf := make([]byte, 17)
conn.SetReadDeadline(time.Now().Add(time.Second * 5))
_, err := io.ReadFull(conn, idBuf)
if err != nil {
log.Println(err)
log.Println("Failed to read session ID:", err)
return
}
defer func() {
if err = serverConn.Close(); err != nil {
log.Printf("failed to close outgoing connection: %s", err)
sessionID := fmt.Sprintf("%x", idBuf[:16])
streamID := idBuf[16]
session, err := manager.GetOrCreate(ctx, sessionID, *connect)
if err != nil {
log.Println("Failed to get/create session:", err)
return
}
session.AddConn(streamID, conn)
defer session.RemoveConn(streamID, conn)
log.Printf("New stream %d for session %s from %s", streamID, sessionID, conn.RemoteAddr())
// Upstream Loop: DTLS -> Backend
buf := make([]byte, 1600)
for {
conn.SetReadDeadline(time.Now().Add(time.Minute * 5))
n, err := conn.Read(buf)
if err != nil {
log.Printf("Stream %s closed: %v", sessionID, err)
return
}
}()
var wg sync.WaitGroup
wg.Add(2)
ctx2, cancel2 := context.WithCancel(ctx)
context.AfterFunc(ctx2, func() {
conn.SetDeadline(time.Now())
serverConn.SetDeadline(time.Now())
})
go func() {
defer wg.Done()
defer cancel2()
buf := make([]byte, 1600)
for {
select {
case <-ctx2.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(time.Minute * 30))
n, err1 := conn.Read(buf)
if err1 != nil {
log.Printf("Failed: %s", err1)
return
}
serverConn.SetWriteDeadline(time.Now().Add(time.Minute * 30))
_, err1 = serverConn.Write(buf[:n])
if err1 != nil {
log.Printf("Failed: %s", err1)
return
}
}
}()
go func() {
defer wg.Done()
defer cancel2()
buf := make([]byte, 1600)
for {
select {
case <-ctx2.Done():
return
default:
}
serverConn.SetReadDeadline(time.Now().Add(time.Minute * 30))
n, err1 := serverConn.Read(buf)
if err1 != nil {
log.Printf("Failed: %s", err1)
return
}
conn.SetWriteDeadline(time.Now().Add(time.Minute * 30))
_, err1 = conn.Write(buf[:n])
if err1 != nil {
log.Printf("Failed: %s", err1)
return
}
session.BackendConn.SetWriteDeadline(time.Now().Add(time.Second * 5))
_, err = session.BackendConn.Write(buf[:n])
if err != nil {
log.Printf("Session %s backend write error: %v", sessionID, err)
return
}
}()
wg.Wait()
log.Printf("Connection closed: %s\n", conn.RemoteAddr())
}
}(conn)
}
}

Loading…
Cancel
Save