diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d0135cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.gocache/
diff --git a/.github/workflows/.golangci.yml b/.golangci.yml
similarity index 93%
rename from .github/workflows/.golangci.yml
rename to .golangci.yml
index db48597..ddbe2fb 100644
--- a/.github/workflows/.golangci.yml
+++ b/.golangci.yml
@@ -49,13 +49,13 @@ linters:
- third_party$
- builtin$
- examples$
+ rules:
+ - linters:
+ - errcheck
+ source: "doRequest|packetPool\\.Get"
issues:
max-issues-per-linter: 0
max-same-issues: 0
- exclude-rules:
- - linters:
- - errcheck
- source: "doRequest|packetPool\\.Get"
formatters:
exclusions:
generated: lax
diff --git a/Dockerfile b/Dockerfile
index f3c8350..9ba25b1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,8 +13,9 @@ WORKDIR /app
COPY docker-entrypoint.sh .
COPY --from=builder /build/vk-turn-proxy .
-RUN chmod +x docker-entrypoint.sh
+RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
+EXPOSE 56000/tcp
EXPOSE 56000/udp
ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/README.md b/README.md
index 468f45f..c9b8be9 100644
--- a/README.md
+++ b/README.md
@@ -1,543 +1,421 @@
-# Good TURN
+# VK TURN Proxy
-Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или ~~Яндекс телемоста~~. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок.
+VK TURN Proxy - клиент и сервер для прокидывания локального UDP/TCP-трафика через TURN-реле, получаемые из ссылки на VK Calls. Типичный сценарий - поднять небольшой `server` на VPS рядом с WireGuard или Xray, а на клиентском устройстве запустить `client`, который слушает локальный адрес вроде `127.0.0.1:9000`.
-Только для учебных целей!
+> [!CAUTION]
+> Проект предназначен для обучения, исследований и администрирования собственных стендов. Используйте его только там, где у вас есть право запускать такой трафик и менять сетевую конфигурацию.
-## Похожие проекты
+## Содержание
-> [!WARNING]
-> Авторы данного репозитория не несут ответственности за другие похожие проекты.
+- [Как это работает](#как-это-работает)
+- [Возможности](#возможности)
+- [Что нужно](#что-нужно)
+- [Быстрый старт: WireGuard](#быстрый-старт-wireguard)
+ - [Запуск сервера на VPS](#1-запустите-сервер-на-vps)
+ - [Настройка WireGuard](#2-настройте-wireguard-на-клиенте)
+ - [Запуск клиента](#3-запустите-клиент)
+- [Android через Termux](#android-через-termux)
+- [iOS через iSH](#ios-через-ish)
+- [systemd-сервис](#сервер-как-systemd-сервис)
+- [Docker](#docker)
+- [VLESS / Xray](#vless--xray)
+- [WRAP-режим](#wrap-режим)
+- [Яндекс Телемост](#яндекс-телемост)
+- [Флаги клиента](#флаги-клиента)
+- [Флаги сервера](#флаги-сервера)
+- [Captcha](#captcha)
+- [Сборка из исходников](#сборка-из-исходников)
+- [Решение проблем](#решение-проблем)
+- [Похожие проекты](#похожие-проекты)
+- [Лицензия](#лицензия)
-#### Server
-- https://github.com/Urtyom-Alyanov/turn-proxy - реализация на Rust
-- https://github.com/jaykaiperson/lionheart - аналог для https://stream.wb.ru (статья: https://habr.com/ru/articles/1017410/)
-- https://github.com/kulikov0/whitelist-bypass - проброс через медиасервер SFU ВК и Яндекс Телемоста
-- https://github.com/NedgNDG/vk-proxy-auto-installer - автоустановщик VK TURN Proxy (TUI)
+## Как Это Работает
-#### Android
-##### Мои любимые
-- https://github.com/samosvalishe/turn-proxy-android - клиент для андроида c Material 3 UI и автоапдейтами (Kotlin)
-- https://github.com/MYSOREZ/vk-turn-proxy-android - клиент для андроида
-- https://github.com/kiper292/wireguard-turn-android - клиент для андроида интегрированный в WireGuard
-##### Ещё
-- https://github.com/WINGS-N/WINGSV - клиент для андроида с One UI, WireGuard, раздачей VPN с root
-- https://github.com/oxsidee/vkpn - клиент для андроида (кроссплатформенный Flutter)
-- https://github.com/amurcanov/proxy-turn-vk-android - клиент для андроида с WireGuard
+Схема для WireGuard:
-#### iOS
-- https://github.com/nullcstring/turnbridge - клиент для iOS
-
-#### macOS
-- https://github.com/denny4-user/vk-turn-proxy-macos-gui - клиент для macOS
+```text
+WireGuard client -> 127.0.0.1:9000 -> VK TURN Proxy client
+ -> VK TURN relay -> VK TURN Proxy server на VPS
+ -> 127.0.0.1:<порт WireGuard> -> WireGuard server
+```
+Клиент берет временные TURN-учетные данные из ссылки VK Calls, открывает одно или несколько соединений к TURN-реле и отправляет через них трафик к вашему `server`. Между `client` и `server` используется DTLS. Для WireGuard сервер пересылает данные в UDP backend, для VLESS/Xray - в TCP backend через KCP и smux.
-## Настройка
+## Возможности
-Нам понадобится:
+- VK Calls как основной источник TURN-учетных данных.
+- TCP или UDP подключение клиента к TURN-реле.
+- Несколько параллельных TURN-потоков через `-n`.
+- WireGuard/Hysteria-подобный UDP backend.
+- VLESS/Xray TCP backend через `-vless`.
+- Bonding для VLESS через `-vless-bond`.
+- Дополнительная WRAP-обфускация DTLS-пакетов через `-wrap`.
+- Автоматическое и ручное прохождение VK captcha.
+- Docker-образ для серверной части.
-1. Ссылка на действующий ВК звонок: создаём свой (нужен аккаунт вк), или гуглим `"https://vk.com/call/join/"`.
- Ссылка действительна вечно, если не нажимать "завершить звонок для всех"
-2. Или ссылка на звонок Яндекс телемоста: `"https://telemost.yandex.ru/j/"`. Её лучше не гуглить, так как видно подключение к конференции
-3. VPS с установленным WireGuard
-4. Для андроида: скачать Termux из F-Droid
+## Что Нужно
-### Сервер
+- VPS с публичным IP.
+- На VPS уже должен слушать backend:
+ - WireGuard: обычно `127.0.0.1:51820/udp`;
+ - Xray/VLESS: обычно `127.0.0.1:443/tcp`.
+- Ссылка на активный VK Calls вида `https://vk.com/call/join/...`.
+- На клиенте: WireGuard, Xray или другой локальный клиент, который будет ходить в `127.0.0.1:9000`.
-Рекомендуется tmux
+Ссылку VK Calls лучше создать самостоятельно. Не завершайте звонок для всех, если хотите использовать эту ссылку дальше.
-На сервере запустить tmux:
+## Быстрый Старт: WireGuard
-```bash
-# Создание сессии tmux
-tmux new -s vkturn
-```
+### 1. Запустите Сервер На VPS
-Внутри сессии tmux запустить команду сервера ниже. Далее нажать `Ctrl+B` `D`, чтобы свернуть сессию, не завершая её. Прокси процесс останется запущенным, сервер будет доступен для новых команд или безопасного выхода из него.
+Скачайте бинарник для Linux amd64:
```bash
-# Войти в ранее созданную сессию tmux
-tmux a -t vkturn
+curl -L -o server https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/server-linux-amd64
+chmod +x server
```
-
-
-Скачать бинарник, в данном примере используется самый популярный сервер `server-linux-amd64`:
+Запустите `server`, указав локальный адрес WireGuard:
```bash
-# Скачать бинарник
-curl -L -o server https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/server-linux-amd64 && chmod +x server
+./server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820
```
-```bash
-# Запуск сервера
-./server -listen 0.0.0.0:56000 -connect 127.0.0.1:<порт wg>
-```
-#### Установка демона
-На сервере в файле `/etc/systemd/system/vk-turn-proxy.service`
-```
-[Unit]
-Description=VK Turn Proxy Service
-After=network.target
+Порт `56000/udp` должен быть доступен снаружи. Если WireGuard слушает другой порт, замените `51820`.
-[Service]
-Type=simple
-ExecStart=/opt/vk-turn-proxy/server-linux-amd64 -listen 0.0.0.0:56000 -connect 127.0.0.1:
-KillMode=process
-Restart=always
-RestartSec=5
-User=nobody
-Group=nogroup
-StandardOutput=append:/var/log/vk-turn-proxy/vk-turn-proxy.log
-StandardError=append:/var/log/vk-turn-proxy/vk-turn-proxy_error.log
-SyslogIdentifier=vk-turn-proxy
+### 2. Настройте WireGuard На Клиенте
-[Install]
-WantedBy=multi-user.target
-```
-Где `/opt/vk-turn-proxy/server-linux-amd64` - путь к файлу, `` - порт сервера wg
-```
-systemctl daemon-reload
-systemctl enable vk-turn-proxy.service
-systemctl start vk-turn-proxy.service
-```
-#### Docker
+В клиентском конфиге WireGuard замените endpoint сервера на локальный адрес VK TURN Proxy:
-Образ Docker публикуется в GitHub Container Registry:
-
-```
-docker pull ghcr.io/cacggghp/vk-turn-proxy:latest
-docker tag ghcr.io/cacggghp/vk-turn-proxy:latest vkt
+```ini
+Endpoint = 127.0.0.1:9000
+MTU = 1280
```
-Для Linux-сервера, где `xray` или WireGuard слушает локально, удобнее запускать через host network:
+На Android добавьте Termux или приложение-клиент в исключения WireGuard. На Windows, Linux и macOS перед включением WireGuard нужно добавить маршрут до TURN-реле, иначе клиент может попытаться подключаться к TURN уже через сам VPN.
-```
-docker run --rm --network host -e CONNECT_ADDR=127.0.0.1:<порт wg> vkt
-```
+### 3. Запустите Клиент
-Если нужен bridge mode:
+Linux:
-```
-docker run --rm -p 56000:56000/udp -e CONNECT_ADDR=:<порт wg> vkt
+```bash
+curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-linux-amd64
+chmod +x client
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" | ./routes.sh
```
-Сборка образа вручную:
+Windows PowerShell от администратора:
-```
-git clone https://github.com/cacggghp/vk-turn-proxy.git
-cd vk-turn-proxy
-docker build -t vk-turn-proxy .
+```powershell
+Invoke-WebRequest -Uri https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe
+.\client.exe -listen 127.0.0.1:9000 -peer :56000 -vk-link "" | .\routes.ps1
```
-Переменная окружения **CONNECT_ADDR** — адрес WireGuard (обязательный), например `192.168.1.10:51820`.
+macOS:
-Пример запуска:
-
-```
-docker run -p 56000:56000/udp -e CONNECT_ADDR=192.168.1.10:51820 vk-turn-proxy
+```bash
+curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-darwin-arm64
+chmod +x client
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" | ./routes-macos.sh
```
-### Клиент
+После появления соединения включите WireGuard.
-#### Android
+Если вы скачали только бинарник, но не клонировали репозиторий, возьмите нужный route-скрипт из этого репозитория: `routes.sh`, `routes.ps1` или `routes-macos.sh`.
-См. [клиенты](#android).
+## Android Через Termux
-**Альтернативный способ (через Termux):**
+1. Установите Termux из F-Droid.
+2. В WireGuard укажите `Endpoint = 127.0.0.1:9000` и `MTU = 1280`.
+3. Добавьте Termux в исключения WireGuard.
+4. Запустите в Termux:
-- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
-- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".**
- В Termux:
-
-```
+```bash
termux-wake-lock
+curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-android-arm64
+chmod +x client
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link ""
```
-Телефон не будет уходить в глубокий сон, так что на ночь ставьте на зарядку. Чтобы отключить:
+Чтобы снять wake lock:
-```
+```bash
termux-wake-unlock
```
-Скачиваем бинарник в локальную папку, даём права на исполнение, в команде указана самая популярная архитектура `client-android-arm64`:
+## iOS Через iSH
+
+Это запасной вариант, если нет нативного клиента.
```bash
-curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-android-arm64 && chmod +x client
+apk update
+apk add curl
+curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-linux-386
+chmod +x client
+GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer :56000 -vk-link ""
```
-Запускаем:
+Чтобы iSH дольше жил в фоне, можно в начале сессии выполнить:
-```
-./client -listen 127.0.0.1:9000 -peer :56000 -vk-link
+```bash
+cat /dev/location > /dev/null &
```
-Или
+## Сервер Как systemd-Сервис
-```
-./client -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000
-```
-
-**Если после включения VPN в терминале вылезают ошибки DNS, попробуйте в Wireguard включить VPN только для нужных приложений.**
+Пример `/etc/systemd/system/vk-turn-proxy.service`:
-#### iOS
+```ini
+[Unit]
+Description=VK TURN Proxy server
+After=network.target
-См. [клиенты](#ios).
+[Service]
+Type=simple
+ExecStart=/opt/vk-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820
+Restart=always
+RestartSec=5
+User=nobody
+Group=nogroup
-**Альтернативный способ (через iSH Shell):**
+[Install]
+WantedBy=multi-user.target
+```
-Скачать приложение [iSH Shell](https://apps.apple.com/ru/app/ish-shell/id1436902243):
+Применить:
```bash
-# Установить curl, если его нет
-apk update
-apk add curl
+sudo systemctl daemon-reload
+sudo systemctl enable --now vk-turn-proxy.service
+sudo systemctl status vk-turn-proxy.service
```
+## Docker
+
+Образ публикуется в GitHub Container Registry:
+
```bash
-# Скачать бинарник и дать права на запуск
-curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-linux-386 && chmod +x client
+docker pull ghcr.io/cacggghp/vk-turn-proxy:latest
```
+Если backend слушает на хосте, удобнее использовать host network:
+
```bash
-# Запустить клиент
-./client -listen 127.0.0.1:9000 -peer :56000 -vk-link
+docker run --rm --network host \
+ -e CONNECT_ADDR=127.0.0.1:51820 \
+ ghcr.io/cacggghp/vk-turn-proxy:latest
```
-####
-В конфиге WireGuard (WG) есть строка
-```
-AllowedIPs = 0.0.0.0/0, ::0
+Bridge mode:
+
+```bash
+docker run --rm -p 56000:56000/udp \
+ -e CONNECT_ADDR=:51820 \
+ ghcr.io/cacggghp/vk-turn-proxy:latest
```
-Что означает разрешить весь интернет.
-Для данной утилиты, нужно исключить IP адреса ВК, т.к. для звонков он подключается к диапазону 155.212.192.0/20, то можно исключить этот диапазон.
+Переменные окружения:
-В конфиге WG, замените строку AllowedIPs:
-```
-AllowedIPs = 0.0.0.0/1, 128.0.0.0/4, 144.0.0.0/5, 152.0.0.0/7, 154.0.0.0/8, 155.0.0.0/9, 155.128.0.0/10, 155.192.0.0/12, 155.208.0.0/14, 155.212.0.0/17, 155.212.128.0/18, 155.212.208.0/20, 155.212.224.0/19, 155.213.0.0/16, 155.214.0.0/15, 155.216.0.0/13, 155.224.0.0/11, 156.0.0.0/6, 160.0.0.0/3, 192.0.0.0/2
-```
+| Переменная | По умолчанию | Описание |
+| --- | --- | --- |
+| `CONNECT_ADDR` | обязательна | backend, куда сервер пересылает трафик |
+| `LISTEN_ADDR` | `0.0.0.0:56000` | адрес прослушивания сервера |
+| `VLESS_MODE` | `false` | включает `-vless` |
+| `VLESS_BOND` | `false` | включает `-vless-bond` |
+| `WRAP_MODE` | `false` | включает `-wrap` |
+| `WRAP_KEY` | пусто | ключ для `-wrap-key` |
+| `VK_TURN_KCP_PROFILE` | `balanced` | профиль KCP (`fast`, `balanced`, `slow`) |
+| `VK_TURN_KCP_MTU` | `1200` | переопределить MTU для KCP |
-Строка со всеми исключёнными адресами ВК:
-```
-AllowedIPs = 0.0.0.0/6, 4.0.0.0/8, 5.0.0.0/11, 5.32.0.0/12, 5.48.0.0/13, 5.56.0.0/14, 5.60.0.0/16, 5.61.0.0/20, 5.61.24.0/21, 5.61.32.0/19, 5.61.64.0/18, 5.61.128.0/18, 5.61.192.0/19, 5.61.224.0/21, 5.61.240.0/20, 5.62.0.0/15, 5.64.0.0/11, 5.96.0.0/14, 5.100.0.0/16, 5.101.0.0/19, 5.101.32.0/21, 5.101.44.0/22, 5.101.48.0/20, 5.101.64.0/18, 5.101.128.0/17, 5.102.0.0/15, 5.104.0.0/13, 5.112.0.0/12, 5.128.0.0/11, 5.160.0.0/12, 5.176.0.0/14, 5.180.0.0/16, 5.181.0.0/19, 5.181.32.0/20, 5.181.48.0/21, 5.181.56.0/22, 5.181.64.0/18, 5.181.128.0/17, 5.182.0.0/15, 5.184.0.0/14, 5.188.0.0/17, 5.188.128.0/21, 5.188.136.0/22, 5.188.144.0/20, 5.188.160.0/19, 5.188.192.0/18, 5.189.0.0/16, 5.190.0.0/15, 5.192.0.0/10, 6.0.0.0/7, 8.0.0.0/5, 16.0.0.0/5, 24.0.0.0/6, 28.0.0.0/7, 30.0.0.0/8, 31.0.0.0/9, 31.128.0.0/11, 31.160.0.0/12, 31.176.0.0/16, 31.177.0.0/18, 31.177.64.0/19, 31.177.96.0/21, 31.177.108.0/22, 31.177.112.0/20, 31.177.128.0/17, 31.178.0.0/15, 31.180.0.0/14, 31.184.0.0/13, 31.192.0.0/10, 32.0.0.0/6, 36.0.0.0/8, 37.0.0.0/9, 37.128.0.0/13, 37.136.0.0/15, 37.138.0.0/16, 37.139.0.0/19, 37.139.36.0/22, 37.139.44.0/22, 37.139.48.0/20, 37.139.64.0/18, 37.139.128.0/17, 37.140.0.0/14, 37.144.0.0/12, 37.160.0.0/11, 37.192.0.0/10, 38.0.0.0/7, 40.0.0.0/6, 44.0.0.0/8, 45.0.0.0/10, 45.64.0.0/12, 45.80.0.0/14, 45.84.0.0/17, 45.84.132.0/22, 45.84.136.0/21, 45.84.144.0/20, 45.84.160.0/19, 45.84.192.0/18, 45.85.0.0/16, 45.86.0.0/15, 45.88.0.0/13, 45.96.0.0/11, 45.128.0.0/13, 45.136.0.0/20, 45.136.16.0/22, 45.136.24.0/21, 45.136.32.0/19, 45.136.64.0/18, 45.136.128.0/17, 45.137.0.0/16, 45.138.0.0/15, 45.140.0.0/14, 45.144.0.0/12, 45.160.0.0/11, 45.192.0.0/10, 46.0.0.0/7, 48.0.0.0/5, 56.0.0.0/6, 60.0.0.0/7, 62.0.0.0/9, 62.128.0.0/10, 62.192.0.0/12, 62.208.0.0/13, 62.216.0.0/16, 62.217.0.0/17, 62.217.128.0/19, 62.217.176.0/20, 62.217.192.0/18, 62.218.0.0/15, 62.220.0.0/14, 62.224.0.0/11, 63.0.0.0/8, 64.0.0.0/5, 72.0.0.0/6, 76.0.0.0/7, 78.0.0.0/8, 79.0.0.0/9, 79.128.0.0/13, 79.136.0.0/16, 79.137.0.0/17, 79.137.128.0/20, 79.137.144.0/21, 79.137.152.0/22, 79.137.156.0/24, 79.137.158.0/23, 79.137.160.0/21, 79.137.168.0/22, 79.137.172.0/23, 79.137.176.0/20, 79.137.192.0/19, 79.137.224.0/20, 79.137.248.0/21, 79.138.0.0/15, 79.140.0.0/14, 79.144.0.0/12, 79.160.0.0/11, 79.192.0.0/10, 80.0.0.0/7, 82.0.0.0/8, 83.0.0.0/9, 83.128.0.0/11, 83.160.0.0/14, 83.164.0.0/15, 83.166.0.0/17, 83.166.128.0/18, 83.166.192.0/19, 83.166.224.0/21, 83.166.240.0/21, 83.167.0.0/16, 83.168.0.0/13, 83.176.0.0/12, 83.192.0.0/12, 83.208.0.0/13, 83.216.0.0/16, 83.217.0.0/17, 83.217.128.0/18, 83.217.192.0/20, 83.217.208.0/21, 83.217.220.0/22, 83.217.224.0/19, 83.218.0.0/15, 83.220.0.0/15, 83.222.0.0/20, 83.222.16.0/21, 83.222.24.0/22, 83.222.32.0/19, 83.222.64.0/18, 83.222.128.0/17, 83.223.0.0/16, 83.224.0.0/11, 84.0.0.0/12, 84.16.0.0/14, 84.20.0.0/15, 84.22.0.0/16, 84.23.0.0/19, 84.23.32.0/20, 84.23.48.0/22, 84.23.56.0/21, 84.23.64.0/18, 84.23.128.0/17, 84.24.0.0/13, 84.32.0.0/11, 84.64.0.0/10, 84.128.0.0/9, 85.0.0.0/9, 85.128.0.0/10, 85.192.0.0/19, 85.192.36.0/22, 85.192.40.0/21, 85.192.48.0/20, 85.192.64.0/18, 85.192.128.0/17, 85.193.0.0/16, 85.194.0.0/15, 85.196.0.0/14, 85.200.0.0/13, 85.208.0.0/12, 85.224.0.0/11, 86.0.0.0/8, 87.0.0.0/9, 87.128.0.0/10, 87.192.0.0/11, 87.224.0.0/13, 87.232.0.0/14, 87.236.0.0/15, 87.238.0.0/16, 87.239.0.0/18, 87.239.64.0/19, 87.239.96.0/21, 87.239.112.0/20, 87.239.128.0/17, 87.240.0.0/15, 87.242.0.0/18, 87.242.64.0/19, 87.242.96.0/20, 87.242.116.0/22, 87.242.120.0/21, 87.242.128.0/17, 87.243.0.0/16, 87.244.0.0/14, 87.248.0.0/13, 88.0.0.0/8, 89.0.0.0/9, 89.128.0.0/10, 89.192.0.0/12, 89.208.0.0/18, 89.208.64.0/20, 89.208.80.0/22, 89.208.88.0/21, 89.208.96.0/19, 89.208.128.0/18, 89.208.192.0/22, 89.208.200.0/21, 89.208.212.0/22, 89.208.224.0/22, 89.208.232.0/21, 89.208.240.0/20, 89.209.0.0/16, 89.210.0.0/15, 89.212.0.0/14, 89.216.0.0/14, 89.220.0.0/16, 89.221.0.0/17, 89.221.128.0/18, 89.221.192.0/19, 89.221.224.0/22, 89.221.240.0/20, 89.222.0.0/15, 89.224.0.0/11, 90.0.0.0/9, 90.128.0.0/12, 90.144.0.0/13, 90.152.0.0/14, 90.156.0.0/17, 90.156.128.0/20, 90.156.144.0/22, 90.156.152.0/21, 90.156.160.0/19, 90.156.192.0/20, 90.156.208.0/22, 90.156.220.0/22, 90.156.224.0/21, 90.156.240.0/20, 90.157.0.0/16, 90.158.0.0/15, 90.160.0.0/11, 90.192.0.0/10, 91.0.0.0/9, 91.128.0.0/10, 91.192.0.0/12, 91.208.0.0/13, 91.216.0.0/15, 91.218.0.0/16, 91.219.0.0/17, 91.219.128.0/18, 91.219.192.0/19, 91.219.228.0/22, 91.219.232.0/21, 91.219.240.0/20, 91.220.0.0/14, 91.224.0.0/14, 91.228.0.0/15, 91.230.0.0/16, 91.231.0.0/17, 91.231.128.0/22, 91.231.136.0/21, 91.231.144.0/20, 91.231.160.0/19, 91.231.192.0/18, 91.232.0.0/13, 91.240.0.0/12, 92.0.0.0/11, 92.32.0.0/14, 92.36.0.0/15, 92.38.0.0/17, 92.38.128.0/18, 92.38.192.0/20, 92.38.208.0/21, 92.38.216.0/24, 92.38.218.0/23, 92.38.220.0/22, 92.38.224.0/19, 92.39.0.0/16, 92.40.0.0/13, 92.48.0.0/12, 92.64.0.0/10, 92.128.0.0/9, 93.0.0.0/8, 94.0.0.0/10, 94.64.0.0/11, 94.96.0.0/14, 94.100.0.0/17, 94.100.128.0/19, 94.100.160.0/20, 94.100.192.0/18, 94.101.0.0/16, 94.102.0.0/15, 94.104.0.0/13, 94.112.0.0/12, 94.128.0.0/13, 94.136.0.0/15, 94.138.0.0/16, 94.139.0.0/17, 94.139.128.0/18, 94.139.192.0/19, 94.139.224.0/20, 94.139.240.0/22, 94.139.248.0/21, 94.140.0.0/14, 94.144.0.0/12, 94.160.0.0/11, 94.192.0.0/10, 95.0.0.0/9, 95.128.0.0/11, 95.160.0.0/15, 95.162.0.0/16, 95.163.0.0/19, 95.163.64.0/18, 95.163.128.0/22, 95.163.132.0/24, 95.163.134.0/23, 95.163.136.0/21, 95.163.144.0/20, 95.163.160.0/20, 95.163.176.0/22, 95.163.184.0/21, 95.163.192.0/20, 95.163.220.0/22, 95.163.224.0/20, 95.163.240.0/21, 95.164.0.0/14, 95.168.0.0/13, 95.176.0.0/12, 95.192.0.0/10, 96.0.0.0/5, 104.0.0.0/6, 108.0.0.0/8, 109.0.0.0/10, 109.64.0.0/11, 109.96.0.0/12, 109.112.0.0/13, 109.120.0.0/17, 109.120.128.0/19, 109.120.160.0/20, 109.120.176.0/22, 109.120.184.0/22, 109.120.192.0/18, 109.121.0.0/16, 109.122.0.0/15, 109.124.0.0/14, 109.128.0.0/9, 110.0.0.0/7, 112.0.0.0/4, 128.0.0.0/9, 128.128.0.0/13, 128.136.0.0/14, 128.140.0.0/17, 128.140.128.0/19, 128.140.160.0/21, 128.140.176.0/20, 128.140.192.0/18, 128.141.0.0/16, 128.142.0.0/15, 128.144.0.0/12, 128.160.0.0/11, 128.192.0.0/10, 129.0.0.0/8, 130.0.0.0/11, 130.32.0.0/12, 130.48.0.0/16, 130.49.0.0/17, 130.49.128.0/18, 130.49.192.0/19, 130.50.0.0/15, 130.52.0.0/14, 130.56.0.0/13, 130.64.0.0/10, 130.128.0.0/9, 131.0.0.0/8, 132.0.0.0/6, 136.0.0.0/5, 144.0.0.0/7, 146.0.0.0/9, 146.128.0.0/11, 146.160.0.0/12, 146.176.0.0/13, 146.184.0.0/16, 146.185.0.0/17, 146.185.128.0/18, 146.185.192.0/20, 146.185.212.0/22, 146.185.216.0/21, 146.185.224.0/20, 146.185.244.0/22, 146.185.248.0/21, 146.186.0.0/15, 146.188.0.0/14, 146.192.0.0/10, 147.0.0.0/8, 148.0.0.0/6, 152.0.0.0/7, 154.0.0.0/8, 155.0.0.0/9, 155.128.0.0/10, 155.192.0.0/12, 155.208.0.0/14, 155.212.0.0/17, 155.212.128.0/18, 155.212.208.0/20, 155.212.224.0/19, 155.213.0.0/16, 155.214.0.0/15, 155.216.0.0/13, 155.224.0.0/11, 156.0.0.0/6, 160.0.0.0/8, 161.0.0.0/10, 161.64.0.0/11, 161.96.0.0/13, 161.104.0.0/18, 161.104.64.0/19, 161.104.96.0/21, 161.104.112.0/20, 161.104.128.0/17, 161.105.0.0/16, 161.106.0.0/15, 161.108.0.0/14, 161.112.0.0/12, 161.128.0.0/9, 162.0.0.0/7, 164.0.0.0/6, 168.0.0.0/5, 176.0.0.0/10, 176.64.0.0/11, 176.96.0.0/12, 176.112.0.0/17, 176.112.128.0/19, 176.112.160.0/21, 176.112.176.0/20, 176.112.192.0/18, 176.113.0.0/16, 176.114.0.0/15, 176.116.0.0/14, 176.120.0.0/13, 176.128.0.0/9, 177.0.0.0/8, 178.0.0.0/12, 178.16.0.0/14, 178.20.0.0/15, 178.22.0.0/18, 178.22.64.0/20, 178.22.80.0/21, 178.22.96.0/19, 178.22.128.0/17, 178.23.0.0/16, 178.24.0.0/13, 178.32.0.0/11, 178.64.0.0/10, 178.128.0.0/10, 178.192.0.0/11, 178.224.0.0/13, 178.232.0.0/14, 178.236.0.0/16, 178.237.0.0/20, 178.237.32.0/19, 178.237.64.0/18, 178.237.128.0/17, 178.238.0.0/15, 178.240.0.0/12, 179.0.0.0/8, 180.0.0.0/6, 184.0.0.0/8, 185.0.0.0/14, 185.4.0.0/16, 185.5.0.0/17, 185.5.128.0/21, 185.5.140.0/22, 185.5.144.0/20, 185.5.160.0/19, 185.5.192.0/18, 185.6.0.0/15, 185.8.0.0/13, 185.16.0.0/17, 185.16.128.0/20, 185.16.144.0/22, 185.16.152.0/21, 185.16.160.0/19, 185.16.192.0/19, 185.16.224.0/20, 185.16.240.0/22, 185.16.248.0/21, 185.17.0.0/16, 185.18.0.0/15, 185.20.0.0/14, 185.24.0.0/13, 185.32.0.0/11, 185.64.0.0/12, 185.80.0.0/14, 185.84.0.0/15, 185.86.0.0/17, 185.86.128.0/20, 185.86.148.0/22, 185.86.152.0/21, 185.86.160.0/19, 185.86.192.0/18, 185.87.0.0/16, 185.88.0.0/13, 185.96.0.0/14, 185.100.0.0/18, 185.100.64.0/19, 185.100.96.0/21, 185.100.108.0/22, 185.100.112.0/20, 185.100.128.0/17, 185.101.0.0/16, 185.102.0.0/15, 185.104.0.0/13, 185.112.0.0/12, 185.128.0.0/15, 185.130.0.0/18, 185.130.64.0/19, 185.130.96.0/20, 185.130.116.0/22, 185.130.120.0/21, 185.130.128.0/17, 185.131.0.0/18, 185.131.64.0/22, 185.131.72.0/21, 185.131.80.0/20, 185.131.96.0/19, 185.131.128.0/17, 185.132.0.0/14, 185.136.0.0/13, 185.144.0.0/12, 185.160.0.0/12, 185.176.0.0/14, 185.180.0.0/17, 185.180.128.0/18, 185.180.192.0/21, 185.180.204.0/22, 185.180.208.0/20, 185.180.224.0/19, 185.181.0.0/16, 185.182.0.0/15, 185.184.0.0/15, 185.186.0.0/16, 185.187.0.0/19, 185.187.32.0/20, 185.187.48.0/21, 185.187.56.0/22, 185.187.60.0/23, 185.187.62.0/24, 185.187.64.0/18, 185.187.128.0/17, 185.188.0.0/14, 185.192.0.0/11, 185.224.0.0/15, 185.226.0.0/19, 185.226.32.0/20, 185.226.48.0/22, 185.226.56.0/21, 185.226.64.0/18, 185.226.128.0/17, 185.227.0.0/16, 185.228.0.0/14, 185.232.0.0/13, 185.240.0.0/16, 185.241.0.0/17, 185.241.128.0/18, 185.241.196.0/22, 185.241.200.0/21, 185.241.208.0/20, 185.241.224.0/19, 185.242.0.0/15, 185.244.0.0/14, 185.248.0.0/13, 186.0.0.0/7, 188.0.0.0/10, 188.64.0.0/12, 188.80.0.0/13, 188.88.0.0/14, 188.92.0.0/16, 188.93.0.0/19, 188.93.32.0/20, 188.93.48.0/21, 188.93.64.0/18, 188.93.128.0/17, 188.94.0.0/15, 188.96.0.0/11, 188.128.0.0/9, 189.0.0.0/8, 190.0.0.0/7, 192.0.0.0/8, 193.0.0.0/9, 193.128.0.0/10, 193.192.0.0/13, 193.200.0.0/15, 193.202.0.0/16, 193.203.0.0/19, 193.203.32.0/21, 193.203.44.0/22, 193.203.48.0/20, 193.203.64.0/18, 193.203.128.0/17, 193.204.0.0/14, 193.208.0.0/12, 193.224.0.0/11, 194.0.0.0/9, 194.128.0.0/11, 194.160.0.0/12, 194.176.0.0/13, 194.184.0.0/15, 194.186.0.0/19, 194.186.32.0/20, 194.186.48.0/21, 194.186.56.0/22, 194.186.60.0/23, 194.186.62.0/24, 194.186.64.0/18, 194.186.128.0/17, 194.187.0.0/16, 194.188.0.0/14, 194.192.0.0/10, 195.0.0.0/9, 195.128.0.0/10, 195.192.0.0/12, 195.208.0.0/15, 195.210.0.0/16, 195.211.0.0/20, 195.211.16.0/22, 195.211.24.0/21, 195.211.32.0/19, 195.211.64.0/18, 195.211.128.0/17, 195.212.0.0/14, 195.216.0.0/15, 195.218.0.0/17, 195.218.128.0/19, 195.218.160.0/20, 195.218.176.0/21, 195.218.184.0/22, 195.218.188.0/23, 195.218.192.0/18, 195.219.0.0/16, 195.220.0.0/14, 195.224.0.0/11, 196.0.0.0/6, 200.0.0.0/5, 208.0.0.0/6, 212.0.0.0/10, 212.64.0.0/11, 212.96.0.0/13, 212.104.0.0/14, 212.108.0.0/15, 212.110.0.0/16, 212.111.0.0/18, 212.111.64.0/20, 212.111.80.0/22, 212.111.88.0/21, 212.111.96.0/19, 212.111.128.0/17, 212.112.0.0/12, 212.128.0.0/10, 212.192.0.0/11, 212.224.0.0/13, 212.232.0.0/16, 212.233.0.0/18, 212.233.64.0/21, 212.233.80.0/21, 212.233.100.0/22, 212.233.104.0/21, 212.233.112.0/21, 212.233.124.0/22, 212.233.128.0/17, 212.234.0.0/15, 212.236.0.0/14, 212.240.0.0/12, 213.0.0.0/9, 213.128.0.0/10, 213.192.0.0/12, 213.208.0.0/13, 213.216.0.0/15, 213.218.0.0/16, 213.219.0.0/17, 213.219.128.0/18, 213.219.192.0/20, 213.219.208.0/22, 213.219.216.0/21, 213.219.224.0/19, 213.220.0.0/14, 213.224.0.0/11, 214.0.0.0/7, 216.0.0.0/8, 217.0.0.0/12, 217.16.0.0/20, 217.16.32.0/19, 217.16.64.0/18, 217.16.128.0/17, 217.17.0.0/16, 217.18.0.0/15, 217.20.0.0/17, 217.20.128.0/20, 217.20.160.0/19, 217.20.192.0/18, 217.21.0.0/16, 217.22.0.0/15, 217.24.0.0/13, 217.32.0.0/11, 217.64.0.0/14, 217.68.0.0/16, 217.69.0.0/17, 217.69.144.0/20, 217.69.160.0/19, 217.69.192.0/18, 217.70.0.0/15, 217.72.0.0/13, 217.80.0.0/12, 217.96.0.0/11, 217.128.0.0/11, 217.160.0.0/13, 217.168.0.0/14, 217.172.0.0/15, 217.174.0.0/17, 217.174.128.0/19, 217.174.160.0/20, 217.174.176.0/21, 217.174.184.0/22, 217.174.192.0/18, 217.175.0.0/16, 217.176.0.0/12, 217.192.0.0/10, 218.0.0.0/7, 220.0.0.0/6, 224.0.0.0/3
+Сборка образа вручную:
+
+```bash
+docker build -t vk-turn-proxy .
```
-Утилита, где самому можно добавить ещё свои адреса в исключения: https://www.procustodibus.com/blog/2021/03/wireguard-allowedips-calculator/
+## VLESS / Xray
-Конфиг WG с изменённым полем `AllowedIPs` добавить в любом клиенте WG и запустить.
+В режиме `-vless` VK TURN Proxy прокидывает TCP-соединения. На VPS `server` подключается к локальному TCP backend, например к Xray inbound на `127.0.0.1:443`. На клиенте `client` слушает локальный TCP адрес, на который должен смотреть ваш Xray/v2rayN/sing-box клиент.
-#### Linux
+Сервер:
-В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
+```bash
+./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless
+```
-Скрипт будет добавлять маршруты к нужным ip:
+Клиент:
+```bash
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" -vless
```
-./client-linux -peer :56000 -vk-link -listen 127.0.0.1:9000 | sudo routes.sh
+
+С bonding:
+
+```bash
+./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -vless-bond
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" -vless -vless-bond -n 4
```
+## WRAP-Режим
+
+`-wrap` дополнительно оборачивает DTLS-пакеты ChaCha20-XOR перед отправкой в TURN ChannelData. Ключ должен совпадать на клиенте и сервере.
+
+Сгенерировать ключ:
+
+```bash
+./server -gen-wrap-key
```
-./client-linux -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | sudo routes.sh
+
+Запуск:
+
+```bash
+./server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -wrap -wrap-key <64-hex-key>
+./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" -wrap -wrap-key <64-hex-key>
```
-Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
+`-wrap` нельзя использовать вместе с `-no-dtls`.
-#### macOS
+## Настройка KCP (VLESS)
-См. [клиенты](#macos).
+В режиме `-vless` для передачи данных поверх DTLS используется KCP. Его можно настроить через переменные окружения (работает и для клиента, и для сервера):
-**Альтернативный способ (через Terminal):**
+| Переменная | Профили / Значения | Описание |
+| --- | --- | --- |
+| `VK_TURN_KCP_PROFILE` | `fast`, `balanced`, `slow` | Предустановленные режимы работы KCP. |
+| `VK_TURN_KCP_MTU` | например, `1200` | Максимальный размер пакета. |
-- В клиентском конфиге WireGuard меняем адрес сервера на 127.0.0.1:9000, ставим MTU 1280.
-- Добавляем Terminal в исключения WireGuard. Нажимаем "сохранить".
+**Профили:**
+- `fast` (или `legacy`): Минимальные задержки, активная переотправка, MTU 1280.
+- `balanced` (или `cc`): Оптимальный баланс для большинства сетей, MTU 1200.
+- `slow` (или `conservative`): Для очень нестабильных каналов, MTU 1150.
-В Terminal:
+Для более тонкой настройки доступны переменные: `VK_TURN_KCP_NODELAY`, `VK_TURN_KCP_INTERVAL`, `VK_TURN_KCP_RESEND`, `VK_TURN_KCP_NC`, `VK_TURN_KCP_SNDWND`, `VK_TURN_KCP_RCVWND`, `VK_TURN_KCP_ACK_NODELAY`.
-Скачать бинарник (Apple Silicon):
-```bash
-curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-darwin-arm64 && chmod +x client
-```
+## Яндекс Телемост
-Скачать бинарник (Intel):
+Поддержка `-yandex-link` оставлена в коде, но этот режим считается нестабильным и может не работать. Если используете его, обычно нужен `-udp` и ручной TURN IP:
```bash
-curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-darwin-amd64 && chmod +x client
-```
-
-Запустить клиент
+./client -udp -turn 5.255.211.241 -listen 127.0.0.1:9000 -peer :56000 -yandex-link ""
+```
+
+## Флаги Клиента
+
+| Флаг | По умолчанию | Описание |
+| --- | --- | --- |
+| `-listen` | `127.0.0.1:9000` | локальный адрес для WireGuard или Xray клиента |
+| `-peer` | обязательный | адрес VK TURN Proxy server на VPS, например `:56000` |
+| `-vk-link` | пусто | ссылка VK Calls |
+| `-yandex-link` | пусто | ссылка Яндекс Телемоста, legacy-режим |
+| `-n` | VK: `10`, Yandex: `1` | количество TURN-соединений |
+| `-udp` | `false` | подключаться к TURN-реле по UDP вместо TCP |
+| `-turn` | из ссылки | переопределить IP TURN-сервера |
+| `-port` | из ссылки | переопределить порт TURN-сервера |
+| `-vless` | `false` | TCP/VLESS режим |
+| `-vless-bond` | `false` | распределять одно TCP-соединение по активным smux-сессиям |
+| `-wrap` | `false` | включить WRAP-обфускацию |
+| `-wrap-key` | пусто | 32-байтный ключ в hex, 64 символа |
+| `-gen-wrap-key` | `false` | напечатать новый WRAP-ключ и выйти |
+| `-manual-captcha` | `false` | сразу использовать ручное прохождение captcha |
+| `-captcha-host` | пусто | host:port для manual captcha, например `192.168.99.1:8765` |
+| `-captcha-solver` | `v2` | авто-решатель captcha: `v1` или `v2` |
+| `-streams-per-cred` | `10` | сколько потоков используют один кеш TURN-учетных данных |
+| `-debug` | `false` | подробные логи |
+| `-no-dtls` | `false` | прямой режим без DTLS, не рекомендуется |
+
+Нужно указать ровно одну ссылку: `-vk-link` или `-yandex-link`.
+
+## Флаги Сервера
+
+| Флаг | По умолчанию | Описание |
+| --- | --- | --- |
+| `-listen` | `0.0.0.0:56000` | адрес прослушивания |
+| `-connect` | обязательный | backend-адрес, например `127.0.0.1:51820` или `127.0.0.1:443` |
+| `-vless` | `false` | TCP/VLESS режим |
+| `-vless-bond` | `false` | bonding для VLESS |
+| `-wrap` | `false` | включить WRAP-обфускацию |
+| `-wrap-key` | пусто | 32-байтный ключ в hex, 64 символа |
+| `-gen-wrap-key` | `false` | напечатать новый WRAP-ключ и выйти |
+| `-debug` | `false` | подробные логи |
+
+## Captcha
+
+Для VK Calls клиент умеет автоматически проходить captcha. Если автоматика не сработала, включается ручной сценарий через локальный браузер. Можно сразу запросить ручной режим:
```bash
-./client -listen 127.0.0.1:9000 -peer :56000 -vk-link
+./client -manual-captcha -listen 127.0.0.1:9000 -peer :56000 -vk-link ""
```
+Профиль браузера сохраняется в `vk_profile.json` рядом с бинарником и может помочь последующим запросам выглядеть последовательнее.
-#### Windows
+## Сборка Из Исходников
-В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280
+Нужен Go 1.25.x.
-В PowerShell от Администратора (чтобы скрипт прописывал маршруты):
-
-```
-./client.exe -peer :56000 -vk-link -listen 127.0.0.1:9000 | routes.ps1
+```bash
+go build -o client ./client
+go build -o server ./server
+go test ./...
```
-```
-./client.exe -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | routes.ps1
-```
+Кросс-сборка примера для Linux amd64:
-Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn)
-
-### Если не работает
-
-С помощью опции `-turn` можно указать адрес TURN сервера вручную. Это должен быть сервер ВК, Макса или Одноклассников (ссылка вк) или Яндекса (ссылка яндекса). Возможно потом составлю список.
-
-Если не работает TCP, попробуйте добавить флаг `-udp`.
-
-Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 МБит/с для ВК)
-
-Для прохождения капчи вручную - `-manual-captcha`.
-
-По умолчанию капча теперь проходит так: обычная автопопытка, затем автопопытка через пазл-слайдер POC, и только потом ручной режим.
-
-## Яндекс телемост
-
-**UPD. ТЕЛЕМОСТ ЗАКРЫЛИ**
-
-В отличие от ВК, сервера яндекса не ограничивают скорость, так что по умолчанию стоит `-n 1`. Увеличение этого числа может привести к временной блокировке по IP из-за переполнения конференции фейковыми участниками.
-
-В режиме `-udp` скорость обычно больше
-
-Большинство диапазонов IP TURN серверов Яндекса не работают, указывайте вручную через `-turn`
-
-
-
- Рабочие IP
-
-
- 5.255.211.241
- 5.255.211.242
- 5.255.211.243
- 5.255.211.245
- 5.255.211.246
-
-
-Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :)
-
-## v2ray
-
-Вместо WireGuard можно использовать любое V2Ray-ядро, которое его поддерживает (например, xray или sing-box) и любой V2Ray-клиент, который использует это ядро (например, v2rayN или v2rayNG). С помощью их вы сможете добавить больше входящих интерфейсов (например, SOCKS) и реализовать точечный роутинг.
-
-Пример конфигов:
-
-
-
-
-Клиент
-
-
-```json
-{
- "inbounds": [
- {
- "protocol": "socks",
- "listen": "127.0.0.1",
- "port": 1080,
- "settings": {
- "udp": true
- },
- "sniffing": {
- "enabled": true,
- "destOverride": ["http", "tls"]
- }
- },
- {
- "protocol": "http",
- "listen": "127.0.0.1",
- "port": 8080,
- "sniffing": {
- "enabled": true,
- "destOverride": ["http", "tls"]
- }
- }
- ],
- "outbounds": [
- {
- "protocol": "wireguard",
- "settings": {
- "secretKey": "",
- "peers": [
- {
- "endpoint": "127.0.0.1:9000",
- "publicKey": ""
- }
- ],
- "domainStrategy": "ForceIPv4",
- "mtu": 1280
- }
- }
- ]
-}
+```bash
+GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./server
+GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-linux-amd64 ./client
```
-
-
-
-
-
-Сервер
-
-
-```json
-{
- "inbounds": [
- {
- "protocol": "wireguard",
- "listen": "0.0.0.0",
- "port": 51820,
- "settings": {
- "secretKey": "",
- "peers": [
- {
- "publicKey": ""
- }
- ],
- "mtu": 1280
- },
- "sniffing": {
- "enabled": true,
- "destOverride": ["http", "tls"]
- }
- }
- ],
- "outbounds": [
- {
- "protocol": "freedom",
- "settings": {
- "domainStrategy": "UseIPv4"
- }
- }
- ]
-}
-```
+## Решение Проблем
-
+- Сначала запускайте VK TURN Proxy client, потом включайте WireGuard.
+- Если WireGuard забирает весь трафик, добавьте маршрут до IP TURN-реле через `routes.sh`, `routes.ps1` или `routes-macos.sh`.
+- Если TCP до TURN не работает, попробуйте `-udp`.
+- Если соединение нестабильное, попробуйте уменьшить `-n`, например `-n 1`.
+- Если VK просит captcha слишком часто, попробуйте `-manual-captcha`, затем повторите обычный запуск.
+- Если клиент зависает на получении TURN-данных, проверьте, что ссылка VK Calls живая и не была завершена для всех.
+- Если сервер запущен в Docker bridge mode, `CONNECT_ADDR=127.0.0.1:51820` укажет внутрь контейнера, а не на хост. Используйте host network или IP хоста.
+- Если включен `-wrap`, убедитесь, что и клиент, и сервер используют одинаковый `-wrap-key`.
-## VLESS-режим
+## Похожие Проекты
-Можно использовать VLESS через флаг `-vless`. В этом режиме вместо UDP-пакетов пробрасываются TCP-соединения через TURN-туннель с помощью KCP и smux.
+Авторы этого репозитория не отвечают за работу сторонних проектов.
-### Настройка
-1. На VPS установить Xray с VLESS inbound
-2. Запустить `server` с флагом `-vless`
-3. На клиенте запустить `client` с флагом `-vless`
-4. Настроить Xray/v2rayN клиент с VLESS outbound на `127.0.0.1:9000`
+Server:
-### Сервер (VPS)
-```
-./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless
-```
+- https://github.com/Urtyom-Alyanov/turn-proxy - реализация на Rust.
+- https://github.com/jaykaiperson/lionheart - похожий подход для `stream.wb.ru`.
+- https://github.com/kulikov0/whitelist-bypass - проброс через медиасерверы.
+- https://github.com/NedgNDG/vk-proxy-auto-installer - автоустановщик VK TURN Proxy.
+- https://github.com/defin85/vk-turn-proxy-go
-#### Docker
-```
-docker run -p 56000:56000/udp -e CONNECT_ADDR=127.0.0.1:443 -e VLESS_MODE=true vk-turn-proxy
-```
+Android:
-### Клиент
-```
-./client -peer :56000 -vk-link -listen 127.0.0.1:9000 -vless
-```
+- https://github.com/samosvalishe/turn-proxy-android
+- https://github.com/MYSOREZ/vk-turn-proxy-android
+- https://github.com/kiper292/wireguard-turn-android
+- https://github.com/WINGS-N/WINGSV
+- https://github.com/oxsidee/vkpn
+- https://github.com/amurcanov/proxy-turn-vk-android
-
-
-
-Xray клиент (config.json)
-
-
-```json
-{
- "inbounds": [
- {
- "protocol": "socks",
- "listen": "127.0.0.1",
- "port": 1080,
- "settings": {
- "udp": true
- },
- "sniffing": {
- "enabled": true,
- "destOverride": ["http", "tls"]
- }
- }
- ],
- "outbounds": [
- {
- "protocol": "vless",
- "settings": {
- "vnext": [
- {
- "address": "127.0.0.1",
- "port": 9000,
- "users": [
- {
- "id": "",
- "encryption": "none"
- }
- ]
- }
- ]
- },
- "streamSettings": {
- "network": "tcp",
- "security": "none"
- }
- }
- ]
-}
-```
+iOS:
-
-
-
-
-
-Xray сервер (config.json)
-
-
-```json
-{
- "inbounds": [
- {
- "protocol": "vless",
- "listen": "127.0.0.1",
- "port": 443,
- "settings": {
- "clients": [
- {
- "id": "<тот же UUID>",
- "level": 0
- }
- ],
- "decryption": "none"
- }
- }
- ],
- "outbounds": [
- {
- "protocol": "freedom",
- "settings": {
- "domainStrategy": "UseIPv4"
- }
- }
- ]
-}
-```
+- https://github.com/nullcstring/turnbridge
+- https://github.com/iamdiviem/turnbridge
+- https://github.com/anton48/vk-turn-proxy-ios
-
+macOS:
+- https://github.com/denny4-user/vk-turn-proxy-macos-gui
-## Direct mode
+## Лицензия
-С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от вк/яндекса.
+GPL-3.0. См. [LICENSE](LICENSE).
+
+
+
+
+
+
+
diff --git a/client/main.go b/client/main.go
index 73dfd17..bd2b935 100644
--- a/client/main.go
+++ b/client/main.go
@@ -1,2328 +1,7 @@
-// SPDX-FileCopyrightText: 2023 The Pion community
-// SPDX-License-Identifier: MIT
-
package main
-import (
- "bytes"
- "context"
- "crypto/md5"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "flag"
- "fmt"
- "io"
- "log"
- "math/rand"
- "net"
- "net/http"
- neturl "net/url"
- "os"
- "os/signal"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "syscall"
- "time"
-
- fhttp "github.com/bogdanfinn/fhttp"
- tlsclient "github.com/bogdanfinn/tls-client"
- "github.com/bogdanfinn/tls-client/profiles"
-
- "github.com/bschaatsbergen/dnsdialer"
- "github.com/cacggghp/vk-turn-proxy/tcputil"
- "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/transport/v4"
- "github.com/pion/turn/v5"
- "github.com/xtaci/smux"
-)
-
-type getCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error)
-
-type directNet struct{}
-
-type directDialer struct {
- *net.Dialer
-}
-
-type directListenConfig struct {
- *net.ListenConfig
-}
-
-// Global state trackers
-var (
- activeLocalPeer atomic.Value
- globalCaptchaLockout atomic.Int64
- connectedStreams atomic.Int32
- globalAppCancel context.CancelFunc
- handshakeSem = make(chan struct{}, 3)
- isDebug bool
- manualCaptcha bool
- autoCaptchaSliderPOC bool
-)
-
-type captchaSolveMode int
-
-const (
- captchaSolveModeAuto captchaSolveMode = iota
- captchaSolveModeSliderPOC
- captchaSolveModeManual
-)
-
-func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
- if manualOnly {
- return captchaSolveModeManual, attempt == 0
- }
-
- switch attempt {
- case 0:
- return captchaSolveModeAuto, true
- case 1:
- if enableSliderPOC {
- return captchaSolveModeSliderPOC, true
- }
- return captchaSolveModeManual, true
- case 2:
- if enableSliderPOC {
- return captchaSolveModeManual, true
- }
- }
-
- return 0, false
-}
-
-func captchaSolveModeLabel(mode captchaSolveMode) string {
- switch mode {
- case captchaSolveModeAuto:
- return "auto captcha"
- case captchaSolveModeSliderPOC:
- return "auto captcha slider POC"
- case captchaSolveModeManual:
- return "manual captcha"
- default:
- return "captcha"
- }
-}
-
-type UDPPacket struct {
- Data []byte
- N int
-}
-
-var packetPool = sync.Pool{
- New: func() any { return &UDPPacket{Data: make([]byte, 2048)} },
-}
-
-func newDirectNet() transport.Net {
- return directNet{}
-}
-
-func (directNet) ListenPacket(network string, address string) (net.PacketConn, error) {
- return net.ListenPacket(network, address)
-}
-
-func (directNet) ListenUDP(network string, locAddr *net.UDPAddr) (transport.UDPConn, error) {
- return net.ListenUDP(network, locAddr)
-}
-
-func (directNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
- listener, err := net.ListenTCP(network, laddr)
- if err != nil {
- return nil, err
- }
-
- return directTCPListener{listener}, nil
-}
-
-func (directNet) Dial(network, address string) (net.Conn, error) {
- return net.Dial(network, address)
-}
-
-func (directNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) {
- return net.DialUDP(network, laddr, raddr)
-}
-
-func (directNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) {
- return net.DialTCP(network, laddr, raddr)
-}
-
-func (directNet) ResolveIPAddr(network, address string) (*net.IPAddr, error) {
- return net.ResolveIPAddr(network, address)
-}
-
-func (directNet) ResolveUDPAddr(network, address string) (*net.UDPAddr, error) {
- return net.ResolveUDPAddr(network, address)
-}
-
-func (directNet) ResolveTCPAddr(network, address string) (*net.TCPAddr, error) {
- return net.ResolveTCPAddr(network, address)
-}
-
-func (directNet) Interfaces() ([]*transport.Interface, error) {
- return nil, transport.ErrNotSupported
-}
-
-func (directNet) InterfaceByIndex(index int) (*transport.Interface, error) {
- return nil, fmt.Errorf("%w: index=%d", transport.ErrInterfaceNotFound, index)
-}
-
-func (directNet) InterfaceByName(name string) (*transport.Interface, error) {
- return nil, fmt.Errorf("%w: %s", transport.ErrInterfaceNotFound, name)
-}
-
-func (directNet) CreateDialer(dialer *net.Dialer) transport.Dialer {
- return directDialer{Dialer: dialer}
-}
-
-func (directNet) CreateListenConfig(listenerConfig *net.ListenConfig) transport.ListenConfig {
- return directListenConfig{ListenConfig: listenerConfig}
-}
-
-func (d directDialer) Dial(network, address string) (net.Conn, error) {
- return d.Dialer.Dial(network, address)
-}
-
-func (d directListenConfig) Listen(ctx context.Context, network, address string) (net.Listener, error) {
- return d.ListenConfig.Listen(ctx, network, address)
-}
-
-func (d directListenConfig) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) {
- return d.ListenConfig.ListenPacket(ctx, network, address)
-}
-
-type directTCPListener struct {
- *net.TCPListener
-}
-
-func (l directTCPListener) AcceptTCP() (transport.TCPConn, error) {
- return l.TCPListener.AcceptTCP()
-}
-
-// region Helper: HTTP Headers Injection
-
-// applyBrowserProfile applies consistent User-Agent and Client Hints to bypass WAFs
-func applyBrowserProfile(req *http.Request, profile Profile) {
- req.Header.Set("User-Agent", profile.UserAgent)
- req.Header.Set("sec-ch-ua", profile.SecChUa)
- req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
- req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
- req.Header.Set("Accept-Language", "en-US,en;q=0.9")
- req.Header.Set("DNT", "1")
-}
-
-func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
- req.Header.Set("User-Agent", profile.UserAgent)
- req.Header.Set("sec-ch-ua", profile.SecChUa)
- req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
- req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
- req.Header.Set("Accept-Language", "en-US,en;q=0.9")
- req.Header.Set("DNT", "1")
-}
-
-func generateBrowserFp(profile Profile) string {
- data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10)
- h := md5.Sum([]byte(data))
- return hex.EncodeToString(h[:])
-}
-
-func generateFakeCursor() string {
- startX := 600 + rand.Intn(400)
- startY := 300 + rand.Intn(200)
- startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000)
- var points []string
- for i := 0; i < 15+rand.Intn(10); i++ {
- startX += rand.Intn(15) - 5
- startY += rand.Intn(15) + 2
- startTime += int64(rand.Intn(40) + 10)
- points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime))
- }
- return "[" + strings.Join(points, ",") + "]"
-}
-
-func getCustomNetDialer() net.Dialer {
- return net.Dialer{
- Timeout: 20 * time.Second,
- KeepAlive: 30 * time.Second,
- Resolver: &net.Resolver{
- PreferGo: true,
- Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
- var d net.Dialer
- dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
- var lastErr error
- for _, dns := range dnsServers {
- conn, err := d.DialContext(ctx, "udp", dns)
- if err == nil {
- return conn, nil
- }
- lastErr = err
- }
- return nil, lastErr
- },
- },
- }
-}
-
-// endregion
-
-// region Automatic Captcha Solver & Authentication
-
-type VkCaptchaError struct {
- ErrorCode int
- ErrorMsg string
- CaptchaSid string
- CaptchaImg string
- RedirectURI string
- IsSoundCaptchaAvailable bool
- SessionToken string
- CaptchaTs string
- CaptchaAttempt string
-}
-
-func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
- // Extract error_code
- codeFloat, ok := errData["error_code"].(float64)
- if !ok {
- log.Printf("missing error_code in captcha error data")
- return nil
- }
- code := int(codeFloat)
-
- // Extract redirect_uri
- RedirectURI, ok := errData["redirect_uri"].(string)
- if !ok {
- log.Printf("missing redirect_uri in captcha error data")
- return nil
- }
-
- // Extract captcha_sid
- captchaSid, ok := errData["captcha_sid"].(string)
- if !ok {
- // try numeric
- if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
- captchaSid = fmt.Sprintf("%.0f", sidNum)
- } else {
- log.Printf("missing captcha_sid in captcha error data")
- return nil
- }
- }
-
- // Extract captcha_img
- captchaImg, ok := errData["captcha_img"].(string)
- if !ok {
- log.Printf("missing captcha_img in captcha error data")
- return nil
- }
-
- // Extract error_msg
- errorMsg, ok := errData["error_msg"].(string)
- if !ok {
- log.Printf("missing error_msg in captcha error data")
- return nil
- }
-
- // Extract session token if redirect_uri present
- var sessionToken string
- if RedirectURI != "" {
- if parsed, err := neturl.Parse(RedirectURI); err == nil {
- sessionToken = parsed.Query().Get("session_token")
- } else {
- log.Printf("failed to parse redirect_uri: %v", err)
- return nil
- }
- }
-
- // Extract is_sound_captcha_available
- isSound, ok := errData["is_sound_captcha_available"].(bool)
- if !ok {
- isSound = false
- }
-
- // Extract captcha_ts
- var captchaTs string
- if tsFloat, ok := errData["captcha_ts"].(float64); ok {
- captchaTs = fmt.Sprintf("%.0f", tsFloat)
- } else if tsStr, ok := errData["captcha_ts"].(string); ok {
- captchaTs = tsStr
- }
-
- // Extract captcha_attempt
- var captchaAttempt string
- if attFloat, ok := errData["captcha_attempt"].(float64); ok {
- captchaAttempt = fmt.Sprintf("%.0f", attFloat)
- } else if attStr, ok := errData["captcha_attempt"].(string); ok {
- captchaAttempt = attStr
- }
-
- // Build VkCaptchaError
- return &VkCaptchaError{
- ErrorCode: code,
- ErrorMsg: errorMsg,
- CaptchaSid: captchaSid,
- CaptchaImg: captchaImg,
- RedirectURI: RedirectURI,
- IsSoundCaptchaAvailable: isSound,
- SessionToken: sessionToken,
- CaptchaTs: captchaTs,
- CaptchaAttempt: captchaAttempt,
- }
-}
-
-func (e *VkCaptchaError) IsCaptchaError() bool {
- return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != ""
-}
-
-func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) {
- if useSliderPOC {
- log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID)
- } else {
- log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID)
- }
-
- if captchaErr.SessionToken == "" {
- return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
- }
- if captchaErr.RedirectURI == "" {
- return "", fmt.Errorf("no redirect_uri for auto-solve")
- }
-
- bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
- if err != nil {
- return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
- }
-
- log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
-
- hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
- log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash)
-
- var successToken string
- if useSliderPOC {
- successToken, err = callCaptchaNotRobotWithSliderPOC(
- ctx,
- captchaErr.SessionToken,
- hash,
- streamID,
- client,
- profile,
- bootstrap.Settings,
- )
- } else {
- successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile)
- }
- if err != nil {
- return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
- }
-
- log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID)
- return successToken, nil
-}
-
-func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) {
- parsedURL, err := neturl.Parse(redirectURI)
- if err != nil {
- return nil, err
- }
- domain := parsedURL.Hostname()
-
- req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil)
- if err != nil {
- return nil, err
- }
-
- req.Host = domain
- applyBrowserProfileFhttp(req, profile)
- req.Header.Set("Sec-Fetch-Site", "none")
- req.Header.Set("Sec-Fetch-Mode", "navigate")
- req.Header.Set("Sec-Fetch-Dest", "document")
- req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer func(Body io.ReadCloser) {
- _ = Body.Close()
- }(resp.Body)
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
- return parseCaptchaBootstrapHTML(string(body))
-}
-
-func solvePoW(powInput string, difficulty int) string {
- target := strings.Repeat("0", difficulty)
- for nonce := 1; nonce <= 10000000; nonce++ {
- data := powInput + strconv.Itoa(nonce)
- hash := sha256.Sum256([]byte(data))
- hexHash := hex.EncodeToString(hash[:])
- if strings.HasPrefix(hexHash, target) {
- return hexHash
- }
- }
- return ""
-}
-
-func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) {
- vkReq := func(method string, postData string) (map[string]interface{}, error) {
- reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
- parsedURL, err := neturl.Parse(reqURL)
- if err != nil {
- return nil, fmt.Errorf("parse request URL: %w", err)
- }
- domain := parsedURL.Hostname()
-
- req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData))
- if err != nil {
- return nil, err
- }
-
- req.Host = domain
- applyBrowserProfileFhttp(req, profile)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Accept", "*/*")
- req.Header.Set("Origin", "https://id.vk.ru")
- req.Header.Set("Referer", "https://id.vk.ru/")
- req.Header.Set("Sec-Fetch-Site", "same-site")
- req.Header.Set("Sec-Fetch-Mode", "cors")
- req.Header.Set("Sec-Fetch-Dest", "empty")
- req.Header.Set("Sec-GPC", "1")
- req.Header.Set("Priority", "u=1, i")
-
- httpResp, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer func(Body io.ReadCloser) {
- _ = Body.Close()
- }(httpResp.Body)
-
- 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
- }
-
- baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken))
-
- log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
- if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
- return "", fmt.Errorf("settings failed: %w", err)
- }
-
- time.Sleep(200 * time.Millisecond)
-
- log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
- browserFp := generateBrowserFp(profile)
- deviceJSON := buildCaptchaDeviceJSON(profile)
- componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
-
- if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
- return "", fmt.Errorf("componentDone failed: %w", err)
- }
-
- time.Sleep(200 * time.Millisecond)
-
- log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
- cursorJSON := generateFakeCursor()
- answer := base64.StdEncoding.EncodeToString([]byte("{}"))
-
- // Dynamically generate debug_info to avoid static fingerprint bans
- debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10)))
- debugInfo := hex.EncodeToString(debugInfoBytes[:])
-
- connectionRtt := "[50,50,50,50,50,50,50,50,50,50]"
- connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]"
-
- checkData := baseParams + fmt.Sprintf(
- "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s",
- neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), neturl.QueryEscape("[]"),
- neturl.QueryEscape(cursorJSON), neturl.QueryEscape("[]"), neturl.QueryEscape(connectionRtt),
- neturl.QueryEscape(connectionDownlink),
- browserFp, hash, answer, debugInfo,
- )
-
- checkResp, err := vkReq("captchaNotRobot.check", checkData)
- if err != nil {
- return "", fmt.Errorf("check failed: %w", err)
- }
-
- respObj, ok := checkResp["response"].(map[string]interface{})
- if !ok {
- return "", fmt.Errorf("invalid check response: %v", checkResp)
- }
- status, ok := respObj["status"].(string)
- if !ok || status != "OK" {
- return "", fmt.Errorf("check status: %s", status)
- }
- successToken, ok := respObj["success_token"].(string)
- if !ok || successToken == "" {
- return "", fmt.Errorf("success_token not found")
- }
-
- time.Sleep(200 * time.Millisecond)
-
- log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", streamID)
- _, err = vkReq("captchaNotRobot.endSession", baseParams)
- if err != nil {
- log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", streamID, err)
- }
-
- return successToken, nil
-}
-
-// endregion
-
-// region VK Credentials Layer
-
-type VKCredentials struct {
- ClientID string
- ClientSecret string
-}
-
-var vkCredentialsList = []VKCredentials{
- {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
- {ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
- {ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID
- {ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID
- {ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP
-}
-
-type TurnCredentials struct {
- Username string
- Password string
- ServerAddr string
- ExpiresAt time.Time
- Link string
-}
-
-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 = 10
-)
-
-func getCacheID(streamID int) int {
- return streamID / streamsPerCache
-}
-
-func vkDelayRandom(minMs, maxMs int) {
- ms := minMs + rand.Intn(maxMs-minMs+1)
- time.Sleep(time.Duration(ms) * time.Millisecond)
-}
-
-var credentialsStore = struct {
- mu sync.RWMutex
- caches map[int]*StreamCredentialsCache
-}{
- caches: make(map[int]*StreamCredentialsCache),
-}
-
-func getStreamCache(streamID int) *StreamCredentialsCache {
- cacheID := getCacheID(streamID)
-
- credentialsStore.mu.RLock()
- cache, exists := credentialsStore.caches[cacheID]
- credentialsStore.mu.RUnlock()
-
- if exists {
- return cache
- }
-
- credentialsStore.mu.Lock()
- defer credentialsStore.mu.Unlock()
-
- if cache, exists = credentialsStore.caches[cacheID]; exists {
- return cache
- }
-
- cache = &StreamCredentialsCache{}
- credentialsStore.caches[cacheID] = cache
- return cache
-}
-
-func isAuthError(err error) bool {
- if err == nil {
- return false
- }
- errStr := err.Error()
- return strings.Contains(errStr, "401") ||
- strings.Contains(errStr, "Unauthorized") ||
- strings.Contains(errStr, "authentication") ||
- strings.Contains(errStr, "invalid credential") ||
- strings.Contains(errStr, "stale nonce")
-}
-
-func handleAuthError(streamID int) bool {
- cache := getStreamCache(streamID)
- cacheID := getCacheID(streamID)
-
- now := time.Now().Unix()
-
- if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) {
- cache.errorCount.Store(0)
- }
-
- count := cache.errorCount.Add(1)
- cache.lastErrorTime.Store(now)
-
- log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors)
-
- if count >= maxCacheErrors {
- log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID)
- cache.invalidate(streamID)
- return true
- }
- return false
-}
-
-func (c *StreamCredentialsCache) invalidate(streamID int) {
- c.mutex.Lock()
- c.creds = TurnCredentials{}
- c.mutex.Unlock()
-
- c.errorCount.Store(0)
- c.lastErrorTime.Store(0)
-
- log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID)
-}
-
-func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
- cache := getStreamCache(streamID)
- cacheID := getCacheID(streamID)
-
- cache.mutex.RLock()
- if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) {
- expires := time.Until(cache.creds.ExpiresAt)
- u, p, a := cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr
- cache.mutex.RUnlock()
- if isDebug {
- log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v)", streamID, cacheID, expires)
- }
- return u, p, a, nil
- }
- cache.mutex.RUnlock()
-
- cache.mutex.Lock()
- defer cache.mutex.Unlock()
-
- // Double-check inside lock
- if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) {
- return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil
- }
-
- user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer)
- if err != nil {
- return "", "", "", err
- }
-
- cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddr: addr, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link}
- return user, pass, addr, nil
-}
-
-var (
- vkRequestMu sync.Mutex
- globalLastVkFetchTime time.Time
-)
-
-func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
- vkRequestMu.Lock()
- defer vkRequestMu.Unlock()
-
- // Ensure a minimum cooldown between credential requests to avoid VK rate limits
- minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond
- elapsed := time.Since(globalLastVkFetchTime)
-
- if !globalLastVkFetchTime.IsZero() && elapsed < minInterval {
- wait := minInterval - elapsed
- log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond))
- select {
- case <-ctx.Done():
- return "", "", "", ctx.Err()
- case <-time.After(wait):
- }
- }
-
- defer func() {
- globalLastVkFetchTime = time.Now()
- }()
-
- return fetchVkCreds(ctx, link, streamID, dialer)
-}
-
-func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
- // Check Global Lockout to prevent API bans
- if time.Now().Unix() < globalCaptchaLockout.Load() {
- return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
- }
-
- var lastErr error
- jar := tlsclient.NewCookieJar()
-
- for _, creds := range vkCredentialsList {
- log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
-
- user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, dialer, jar)
-
- if err == nil {
- log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
- return user, pass, addr, nil
- }
-
- lastErr = err
- log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
-
- // Hard abort on captcha/fatal conditions instead of trying next creds
- if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") {
- return "", "", "", err
- }
-
- if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") {
- log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID)
- }
- }
-
- return "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr)
-}
-
-func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, error) {
- profile := Profile{
- UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
- SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`,
- SecChUaMobile: "?0",
- SecChUaPlatform: `"Windows"`,
- }
-
- client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
- tlsclient.WithTimeoutSeconds(20),
- tlsclient.WithClientProfile(profiles.Chrome_146),
- tlsclient.WithCookieJar(jar),
- tlsclient.WithDialer(getCustomNetDialer()),
- )
- if err != nil {
- return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err)
- }
-
- name := generateName()
- escapedName := neturl.QueryEscape(name)
-
- log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
-
- doRequest := func(data string, url string) (resp map[string]interface{}, err error) {
- parsedURL, err := neturl.Parse(url)
- if err != nil {
- return nil, fmt.Errorf("parse request URL: %w", err)
- }
- domain := parsedURL.Hostname()
-
- req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
- if err != nil {
- return nil, err
- }
-
- req.Host = domain
- applyBrowserProfileFhttp(req, profile)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Accept", "*/*")
- req.Header.Set("Origin", "https://vk.ru")
- req.Header.Set("Referer", "https://vk.ru/")
- req.Header.Set("Sec-Fetch-Site", "same-site")
- req.Header.Set("Sec-Fetch-Mode", "cors")
- req.Header.Set("Sec-Fetch-Dest", "empty")
- req.Header.Set("Priority", "u=1, i")
-
- httpResp, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer func() {
- if closeErr := httpResp.Body.Close(); closeErr != nil {
- log.Printf("close response body: %s", closeErr)
- }
- }()
-
- body, err := io.ReadAll(httpResp.Body)
- if err != nil {
- return nil, err
- }
-
- err = json.Unmarshal(body, &resp)
- if err != nil {
- return nil, err
- }
- return resp, nil
- }
-
- // Token 1
- data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID)
- resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token")
- if err != nil {
- return "", "", "", err
- }
- dataMap, ok := resp["data"].(map[string]interface{})
- if !ok {
- return "", "", "", fmt.Errorf("unexpected anon token response: %v", resp)
- }
- token1, ok := dataMap["access_token"].(string)
- if !ok {
- return "", "", "", fmt.Errorf("missing access_token in response: %v", resp)
- }
-
- vkDelayRandom(100, 150)
-
- // Token 1 -> getCallPreview
- data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1)
- _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID)
- if err != nil {
- log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err)
- }
-
- vkDelayRandom(200, 400)
-
- // Token 2
- data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1)
- urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID)
-
- var token2 string
- for attempt := 0; ; attempt++ {
- resp, err = doRequest(data, urlAddr)
- if err != nil {
- return "", "", "", err
- }
-
- if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr {
- captchaErr := ParseVkCaptchaError(errObj)
- if captchaErr != nil && captchaErr.IsCaptchaError() {
- solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC)
- if !hasSolveMode {
- log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1)
-
- // Engage global lockout to protect API
- globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
-
- if connectedStreams.Load() == 0 {
- log.Printf("[STREAM %d] [FATAL] 0 connected streams and captcha solve modes exhausted.", streamID)
- return "", "", "", fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS")
- }
-
- return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
- }
-
- var successToken string
- var captchaKey string
- var solveErr error
-
- switch solveMode {
- case captchaSolveModeAuto:
- if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
- successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
- if solveErr != nil {
- log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr)
- }
- } else {
- solveErr = fmt.Errorf("missing fields for auto solve")
- }
- case captchaSolveModeSliderPOC:
- if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
- successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true)
- if solveErr != nil {
- log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr)
- }
- } else {
- solveErr = fmt.Errorf("missing fields for slider POC auto solve")
- }
- case captchaSolveModeManual:
- log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
- manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second)
-
- type manualRes struct {
- token string
- key string
- err error
- }
- resCh := make(chan manualRes, 1)
-
- go func() {
- var t, k string
- var e error
- if captchaErr.RedirectURI != "" {
- t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer)
- } else if captchaErr.CaptchaImg != "" {
- k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg)
- } else {
- e = fmt.Errorf("no redirect_uri or captcha_img")
- }
- resCh <- manualRes{t, k, e}
- }()
-
- select {
- case res := <-resCh:
- successToken = res.token
- captchaKey = res.key
- solveErr = res.err
- case <-manualCtx.Done():
- solveErr = fmt.Errorf("manual captcha timed out after 60s")
- }
- manualCancel()
- }
-
- // If solving failed (auto or manual) or timed out
- if solveErr != nil {
- log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr)
-
- nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha, autoCaptchaSliderPOC)
- if hasNextSolveMode {
- log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode))
- continue
- }
-
- // Engage global lockout to protect API
- globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
-
- // If we have 0 streams alive, this is fatal
- if connectedStreams.Load() == 0 {
- log.Printf("[STREAM %d] [FATAL] 0 connected streams and manual captcha failed/timed out.", streamID)
- return "", "", "", fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS")
- }
-
- return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
- }
-
- if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" {
- captchaErr.CaptchaAttempt = "1"
- }
-
- if captchaKey != "" {
- data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
- link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1)
- } else {
- data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s",
- link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1)
- }
- continue
- }
- return "", "", "", fmt.Errorf("VK API error: %v", errObj)
- }
-
- respMap, okLoop := resp["response"].(map[string]interface{})
- if !okLoop {
- return "", "", "", fmt.Errorf("unexpected getAnonymousToken response: %v", resp)
- }
- token2, okLoop = respMap["token"].(string)
- if !okLoop {
- return "", "", "", fmt.Errorf("missing token in response: %v", resp)
- }
- break
- }
-
- vkDelayRandom(100, 150)
-
- // Token 3
- sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
- data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData))
- resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
- if err != nil {
- return "", "", "", err
- }
- token3, ok := resp["session_key"].(string)
- if !ok {
- return "", "", "", fmt.Errorf("missing session_key in response: %v", resp)
- }
-
- vkDelayRandom(100, 150)
-
- // Token 4 -> TURN Creds
- data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3)
- resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
- if err != nil {
- return "", "", "", err
- }
-
- tsRaw, ok := resp["turn_server"].(map[string]interface{})
- if !ok {
- return "", "", "", fmt.Errorf("missing turn_server in response: %v", resp)
- }
- user, ok := tsRaw["username"].(string)
- if !ok {
- return "", "", "", fmt.Errorf("missing username in turn_server")
- }
- pass, ok := tsRaw["credential"].(string)
- if !ok {
- return "", "", "", fmt.Errorf("missing credential in turn_server")
- }
- urlsRaw, ok := tsRaw["urls"].([]interface{})
- if !ok || len(urlsRaw) == 0 {
- return "", "", "", fmt.Errorf("missing or empty urls in turn_server")
- }
- urlStr, ok := urlsRaw[0].(string)
- if !ok {
- return "", "", "", fmt.Errorf("turn server url is not a string")
- }
-
- clean := strings.Split(urlStr, "?")[0]
- address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
-
- return user, pass, address, nil
-}
-
-// endregion
-
-func getYandexCreds(link string) (string, string, string, error) {
- 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")
-
- profile := getRandomProfile()
- name := generateName()
-
- 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
- tr := &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 100,
- IdleConnTimeout: 90 * time.Second,
- }
- client := &http.Client{
- Timeout: 20 * time.Second,
- Transport: tr,
- }
- defer client.CloseIdleConnections()
- req, err := http.NewRequest("GET", endpoint, nil)
- if err != nil {
- return "", "", "", err
- }
-
- applyBrowserProfile(req, profile)
- 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 func() {
- if closeErr := resp.Body.Close(); closeErr != nil {
- log.Printf("close response body: %s", closeErr)
- }
- }()
- if resp.StatusCode != http.StatusOK {
- readBody, err2 := io.ReadAll(resp.Body)
- if err2 != nil {
- return "", "", "", fmt.Errorf("GetConference: status=%s (failed to read body: %v)", resp.Status, err2)
- }
- return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(readBody))
- }
-
- 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", profile.UserAgent)
-
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
-
- dialer := websocket.Dialer{}
- var conn *websocket.Conn
- conn, resp, err = dialer.DialContext(ctx, data.Wss, h)
- if err != nil {
- if resp != nil && resp.Body != nil {
- _ = resp.Body.Close()
- }
- return "", "", "", fmt.Errorf("ws dial: %w", err)
- }
- if resp != nil && resp.Body != nil {
- defer func() { _ = resp.Body.Close() }()
- }
- defer func() {
- if closeErr := conn.Close(); closeErr != nil {
- log.Printf("close websocket: %s", closeErr)
- }
- }()
-
- req1 := HelloRequest{
- UID: uuid.New().String(),
- Hello: HelloPayload{
- ParticipantMeta: PartMeta{
- Name: name,
- Role: "SPEAKER",
- Description: "",
- SendAudio: false,
- SendVideo: false,
- },
- ParticipantAttributes: PartAttrs{
- Name: 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: profile.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 isDebug {
- 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)
- }
-
- if err := conn.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil {
- return "", "", "", fmt.Errorf("ws set read deadline: %w", err)
- }
-
- for {
- _, msg, err := conn.ReadMessage()
- if err != nil {
- return "", "", "", fmt.Errorf("ws read: %w", err)
- }
- if isDebug {
- 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 {
- return nil, err
- }
-
- select {
- case handshakeSem <- struct{}{}:
- defer func() { <-handshakeSem }()
- case <-ctx.Done():
- return nil, ctx.Err()
- }
-
- ctx1, cancel := context.WithTimeout(ctx, 20*time.Second)
- defer cancel()
- dtlsConn, err := dtls.ClientWithOptions(
- conn,
- peer,
- dtls.WithCertificates(certificate),
- dtls.WithInsecureSkipVerify(true),
- dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
- dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
- dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()),
- )
- if err != nil {
- return nil, err
- }
-
- if err := dtlsConn.HandshakeContext(ctx1); err != nil {
- return nil, err
- }
- return dtlsConn, nil
-}
-
-func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, inboundChan <-chan *UDPPacket, connchan chan<- net.PacketConn, okchan chan<- struct{}, streamID int) error {
- time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond)
-
- dtlsctx, dtlscancel := context.WithCancel(ctx)
- defer dtlscancel()
-
- conn1, conn2 := connutil.AsyncPacketPipe()
- go func() {
- for {
- select {
- case <-dtlsctx.Done():
- return
- case connchan <- conn2:
- }
- }
- }()
- dtlsConn, err1 := dtlsFunc(dtlsctx, conn1, peer)
- if err1 != nil {
- return fmt.Errorf("failed to connect DTLS: %s", err1)
- }
- defer func() {
- if closeErr := dtlsConn.Close(); closeErr != nil {
- log.Printf("[STREAM %d] failed to close DTLS connection: %s", streamID, closeErr)
- }
- log.Printf("[STREAM %d] Closed DTLS connection\n", streamID)
- }()
- log.Printf("[STREAM %d] Established DTLS connection!\n", streamID)
-
- if okchan != nil {
- go func() {
- select {
- case okchan <- struct{}{}:
- case <-dtlsctx.Done():
- }
- }()
- }
-
- wg := sync.WaitGroup{}
- wg.Add(1)
- context.AfterFunc(dtlsctx, func() {
- if err := dtlsConn.SetDeadline(time.Now()); err != nil {
- log.Printf("[STREAM %d] Warning: SetDeadline failed: %v", streamID, err)
- }
- })
-
- go func() {
- defer dtlscancel()
- for {
- select {
- case <-dtlsctx.Done():
- return
- case pkt := <-inboundChan:
- _, _ = dtlsConn.Write(pkt.Data[:pkt.N])
- packetPool.Put(pkt)
- }
- }
- }()
-
- go func() {
- defer wg.Done()
- defer dtlscancel()
- buf := make([]byte, 1600)
- for {
- n, err1 := dtlsConn.Read(buf)
- if err1 != nil {
- return
- }
-
- // Send back to the active WG client
- if peerAddr := activeLocalPeer.Load(); peerAddr != nil {
- if addr, ok := peerAddr.(net.Addr); ok {
- if _, err := listenConn.WriteTo(buf[:n], addr); err != nil {
- log.Printf("[STREAM %d] failed to forward packet to local peer: %v", streamID, err)
- }
- }
- }
- }
- }()
-
- wg.Wait()
- if err := dtlsConn.SetDeadline(time.Time{}); err != nil {
- log.Printf("[STREAM %d] Failed to clear DTLS deadline: %s", streamID, err)
- }
- return nil
-}
-
-type connectedUDPConn struct {
- *net.UDPConn
-}
-
-func (c *connectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) {
- return c.Write(p)
-}
-
-type turnParams struct {
- host string
- port string
- link string
- udp bool
- getCreds getCredsFunc
-}
-
-func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) {
- time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond)
- var err error
- defer func() { c <- err }()
- user, pass, urlTarget, err1 := turnParams.getCreds(ctx, turnParams.link, streamID)
- if err1 != nil {
- err = fmt.Errorf("failed to get TURN credentials: %s", err1)
- return
- }
- urlhost, urlport, err1 := net.SplitHostPort(urlTarget)
- if err1 != nil {
- err = fmt.Errorf("failed to parse TURN server address: %s", err1)
- return
- }
- if turnParams.host != "" {
- urlhost = turnParams.host
- }
- if turnParams.port != "" {
- urlport = turnParams.port
- }
- var turnServerAddr string
- turnServerAddr = net.JoinHostPort(urlhost, urlport)
- turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr)
- if err1 != nil {
- err = fmt.Errorf("failed to resolve TURN server address: %s", err1)
- return
- }
- turnServerAddr = turnServerUDPAddr.String()
- fmt.Println(turnServerUDPAddr.IP)
- var cfg *turn.ClientConfig
- var turnConn net.PacketConn
- var d net.Dialer
- ctx1, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- if turnParams.udp {
- conn, err2 := net.DialUDP("udp", nil, turnServerUDPAddr) // nolint: noctx
- if err2 != nil {
- err = fmt.Errorf("failed to connect to TURN server: %s", err2)
- return
- }
- defer func() {
- if err1 = conn.Close(); err1 != nil {
- err = fmt.Errorf("failed to close TURN server connection: %s", err1)
- return
- }
- }()
- turnConn = &connectedUDPConn{conn}
- } else {
- conn, err2 := d.DialContext(ctx1, "tcp", turnServerAddr)
- if err2 != nil {
- err = fmt.Errorf("failed to connect to TURN server: %s", err2)
- return
- }
- defer func() {
- if err1 = conn.Close(); err1 != nil {
- err = fmt.Errorf("failed to close TURN server connection: %s", err1)
- return
- }
- }()
- turnConn = turn.NewSTUNConn(conn)
- }
- var addrFamily turn.RequestedAddressFamily
- if peer.IP.To4() != nil {
- addrFamily = turn.RequestedAddressFamilyIPv4
- } else {
- addrFamily = turn.RequestedAddressFamilyIPv6
- }
-
- cfg = &turn.ClientConfig{
- STUNServerAddr: turnServerAddr,
- TURNServerAddr: turnServerAddr,
- Conn: turnConn,
- Net: newDirectNet(),
- Username: user,
- Password: pass,
- RequestedAddressFamily: addrFamily,
- LoggerFactory: logging.NewDefaultLoggerFactory(),
- }
-
- client, err1 := turn.NewClient(cfg)
- if err1 != nil {
- err = fmt.Errorf("failed to create TURN client: %s", err1)
- return
- }
- defer client.Close()
-
- err1 = client.Listen()
- if err1 != nil {
- err = fmt.Errorf("failed to listen: %s", err1)
- return
- }
-
- relayConn, err1 := client.Allocate()
- if err1 != nil {
- if isAuthError(err1) {
- handleAuthError(streamID)
- }
- err = fmt.Errorf("failed to allocate: %s", err1)
- return
- }
-
- // Reset error count on successful allocation
- getStreamCache(streamID).errorCount.Store(0)
-
- // Safely track active streams globally
- connectedStreams.Add(1)
- defer func() {
- connectedStreams.Add(-1)
- if err1 := relayConn.Close(); err1 != nil {
- err = fmt.Errorf("failed to close TURN allocated connection: %s", err1)
- }
- }()
-
- if isDebug {
- log.Printf("[STREAM %d] relayed-address=%s", streamID, relayConn.LocalAddr().String())
- }
-
- wg := sync.WaitGroup{}
- wg.Add(1)
- turnctx, turncancel := context.WithCancel(ctx)
- context.AfterFunc(turnctx, func() {
- if err := relayConn.SetDeadline(time.Now()); err != nil {
- log.Printf("Failed to set relay deadline: %s", err)
- }
- // Do not set conn2 deadline (conn2 can sometimes be listenConn if direct mode is used)
- })
- var internalPipeAddr atomic.Value
-
- go func() {
- defer turncancel()
- buf := make([]byte, 1600)
- for {
- if turnctx.Err() != nil {
- return
- }
- n, addr1, err1 := conn2.ReadFrom(buf)
- if err1 != nil {
- return
- }
- if turnctx.Err() != nil {
- return
- }
-
- internalPipeAddr.Store(addr1)
-
- _, err1 = relayConn.WriteTo(buf[:n], peer)
- if err1 != nil {
- return
- }
- }
- }()
-
- go func() {
- defer wg.Done()
- defer turncancel()
- buf := make([]byte, 1600)
- for {
- n, _, err1 := relayConn.ReadFrom(buf)
- if err1 != nil {
- return
- }
- addr1 := internalPipeAddr.Load()
- if addr1 == nil {
- continue
- }
-
- if addr, ok := addr1.(net.Addr); ok {
- if _, err := conn2.WriteTo(buf[:n], addr); err != nil {
- return
- }
- }
- }
- }()
-
- wg.Wait()
- if err := relayConn.SetDeadline(time.Time{}); err != nil {
- log.Printf("Failed to clear relay deadline: %s", err)
- }
-}
-
-func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, inboundChan <-chan *UDPPacket, connchan chan<- net.PacketConn, okchan chan<- struct{}, streamID int) {
- for {
- select {
- case <-ctx.Done():
- return
- default:
- err := oneDtlsConnection(ctx, peer, listenConn, inboundChan, connchan, okchan, streamID)
- if err != nil {
- if time.Now().Unix() < globalCaptchaLockout.Load() && strings.Contains(err.Error(), "context deadline exceeded") {
- continue
- }
- select {
- case <-ctx.Done():
- return
- case <-time.After(time.Duration(10+rand.Intn(20)) * time.Second):
- }
- }
- }
- }
-}
-
-func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) {
- for {
- select {
- case <-ctx.Done():
- return
- case conn2 := <-connchan:
- select {
- case <-t:
- case <-ctx.Done():
- return
- }
- c := make(chan error)
- go oneTurnConnection(ctx, turnParams, peer, conn2, streamID, c)
-
- if err := <-c; err != nil {
- if strings.Contains(err.Error(), "FATAL_CAPTCHA") {
- log.Printf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID)
- if globalAppCancel != nil {
- globalAppCancel()
- }
- return
- }
- if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") {
- if !strings.Contains(err.Error(), "global lockout active") {
- log.Printf("[STREAM %d] Backing off for 60 seconds to avoid IP ban...", streamID)
- select {
- case <-ctx.Done():
- return
- case <-time.After(60 * time.Second):
- }
- } else {
- lockoutEnd := globalCaptchaLockout.Load()
- sleepDuration := time.Until(time.Unix(lockoutEnd, 0))
- if sleepDuration < 0 {
- sleepDuration = 5 * time.Second
- }
- select {
- case <-ctx.Done():
- return
- case <-time.After(sleepDuration):
- }
- }
- } else {
- log.Printf("[STREAM %d] %s", streamID, err)
- time.Sleep(2 * time.Second)
- }
- }
- }
- }
-}
+import "github.com/cacggghp/vk-turn-proxy/pkg/clientcore"
func main() {
- ctx, cancel := context.WithCancel(context.Background())
- globalAppCancel = cancel
- defer cancel()
- signalChan := make(chan os.Signal, 1)
- signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
- go func() {
- <-signalChan
- log.Printf("Terminating...\n")
- cancel()
- select {
- case <-signalChan:
- case <-time.After(5 * time.Second):
- }
- log.Fatalf("Exit...\n")
- }()
-
- host := flag.String("turn", "", "override TURN server ip")
- 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/...\"")
- peerAddr := flag.String("peer", "", "peer server address (host:port)")
- n := flag.Int("n", 0, "connections to TURN (default 10 for VK, 1 for Yandex)")
- udp := flag.Bool("udp", false, "connect to TURN with UDP")
- direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE")
- vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
- debugFlag := flag.Bool("debug", false, "enable debug logging")
- manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
- flag.Parse()
- if *peerAddr == "" {
- log.Panicf("Need peer address!")
- }
- peer, err := net.ResolveUDPAddr("udp", *peerAddr)
- if err != nil {
- panic(err)
- }
- if (*vklink == "") == (*yalink == "") {
- log.Panicf("Need either vk-link or yandex-link!")
- }
-
- isDebug = *debugFlag
- manualCaptcha = *manualCaptchaFlag
- autoCaptchaSliderPOC = !manualCaptcha
-
- var link string
- var getCreds getCredsFunc
- if *vklink != "" {
- parts := strings.Split(*vklink, "join/")
- link = parts[len(parts)-1]
-
- dialer := dnsdialer.New(
- dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"),
- dnsdialer.WithStrategy(dnsdialer.Fallback{}),
- dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour),
- )
-
- getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
- return getVkCredsCached(ctx, s, streamID, dialer)
- }
- if *n <= 0 {
- *n = 10
- }
- } else {
- parts := strings.Split(*yalink, "j/")
- link = parts[len(parts)-1]
- getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
- return getYandexCreds(s)
- }
- if *n <= 0 {
- *n = 1
- }
- }
- if idx := strings.IndexAny(link, "/?#"); idx != -1 {
- link = link[:idx]
- }
-
- params := &turnParams{
- host: *host,
- port: *port,
- link: link,
- udp: *udp,
- getCreds: getCreds,
- }
-
- if *vlessMode {
- runVLESSMode(ctx, params, peer, *listen, *n)
- return
- }
-
- listenConn, err := net.ListenPacket("udp", *listen)
- if err != nil {
- log.Panicf("Failed to listen: %s", err)
- }
- context.AfterFunc(ctx, func() {
- if closeErr := listenConn.Close(); closeErr != nil {
- log.Printf("Failed to close local connection: %s", closeErr)
- }
- })
-
- numStreams := *n
- if numStreams <= 0 {
- numStreams = 1
- }
-
- // Shared Worker Pool Queue for Aggregation
- inboundChan := make(chan *UDPPacket, 2000)
-
- go func() {
- for {
- pktIface := packetPool.Get()
- pkt, ok := pktIface.(*UDPPacket)
- if !ok {
- log.Printf("packetPool returned unexpected type: %T", pktIface)
- continue
- }
- nRead, addr, err := listenConn.ReadFrom(pkt.Data)
- if err != nil {
- return
- }
-
- // Save the local WireGuard peer address
- current := activeLocalPeer.Load()
- if current == nil {
- activeLocalPeer.Store(addr)
- } else if addrStr, ok := current.(net.Addr); ok {
- if addrStr.String() != addr.String() {
- activeLocalPeer.Store(addr)
- }
- } else {
- activeLocalPeer.Store(addr)
- }
-
- pkt.N = nRead
-
- select {
- case inboundChan <- pkt:
- default:
- // Drop the packet only if the global queue is completely full
- packetPool.Put(pkt)
- }
- }
- }()
-
- wg1 := sync.WaitGroup{}
- t := time.Tick(200 * time.Millisecond)
-
- if *direct {
- log.Panicf("Direct mode not supported with dispatcher")
- }
-
- okchan := make(chan struct{})
- connchan := make(chan net.PacketConn)
- wg1.Add(1)
- go func() {
- defer wg1.Done()
- oneDtlsConnectionLoop(ctx, peer, listenConn, inboundChan, connchan, okchan, 1)
- }()
- wg1.Add(1)
- go func() {
- defer wg1.Done()
- oneTurnConnectionLoop(ctx, params, peer, connchan, t, 1)
- }()
-
- select {
- case <-okchan:
- case <-ctx.Done():
- }
-
- for i := 1; i < numStreams; i++ {
- cchan := make(chan net.PacketConn)
- wg1.Add(1)
- go func(streamID int) {
- defer wg1.Done()
- oneDtlsConnectionLoop(ctx, peer, listenConn, inboundChan, cchan, nil, streamID)
- }(i)
- wg1.Add(1)
- go func(streamID int) {
- defer wg1.Done()
- oneTurnConnectionLoop(ctx, params, peer, cchan, t, streamID)
- }(i)
- }
-
- wg1.Wait()
-}
-
-// sessionPool manages a pool of smux sessions for round-robin TCP distribution.
-type sessionPool struct {
- mu sync.RWMutex
- sessions []*smux.Session
- counter atomic.Uint64
-}
-
-func (p *sessionPool) add(s *smux.Session) {
- p.mu.Lock()
- p.sessions = append(p.sessions, s)
- p.mu.Unlock()
-}
-
-func (p *sessionPool) remove(s *smux.Session) {
- p.mu.Lock()
- for i, sess := range p.sessions {
- if sess == s {
- p.sessions = append(p.sessions[:i], p.sessions[i+1:]...)
- break
- }
- }
- p.mu.Unlock()
-}
-
-func (p *sessionPool) pick() *smux.Session {
- p.mu.RLock()
- defer p.mu.RUnlock()
- n := len(p.sessions)
- if n == 0 {
- return nil
- }
- idx := p.counter.Add(1) % uint64(n)
- return p.sessions[idx]
-}
-
-func (p *sessionPool) count() int {
- p.mu.RLock()
- defer p.mu.RUnlock()
- return len(p.sessions)
-}
-
-// runVLESSMode implements TCP forwarding with round-robin across N TURN sessions.
-func runVLESSMode(ctx context.Context, tp *turnParams, peer *net.UDPAddr, listenAddr string, numSessions int) {
- pool := &sessionPool{}
-
- // Start N session maintainers with staggered startup
- var wgMaint sync.WaitGroup
- for i := 0; i < numSessions; i++ {
- wgMaint.Add(1)
- go func(id int) {
- defer wgMaint.Done()
- select {
- case <-ctx.Done():
- return
- case <-time.After(time.Duration(id) * 300 * time.Millisecond):
- }
- maintainVLESSSession(ctx, tp, peer, id, pool)
- }(i)
- }
-
- // Wait for at least one session
- log.Printf("VLESS mode: waiting for sessions to connect (total: %d)...", numSessions)
- for {
- select {
- case <-ctx.Done():
- wgMaint.Wait()
- return
- case <-time.After(100 * time.Millisecond):
- }
- if pool.count() > 0 {
- break
- }
- }
-
- listener, err := net.Listen("tcp", listenAddr)
- if err != nil {
- log.Panicf("TCP listen: %s", err)
- }
- context.AfterFunc(ctx, func() { _ = listener.Close() })
- log.Printf("VLESS mode: listening on %s (round-robin across %d sessions)", listenAddr, numSessions)
-
- var wgConn sync.WaitGroup
- for {
- tcpConn, err := listener.Accept()
- if err != nil {
- select {
- case <-ctx.Done():
- wgConn.Wait()
- wgMaint.Wait()
- return
- default:
- }
- log.Printf("TCP accept error: %s", err)
- continue
- }
-
- sess := pool.pick()
- if sess == nil || sess.IsClosed() {
- log.Printf("No active sessions, rejecting connection")
- _ = tcpConn.Close()
- continue
- }
-
- wgConn.Add(1)
- go func(tc net.Conn, s *smux.Session) {
- defer wgConn.Done()
- defer func() { _ = tc.Close() }()
- stream, err := s.OpenStream()
- if err != nil {
- log.Printf("smux open stream error: %s", err)
- return
- }
- defer func() { _ = stream.Close() }()
- pipe(ctx, tc, stream)
- }(tcpConn, sess)
- }
-}
-
-// maintainVLESSSession keeps one TURN+DTLS+KCP+smux session alive, reconnecting on failure.
-func maintainVLESSSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int, pool *sessionPool) {
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
-
- smuxSess, cleanup, err := createSmuxSession(ctx, tp, peer, id)
- if err != nil {
- log.Printf("[session %d] setup error: %s, retrying...", id, err)
- select {
- case <-ctx.Done():
- return
- case <-time.After(3 * time.Second):
- }
- continue
- }
-
- pool.add(smuxSess)
- log.Printf("[session %d] connected (active: %d)", id, pool.count())
-
- for !smuxSess.IsClosed() {
- select {
- case <-ctx.Done():
- pool.remove(smuxSess)
- cleanup()
- return
- case <-time.After(1 * time.Second):
- }
- }
-
- pool.remove(smuxSess)
- cleanup()
- log.Printf("[session %d] disconnected (active: %d), reconnecting...", id, pool.count())
-
- select {
- case <-ctx.Done():
- return
- case <-time.After(2 * time.Second):
- }
- }
-}
-
-// createSmuxSession establishes a full TURN+DTLS+KCP+smux pipeline and returns
-// the smux session along with a cleanup function to tear down all layers.
-func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int) (*smux.Session, func(), error) {
- var cleanupFns []func()
- cleanup := func() {
- for i := len(cleanupFns) - 1; i >= 0; i-- {
- cleanupFns[i]()
- }
- }
-
- // 1. Get TURN credentials
- user, pass, rawURL, err := tp.getCreds(ctx, tp.link, id)
- if err != nil {
- return nil, nil, fmt.Errorf("get TURN creds: %w", err)
- }
- urlhost, urlport, err := net.SplitHostPort(rawURL)
- if err != nil {
- return nil, nil, fmt.Errorf("parse TURN addr: %w", err)
- }
- if tp.host != "" {
- urlhost = tp.host
- }
- if tp.port != "" {
- urlport = tp.port
- }
- turnServerAddr := net.JoinHostPort(urlhost, urlport)
- turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr)
- if err != nil {
- return nil, nil, fmt.Errorf("resolve TURN addr: %w", err)
- }
- turnServerAddr = turnServerUDPAddr.String()
- fmt.Println(turnServerUDPAddr.IP)
-
- // 2. Connect to TURN server
- var turnConn net.PacketConn
- ctx1, cancel1 := context.WithTimeout(ctx, 5*time.Second)
- defer cancel1()
- if tp.udp {
- c, err1 := net.DialUDP("udp", nil, turnServerUDPAddr)
- if err1 != nil {
- return nil, nil, fmt.Errorf("dial TURN (udp): %w", err1)
- }
- cleanupFns = append(cleanupFns, func() { _ = c.Close() })
- turnConn = &connectedUDPConn{c}
- } else {
- var d net.Dialer
- c, err1 := d.DialContext(ctx1, "tcp", turnServerAddr)
- if err1 != nil {
- return nil, nil, fmt.Errorf("dial TURN (tcp): %w", err1)
- }
- cleanupFns = append(cleanupFns, func() { _ = c.Close() })
- turnConn = turn.NewSTUNConn(c)
- }
-
- // 3. Create TURN client and allocate relay
- var addrFamily turn.RequestedAddressFamily
- if peer.IP.To4() != nil {
- addrFamily = turn.RequestedAddressFamilyIPv4
- } else {
- addrFamily = turn.RequestedAddressFamilyIPv6
- }
- cfg := &turn.ClientConfig{
- STUNServerAddr: turnServerAddr,
- TURNServerAddr: turnServerAddr,
- Conn: turnConn,
- Net: newDirectNet(),
- Username: user,
- Password: pass,
- RequestedAddressFamily: addrFamily,
- LoggerFactory: logging.NewDefaultLoggerFactory(),
- }
- turnClient, err := turn.NewClient(cfg)
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("create TURN client: %w", err)
- }
- cleanupFns = append(cleanupFns, func() { turnClient.Close() })
- if err = turnClient.Listen(); err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("TURN listen: %w", err)
- }
- relayConn, err := turnClient.Allocate()
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("TURN allocate: %w", err)
- }
- cleanupFns = append(cleanupFns, func() { _ = relayConn.Close() })
- log.Printf("relayed-address=%s", relayConn.LocalAddr().String())
-
- // 4. Establish DTLS over TURN relay
- certificate, err := selfsign.GenerateSelfSigned()
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("generate cert: %w", err)
- }
- dtlsPC := &relayPacketConn{relay: relayConn, peer: peer}
- dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer,
- dtls.WithCertificates(certificate),
- dtls.WithInsecureSkipVerify(true),
- dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
- dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
- dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()),
- )
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("DTLS client create: %w", err)
- }
- ctx2, cancel2 := context.WithTimeout(ctx, 30*time.Second)
- defer cancel2()
- if err = dtlsConn.HandshakeContext(ctx2); err != nil {
- _ = dtlsConn.Close()
- cleanup()
- return nil, nil, fmt.Errorf("DTLS handshake: %w", err)
- }
- cleanupFns = append(cleanupFns, func() { _ = dtlsConn.Close() })
- log.Printf("DTLS connection established")
-
- // 5. Create KCP session over DTLS
- kcpSess, err := tcputil.NewKCPOverDTLS(dtlsConn, false)
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("KCP session: %w", err)
- }
- cleanupFns = append(cleanupFns, func() { _ = kcpSess.Close() })
- log.Printf("KCP session established")
-
- // 6. Create smux client session over KCP
- smuxSess, err := smux.Client(kcpSess, tcputil.DefaultSmuxConfig())
- if err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("smux client: %w", err)
- }
- cleanupFns = append(cleanupFns, func() { _ = smuxSess.Close() })
- log.Printf("smux session established")
-
- return smuxSess, cleanup, nil
-}
-
-// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer.
-type relayPacketConn struct {
- relay net.PacketConn
- peer net.Addr
-}
-
-func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
- return r.relay.ReadFrom(b)
-}
-
-func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
- return r.relay.WriteTo(b, r.peer)
-}
-
-func (r *relayPacketConn) Close() error { return r.relay.Close() }
-func (r *relayPacketConn) LocalAddr() net.Addr { return r.relay.LocalAddr() }
-func (r *relayPacketConn) SetDeadline(t time.Time) error { return r.relay.SetDeadline(t) }
-func (r *relayPacketConn) SetReadDeadline(t time.Time) error { return r.relay.SetReadDeadline(t) }
-func (r *relayPacketConn) SetWriteDeadline(t time.Time) error { return r.relay.SetWriteDeadline(t) }
-
-// pipe copies data bidirectionally between two connections.
-func pipe(ctx context.Context, c1, c2 net.Conn) {
- ctx2, cancel := context.WithCancel(ctx)
- context.AfterFunc(ctx2, func() {
- if err := c1.SetDeadline(time.Now()); err != nil {
- log.Printf("pipe: failed to set deadline c1: %v", err)
- }
- if err := c2.SetDeadline(time.Now()); err != nil {
- log.Printf("pipe: failed to set deadline c2: %v", err)
- }
- })
-
- var wg sync.WaitGroup
- wg.Add(2)
- go func() {
- defer wg.Done()
- defer cancel()
- if _, err := io.Copy(c1, c2); err != nil {
- log.Printf("pipe: c1<-c2 copy error: %v", err)
- }
- }()
- go func() {
- defer wg.Done()
- defer cancel()
- if _, err := io.Copy(c2, c1); err != nil {
- log.Printf("pipe: c2<-c1 copy error: %v", err)
- }
- }()
- wg.Wait()
- if err := c1.SetDeadline(time.Time{}); err != nil {
- log.Printf("pipe: failed to reset deadline c1: %v", err)
- }
- if err := c2.SetDeadline(time.Time{}); err != nil {
- log.Printf("pipe: failed to reset deadline c2: %v", err)
- }
+ clientcore.RunCLI()
}
diff --git a/client/manual_captcha_test.go b/client/manual_captcha_test.go
deleted file mode 100644
index 8afbafd..0000000
--- a/client/manual_captcha_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package main
-
-import (
- "net/url"
- "testing"
-)
-
-func TestRewriteProxyRedirectLocation(t *testing.T) {
- t.Parallel()
-
- targetURL, err := url.Parse("https://id.vk.ru/captcha")
- if err != nil {
- t.Fatalf("failed to parse target URL: %v", err)
- }
-
- testCases := []struct {
- name string
- location string
- want string
- ok bool
- }{
- {
- name: "keeps safe relative path",
- location: "/captcha?step=2",
- want: "/captcha?step=2",
- ok: true,
- },
- {
- name: "rewrites same-origin absolute URL",
- location: "https://id.vk.ru/captcha?step=2",
- want: "http://localhost:8765/captcha?step=2",
- ok: true,
- },
- {
- name: "blocks scheme-relative redirect",
- location: "//evil.example/captcha",
- ok: false,
- },
- {
- name: "blocks slash-backslash redirect",
- location: `/\evil.example/captcha`,
- ok: false,
- },
- {
- name: "blocks lookalike absolute host",
- location: "https://id.vk.ru.evil.example/captcha",
- ok: false,
- },
- }
-
- for _, tc := range testCases {
- tc := tc
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
-
- got, ok := rewriteProxyRedirectLocation(tc.location, targetURL)
- if ok != tc.ok {
- t.Fatalf("rewriteProxyRedirectLocation() ok = %v, want %v", ok, tc.ok)
- }
- if got != tc.want {
- t.Fatalf("rewriteProxyRedirectLocation() = %q, want %q", got, tc.want)
- }
- })
- }
-}
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 941bf24..521dc00 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -2,10 +2,24 @@
set -e
CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}"
+LISTEN="${LISTEN_ADDR:-0.0.0.0:56000}"
VLESS_FLAG=""
if [ "${VLESS_MODE}" = "true" ]; then
VLESS_FLAG="-vless"
fi
-exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" $VLESS_FLAG
+BOND_FLAG=""
+if [ "${VLESS_BOND}" = "true" ]; then
+ BOND_FLAG="-vless-bond"
+fi
+
+WRAP_FLAG=""
+WRAP_KEY_FLAG=""
+if [ "${WRAP_MODE}" = "true" ]; then
+ WRAP="${WRAP_KEY:?WRAP_KEY is required when WRAP_MODE=true}"
+ WRAP_FLAG="-wrap"
+ WRAP_KEY_FLAG="-wrap-key $WRAP"
+fi
+
+exec ./vk-turn-proxy -listen "$LISTEN" -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG $WRAP_FLAG $WRAP_KEY_FLAG
diff --git a/go.mod b/go.mod
index e6e5929..b1df564 100644
--- a/go.mod
+++ b/go.mod
@@ -49,3 +49,8 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
)
+
+exclude (
+ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8
+ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55
+)
diff --git a/pkg/clientcore/captcha_v2.go b/pkg/clientcore/captcha_v2.go
new file mode 100644
index 0000000..806c446
--- /dev/null
+++ b/pkg/clientcore/captcha_v2.go
@@ -0,0 +1,575 @@
+package clientcore
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ mathrand "math/rand"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ fhttp "github.com/bogdanfinn/fhttp"
+ tlsclient "github.com/bogdanfinn/tls-client"
+)
+
+const (
+ captchaV2APIVersion = "5.131"
+ captchaV2ScriptVersion = "1.1.1324"
+ captchaV2DeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}`
+)
+
+var (
+ reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
+ reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
+ reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
+ reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
+ reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
+ reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
+
+ errCaptchaV2RateLimit = errors.New("captcha session rate limit reached")
+ errCaptchaV2Bot = errors.New("captcha bot challenge")
+
+ captchaV2MaxAttempts = 2
+
+ captchaV2DebugCache sync.Map // scriptURL -> string
+ captchaV2HeaderOrder = []string{
+ "host",
+ "content-length",
+ "sec-ch-ua-platform",
+ "accept-language",
+ "sec-ch-ua",
+ "content-type",
+ "sec-ch-ua-mobile",
+ "user-agent",
+ "accept",
+ "origin",
+ "sec-fetch-site",
+ "sec-fetch-mode",
+ "sec-fetch-dest",
+ "referer",
+ "accept-encoding",
+ "priority",
+ }
+ captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
+)
+
+type captchaV2Init struct {
+ Data captchaV2InitData `json:"data"`
+}
+
+type captchaV2InitData struct {
+ ShowCaptchaType string `json:"show_captcha_type"`
+ CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"`
+}
+
+type captchaV2InitSetting struct {
+ Type string `json:"type"`
+ Settings string `json:"settings"`
+}
+
+type captchaV2Page struct {
+ PowInput string
+ PowDifficulty int
+ ScriptURL string
+ Init *captchaV2Init
+}
+
+type captchaV2Check struct {
+ Status string
+ SuccessToken string
+ ShowType string
+}
+
+type captchaV2ShowTypeError struct {
+ ShowType string
+}
+
+func (e *captchaV2ShowTypeError) Error() string {
+ return "captcha show type mismatch: " + e.ShowType
+}
+
+type captchaV2Session struct {
+ ctx context.Context
+ client tlsclient.HttpClient
+ profile Profile
+ savedProfile *SavedProfile
+}
+
+func solveVkCaptchaV2(
+ ctx context.Context,
+ captchaErr *VkCaptchaError,
+ streamID int,
+ client tlsclient.HttpClient,
+ profile Profile,
+ savedProfile *SavedProfile,
+) (string, error) {
+ if captchaErr == nil || captchaErr.SessionToken == "" {
+ return "", fmt.Errorf("no session_token in redirect_uri")
+ }
+ log.Printf("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically (v2)...", streamID)
+
+ s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile}
+
+ for attempt := 1; attempt <= captchaV2MaxAttempts; attempt++ {
+ token, solveErr := s.solveOnce(captchaErr)
+ if solveErr == nil {
+ return token, nil
+ }
+ log.Printf("[STREAM %d] [Captcha] v2 captcha solve attempt %d failed: %v", streamID, attempt, solveErr)
+ if errors.Is(solveErr, errCaptchaV2RateLimit) {
+ return "", solveErr
+ }
+
+ backoffSteps := attempt
+ if backoffSteps > 10 {
+ backoffSteps = 10
+ }
+ timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return "", ctx.Err()
+ case <-timer.C:
+ }
+ }
+ return "", fmt.Errorf("v2 captcha attempts exhausted")
+}
+
+func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) {
+ html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
+ if err != nil {
+ return "", err
+ }
+
+ page, err := parseCaptchaV2Page(html)
+ if err != nil {
+ return "", err
+ }
+ if page.PowInput == "" {
+ return "", errors.New("failed to find PoW settings")
+ }
+
+ sliderSettings := ""
+ if page.Init != nil {
+ for _, setting := range page.Init.Data.CaptchaSettings {
+ if setting.Type == "slider" {
+ sliderSettings = setting.Settings
+ }
+ }
+ }
+ if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" {
+ return "", errors.New("failed to find slider captcha settings")
+ }
+
+ log.Printf("v2 captcha solving pow difficulty=%d", page.PowDifficulty)
+ hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty)
+ if hash == "" {
+ return "", errors.New("captcha pow failed")
+ }
+ log.Printf("v2 captcha pow solved")
+
+ base := captchaV2BaseValues(captchaErr.SessionToken)
+ if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
+ return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
+ }
+
+ browserFP, err := captchaV2BrowserFP()
+ if err != nil {
+ return "", err
+ }
+ if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
+ browserFP = s.savedProfile.BrowserFp
+ }
+
+ if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
+ if m[1] != captchaV2ScriptVersion {
+ log.Printf("v2 captcha script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1])
+ }
+ }
+
+ debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
+ if err != nil {
+ return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion)
+ }
+
+ showType := ""
+ if page.Init != nil {
+ showType = page.Init.Data.ShowCaptchaType
+ }
+ var token string
+ for {
+ log.Printf("v2 captcha solving show_type=%s", showType)
+ switch showType {
+ case "slider":
+ token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo)
+ case "checkbox", "":
+ token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo)
+ default:
+ return "", fmt.Errorf("unsupported captcha type: %s", showType)
+ }
+ if err == nil {
+ break
+ }
+ if errors.Is(err, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" {
+ log.Printf("v2 captcha checkbox returned BOT, trying slider challenge from page settings")
+ showType = "slider"
+ continue
+ }
+ var stErr *captchaV2ShowTypeError
+ if !errors.As(err, &stErr) || stErr.ShowType == "" {
+ return "", err
+ }
+ showType = stErr.ShowType
+ }
+
+ if _, endErr := s.captchaRequest("captchaNotRobot.endSession", base); endErr != nil {
+ log.Printf("v2 captcha endSession failed: %v", endErr)
+ }
+ return token, nil
+}
+
+func captchaV2BaseValues(sessionToken string) [][2]string {
+ return [][2]string{
+ {"session_token", sessionToken},
+ {"domain", "vk.com"},
+ {"adFp", ""},
+ {"access_token", ""},
+ }
+}
+
+func captchaV2BrowserFP() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("browser fp generate: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
+func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) {
+ body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "cross-site",
+ })
+ if err != nil {
+ return "", err
+ }
+ return string(body), nil
+}
+
+func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) {
+ if cached, ok := captchaV2DebugCache.Load(scriptURL); ok {
+ if cachedDebugInfo, ok := cached.(string); ok {
+ return cachedDebugInfo, nil
+ }
+ captchaV2DebugCache.Delete(scriptURL)
+ }
+ body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
+ "Accept": "text/javascript,*/*",
+ "Referer": "https://id.vk.com/",
+ })
+ if err != nil {
+ return "", err
+ }
+ m := reCaptchaV2DebugInfo.FindSubmatch(body)
+ if len(m) < 2 {
+ return "", errors.New("debug_info match not found")
+ }
+ v := string(m[1])
+ captchaV2DebugCache.Store(scriptURL, v)
+ log.Printf("v2 captcha debug_info fetched url=%s", scriptURL)
+ return v, nil
+}
+
+func parseCaptchaV2Page(html string) (*captchaV2Page, error) {
+ page := &captchaV2Page{}
+
+ match := reCaptchaV2WindowInit.FindStringSubmatch(html)
+ if len(match) < 2 {
+ return nil, errors.New("captcha init json not found")
+ }
+ var init captchaV2Init
+ if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
+ return nil, fmt.Errorf("captcha init json parse: %w", err)
+ }
+ page.Init = &init
+
+ match = reCaptchaV2ScriptSrc.FindStringSubmatch(html)
+ if len(match) < 2 {
+ return nil, errors.New("captcha script url not found")
+ }
+ page.ScriptURL = match[1]
+
+ if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 {
+ page.PowInput = m[1]
+ }
+ if page.PowInput == "" {
+ return page, nil
+ }
+
+ match = reCaptchaV2Difficulty.FindStringSubmatch(html)
+ if len(match) < 2 {
+ return nil, errors.New("captcha difficulty const not found")
+ }
+ difficulty, err := strconv.Atoi(match[1])
+ if err != nil || difficulty <= 0 {
+ return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
+ }
+ page.PowDifficulty = difficulty
+ return page, nil
+}
+
+func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) {
+ endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion
+ body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
+ "Origin": "https://id.vk.com",
+ "Referer": "https://id.vk.com/",
+ "Priority": "u=1, i",
+ })
+ if err != nil {
+ return nil, err
+ }
+ var out map[string]any
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("captcha api decode: %w", err)
+ }
+ return out, nil
+}
+
+func (s *captchaV2Session) performCaptchaCheck(
+ sessionToken string,
+ browserFP string,
+ hash string,
+ answerJSON string,
+ cursor string,
+ debugInfo string,
+) (*captchaV2Check, error) {
+ values := [][2]string{
+ {"session_token", sessionToken},
+ {"domain", "vk.com"},
+ {"adFp", ""},
+ {"accelerometer", "[]"},
+ {"gyroscope", "[]"},
+ {"motion", "[]"},
+ {"cursor", cursor},
+ {"taps", "[]"},
+ {"connectionRtt", "[]"},
+ {"connectionDownlink", "[]"},
+ {"browser_fp", browserFP},
+ {"hash", hash},
+ {"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))},
+ {"debug_info", debugInfo},
+ {"access_token", ""},
+ }
+ resp, err := s.captchaRequest("captchaNotRobot.check", values)
+ if err != nil {
+ return nil, fmt.Errorf("captcha check failed: %w", err)
+ }
+ check, err := parseCaptchaV2Check(resp)
+ if err != nil {
+ return nil, err
+ }
+ if check.ShowType != "" {
+ log.Printf("v2 captcha check status=%s show_type=%s", check.Status, check.ShowType)
+ } else {
+ log.Printf("v2 captcha check status=%s", check.Status)
+ }
+ return check, nil
+}
+
+func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) {
+ resp, ok := raw["response"].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid captcha check response: %v", raw)
+ }
+ out := &captchaV2Check{
+ Status: captchaV2StringifyAny(resp["status"]),
+ SuccessToken: captchaV2StringifyAny(resp["success_token"]),
+ ShowType: captchaV2StringifyAny(resp["show_captcha_type"]),
+ }
+ if out.Status == "" {
+ return nil, fmt.Errorf("captcha check status missing: %v", raw)
+ }
+ return out, nil
+}
+
+func (s *captchaV2Session) solveCheckboxCaptcha(
+ sessionToken string,
+ browserFP string,
+ hash string,
+ debugInfo string,
+) (string, error) {
+ deviceJSON := captchaV2DeviceInfo
+ if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
+ deviceJSON = s.savedProfile.DeviceJSON
+ }
+ if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
+ {"session_token", sessionToken},
+ {"domain", "vk.com"},
+ {"adFp", ""},
+ {"browser_fp", browserFP},
+ {"device", deviceJSON},
+ {"access_token", ""},
+ }); err != nil {
+ return "", fmt.Errorf("captcha componentDone failed: %w", err)
+ }
+
+ select {
+ case <-s.ctx.Done():
+ return "", s.ctx.Err()
+ case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond):
+ }
+
+ check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo)
+ if err != nil {
+ return "", err
+ }
+ if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") {
+ return "", &captchaV2ShowTypeError{ShowType: check.ShowType}
+ }
+ if strings.EqualFold(check.Status, "error_limit") {
+ return "", errCaptchaV2RateLimit
+ }
+ if strings.EqualFold(check.Status, "bot") {
+ return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status)
+ }
+ if !strings.EqualFold(check.Status, "ok") {
+ return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status)
+ }
+ if check.SuccessToken == "" {
+ return "", errors.New("captcha success token not found")
+ }
+ return check.SuccessToken, nil
+}
+
+func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string {
+ if input == "" || difficulty <= 0 {
+ return ""
+ }
+ target := strings.Repeat("0", difficulty)
+ for nonce := 1; nonce <= 10_000_000; nonce++ {
+ if nonce%4096 == 0 {
+ select {
+ case <-ctx.Done():
+ return ""
+ default:
+ }
+ }
+ sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce)))
+ hashHex := hex.EncodeToString(sum[:])
+ if strings.HasPrefix(hashHex, target) {
+ return hashHex
+ }
+ }
+ return ""
+}
+
+func (s *captchaV2Session) doRaw(
+ method string,
+ endpoint string,
+ form [][2]string,
+ extraHeaders map[string]string,
+) ([]byte, error) {
+ var body []byte
+ if form != nil {
+ body = []byte(captchaV2EncodeForm(form))
+ }
+ req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ applyBrowserProfileFhttp(req, s.profile)
+ req.Header.Set("Accept", "*/*")
+ 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("Origin", "https://vk.com")
+ req.Header.Set("Referer", "https://vk.com/")
+ if form != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ for k, v := range extraHeaders {
+ req.Header.Set(k, v)
+ }
+ req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder
+ req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Printf("v2 captcha close body: %s", closeErr)
+ }
+ }()
+ return io.ReadAll(resp.Body)
+}
+
+func captchaV2EncodeForm(values [][2]string) string {
+ if len(values) == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ for i, kv := range values {
+ if i > 0 {
+ sb.WriteByte('&')
+ }
+ sb.WriteString(captchaV2QueryEscape(kv[0]))
+ sb.WriteByte('=')
+ sb.WriteString(captchaV2QueryEscape(kv[1]))
+ }
+ return sb.String()
+}
+
+func captchaV2QueryEscape(s string) string {
+ const upper = "0123456789ABCDEF"
+ hexDigits := func(b byte) [3]byte {
+ return [3]byte{'%', upper[b>>4], upper[b&0xF]}
+ }
+ out := make([]byte, 0, len(s))
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c == ' ':
+ out = append(out, '+')
+ case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~':
+ out = append(out, c)
+ default:
+ h := hexDigits(c)
+ out = append(out, h[:]...)
+ }
+ }
+ return string(out)
+}
+
+func captchaV2StringifyAny(value any) string {
+ switch v := value.(type) {
+ case nil:
+ return ""
+ case string:
+ return v
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ case bool:
+ return strconv.FormatBool(v)
+ default:
+ data, err := json.Marshal(v)
+ if err != nil {
+ return fmt.Sprintf("%v", v)
+ }
+ return string(data)
+ }
+}
diff --git a/pkg/clientcore/captcha_v2_slider.go b/pkg/clientcore/captcha_v2_slider.go
new file mode 100644
index 0000000..a8d3dbd
--- /dev/null
+++ b/pkg/clientcore/captcha_v2_slider.go
@@ -0,0 +1,606 @@
+package clientcore
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ _ "image/jpeg" // register JPEG decoder
+ "log"
+ "math"
+ mathrand "math/rand"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+type sliderPuzzleV2 struct {
+ Image image.Image
+ Size int
+ Swaps []int
+ Attempts int
+}
+
+type sliderGuessV2 struct {
+ Index int
+ Swaps []int
+ Score int64
+ ScoreRGB int64
+ ScoreLuma int64
+ ScoreText float64
+ ConsensusRank int
+}
+
+func (s *captchaV2Session) solveSliderCaptcha(
+ sessionToken string,
+ browserFP string,
+ hash string,
+ settings string,
+ debugInfo string,
+) (string, error) {
+ values := [][2]string{
+ {"session_token", sessionToken},
+ {"domain", "vk.com"},
+ {"adFp", ""},
+ {"access_token", ""},
+ {"captcha_settings", settings},
+ }
+
+ resp, err := s.captchaRequest("captchaNotRobot.getContent", values)
+ if err != nil {
+ return "", fmt.Errorf("slider getContent failed: %w", err)
+ }
+ puzzle, err := parseSliderPuzzleV2(resp)
+ if err != nil {
+ return "", err
+ }
+ log.Printf("v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
+
+ guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps)
+ if err != nil {
+ return "", err
+ }
+
+ limit := puzzle.Attempts
+ if limit > len(guesses) {
+ limit = len(guesses)
+ }
+ if limit <= 0 {
+ return "", errors.New("slider has no attempts available")
+ }
+ log.Printf("v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit)
+
+ deviceJSON := captchaV2DeviceInfo
+ if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
+ deviceJSON = s.savedProfile.DeviceJSON
+ }
+ if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
+ {"session_token", sessionToken},
+ {"domain", "vk.com"},
+ {"adFp", ""},
+ {"access_token", ""},
+ {"browser_fp", browserFP},
+ {"device", deviceJSON},
+ }); err != nil {
+ return "", fmt.Errorf("captcha componentDone failed: %w", err)
+ }
+
+ for i := 0; i < limit; i++ {
+ log.Printf("v2 slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
+ answerData, err := json.Marshal(struct {
+ Value []int `json:"value"`
+ }{Value: guesses[i].Swaps})
+ if err != nil {
+ return "", err
+ }
+ check, err := s.performCaptchaCheck(
+ sessionToken,
+ browserFP,
+ hash,
+ string(answerData),
+ buildSliderCursorV2(guesses[i].Index, len(guesses)),
+ debugInfo,
+ )
+ if err != nil {
+ return "", err
+ }
+ if strings.EqualFold(check.Status, "ok") {
+ if check.SuccessToken == "" {
+ return "", errors.New("captcha success token not found")
+ }
+ log.Printf("v2 slider accepted on attempt %d", i+1)
+ return check.SuccessToken, nil
+ }
+ if strings.EqualFold(check.Status, "error_limit") {
+ return "", errCaptchaV2RateLimit
+ }
+ }
+ return "", errors.New("slider guesses exhausted")
+}
+
+func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) {
+ resp, ok := raw["response"].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("invalid slider content response: %v", raw)
+ }
+ status := captchaV2StringifyAny(resp["status"])
+ if !strings.EqualFold(status, "ok") {
+ return nil, fmt.Errorf("slider getContent status: %s", status)
+ }
+ rawImage := captchaV2StringifyAny(resp["image"])
+ if rawImage == "" {
+ return nil, errors.New("slider image missing")
+ }
+ rawSteps, ok := resp["steps"].([]any)
+ if !ok {
+ return nil, errors.New("slider steps missing")
+ }
+ steps := make([]int, 0, len(rawSteps))
+ for _, item := range rawSteps {
+ switch v := item.(type) {
+ case float64:
+ steps = append(steps, int(v))
+ case int:
+ steps = append(steps, v)
+ case string:
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil {
+ return nil, fmt.Errorf("invalid numeric value: %v", item)
+ }
+ steps = append(steps, n)
+ default:
+ return nil, fmt.Errorf("invalid numeric value: %v", item)
+ }
+ }
+ size, swaps, attempts, err := splitSliderStepsV2(steps)
+ if err != nil {
+ return nil, err
+ }
+ data, err := base64.StdEncoding.DecodeString(rawImage)
+ if err != nil {
+ return nil, fmt.Errorf("decode slider image: %w", err)
+ }
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("decode slider image: %w", err)
+ }
+ return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil
+}
+
+func splitSliderStepsV2(steps []int) (int, []int, int, error) {
+ if len(steps) < 3 {
+ return 0, nil, 0, errors.New("slider steps payload too short")
+ }
+ size := steps[0]
+ if size <= 0 {
+ return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
+ }
+ tail := append([]int(nil), steps[1:]...)
+ attempts := 4
+ if len(tail)%2 != 0 {
+ attempts = tail[len(tail)-1]
+ tail = tail[:len(tail)-1]
+ log.Printf("v2 slider payload had odd-length tail; fallback attempts=%d", attempts)
+ }
+ if attempts <= 0 {
+ attempts = 4
+ }
+ if len(tail) == 0 || len(tail)%2 != 0 {
+ return 0, nil, 0, errors.New("invalid slider swap payload")
+ }
+ return size, tail, attempts, nil
+}
+
+func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) {
+ candidateCount := len(swaps) / 2
+ if candidateCount == 0 {
+ return nil, errors.New("slider has no candidates")
+ }
+
+ guesses := make([]sliderGuessV2, candidateCount)
+ for idx := 1; idx <= candidateCount; idx++ {
+ active := activeSwapsForIndexV2(swaps, idx)
+ mapping, err := applySliderSwapsV2(gridSize, active)
+ if err != nil {
+ return nil, err
+ }
+ guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active}
+ guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping)
+ }
+
+ lumaOrder := append([]sliderGuessV2(nil), guesses...)
+ sort.SliceStable(lumaOrder, func(i, j int) bool {
+ if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma {
+ return lumaOrder[i].Index < lumaOrder[j].Index
+ }
+ return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma
+ })
+ lumaRank := make(map[int]int, candidateCount)
+ for rank, g := range lumaOrder {
+ lumaRank[g.Index] = rank
+ }
+
+ stage2Count := candidateCount
+ if stage2Count > 12 {
+ stage2Count = 12
+ }
+ stage2Set := make(map[int]struct{}, stage2Count)
+ for i := 0; i < stage2Count; i++ {
+ stage2Set[lumaOrder[i].Index] = struct{}{}
+ }
+
+ type stage2Result struct {
+ index int
+ rgb int64
+ text float64
+ err error
+ }
+ jobs := make([]int, 0, stage2Count)
+ for idx := range stage2Set {
+ jobs = append(jobs, idx)
+ }
+ jobCh := make(chan int, len(jobs))
+ resCh := make(chan stage2Result, len(jobs))
+
+ workers := runtime.NumCPU()
+ if workers < 1 {
+ workers = 1
+ }
+ if workers > len(jobs) {
+ workers = len(jobs)
+ }
+ var wg sync.WaitGroup
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for index := range jobCh {
+ mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps)
+ if err != nil {
+ resCh <- stage2Result{index: index, err: err}
+ continue
+ }
+ rgb, text := seamScoreRGBTextV2(img, gridSize, mapping)
+ resCh <- stage2Result{index: index, rgb: rgb, text: text}
+ }
+ }()
+ }
+ for _, idx := range jobs {
+ jobCh <- idx
+ }
+ close(jobCh)
+ wg.Wait()
+ close(resCh)
+ for r := range resCh {
+ if r.err != nil {
+ return nil, r.err
+ }
+ g := &guesses[r.index-1]
+ g.ScoreRGB = r.rgb
+ g.ScoreText = r.text
+ }
+
+ stage2 := make([]sliderGuessV2, 0, stage2Count)
+ for _, g := range guesses {
+ if _, ok := stage2Set[g.Index]; ok {
+ stage2 = append(stage2, g)
+ }
+ }
+
+ rgbOrder := append([]sliderGuessV2(nil), stage2...)
+ sort.SliceStable(rgbOrder, func(i, j int) bool {
+ if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
+ return rgbOrder[i].Index < rgbOrder[j].Index
+ }
+ return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB
+ })
+ rgbRank := make(map[int]int, len(rgbOrder))
+ for rank, g := range rgbOrder {
+ rgbRank[g.Index] = rank
+ }
+
+ textOrder := append([]sliderGuessV2(nil), stage2...)
+ sort.SliceStable(textOrder, func(i, j int) bool {
+ if textOrder[i].ScoreText == textOrder[j].ScoreText {
+ return textOrder[i].Index < textOrder[j].Index
+ }
+ return textOrder[i].ScoreText < textOrder[j].ScoreText
+ })
+ textRank := make(map[int]int, len(textOrder))
+ for rank, g := range textOrder {
+ textRank[g.Index] = rank
+ }
+
+ for i := range guesses {
+ g := &guesses[i]
+ g.ConsensusRank = lumaRank[g.Index]
+ if _, ok := stage2Set[g.Index]; ok {
+ g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
+ } else {
+ g.ConsensusRank += candidateCount
+ }
+ g.Score = int64(g.ConsensusRank)
+ }
+
+ sort.SliceStable(guesses, func(i, j int) bool {
+ if guesses[i].ConsensusRank == guesses[j].ConsensusRank {
+ if guesses[i].ScoreLuma == guesses[j].ScoreLuma {
+ return guesses[i].Index < guesses[j].Index
+ }
+ return guesses[i].ScoreLuma < guesses[j].ScoreLuma
+ }
+ return guesses[i].ConsensusRank < guesses[j].ConsensusRank
+ })
+ return guesses, nil
+}
+
+func activeSwapsForIndexV2(swaps []int, index int) []int {
+ if index <= 0 {
+ return []int{}
+ }
+ end := index * 2
+ if end > len(swaps) {
+ end = len(swaps)
+ }
+ return append([]int(nil), swaps[:end]...)
+}
+
+func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
+ tileCount := gridSize * gridSize
+ if tileCount <= 0 {
+ return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
+ }
+ if len(swaps)%2 != 0 {
+ return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps))
+ }
+ mapping := make([]int, tileCount)
+ for i := range mapping {
+ mapping[i] = i
+ }
+ for i := 0; i < len(swaps); i += 2 {
+ left := swaps[i]
+ right := swaps[i+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 seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 {
+ bounds := img.Bounds()
+ var score int64
+ for row := 0; row < gridSize; row++ {
+ for col := 0; col < gridSize-1; col++ {
+ leftIdx := row*gridSize + col
+ rightIdx := leftIdx + 1
+ leftDst := sliderTileRect(bounds, gridSize, leftIdx)
+ rightDst := sliderTileRect(bounds, gridSize, rightIdx)
+ leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
+ rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
+ h := leftDst.Dy()
+ if rightDst.Dy() < h {
+ h = rightDst.Dy()
+ }
+ for y := 0; y < h; y++ {
+ yy := leftDst.Min.Y + y
+ a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
+ b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
+ score += int64(absIntV2(int(a) - int(b)))
+ }
+ }
+ }
+ for row := 0; row < gridSize-1; row++ {
+ for col := 0; col < gridSize; col++ {
+ topIdx := row*gridSize + col
+ bottomIdx := (row+1)*gridSize + col
+ topDst := sliderTileRect(bounds, gridSize, topIdx)
+ bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
+ topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
+ bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
+ w := topDst.Dx()
+ if bottomDst.Dx() < w {
+ w = bottomDst.Dx()
+ }
+ for x := 0; x < w; x++ {
+ xx := topDst.Min.X + x
+ a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
+ b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
+ score += int64(absIntV2(int(a) - int(b)))
+ }
+ }
+ }
+ return score
+}
+
+func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) {
+ bounds := img.Bounds()
+ height := float64(bounds.Dy())
+ textCenters := []float64{
+ float64(bounds.Min.Y) + 0.2*height,
+ float64(bounds.Min.Y) + 0.5*height,
+ float64(bounds.Min.Y) + 0.8*height,
+ }
+ sigma := height * 0.14
+ if sigma < 1.0 {
+ sigma = 1.0
+ }
+ weight := func(y int) float64 {
+ yf := float64(y)
+ best := absFloatV2(yf - textCenters[0])
+ for i := 1; i < len(textCenters); i++ {
+ d := absFloatV2(yf - textCenters[i])
+ if d < best {
+ best = d
+ }
+ }
+ return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
+ }
+
+ var rgbScore int64
+ var textScore float64
+ for row := 0; row < gridSize; row++ {
+ for col := 0; col < gridSize-1; col++ {
+ leftIdx := row*gridSize + col
+ rightIdx := leftIdx + 1
+ leftDst := sliderTileRect(bounds, gridSize, leftIdx)
+ rightDst := sliderTileRect(bounds, gridSize, rightIdx)
+ leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
+ rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
+ h := leftDst.Dy()
+ if rightDst.Dy() < h {
+ h = rightDst.Dy()
+ }
+ for y := 0; y < h; y++ {
+ yy := leftDst.Min.Y + y
+ l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
+ r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
+ rgbScore += pixelDiff(l, r)
+ _, _, lb, _ := l.RGBA()
+ _, _, rb, _ := r.RGBA()
+ textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8)))
+ }
+ }
+ }
+ for row := 0; row < gridSize-1; row++ {
+ for col := 0; col < gridSize; col++ {
+ topIdx := row*gridSize + col
+ bottomIdx := (row+1)*gridSize + col
+ topDst := sliderTileRect(bounds, gridSize, topIdx)
+ bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
+ topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
+ bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
+ w := topDst.Dx()
+ if bottomDst.Dx() < w {
+ w = bottomDst.Dx()
+ }
+ for x := 0; x < w; x++ {
+ xx := topDst.Min.X + x
+ t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
+ b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
+ rgbScore += pixelDiff(t, b)
+ _, _, tb, _ := t.RGBA()
+ _, _, bb, _ := b.RGBA()
+ textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8)))
+ }
+ }
+ }
+ return rgbScore, textScore
+}
+
+func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
+ dx := dstRect.Dx()
+ if dx < 1 {
+ dx = 1
+ }
+ dy := dstRect.Dy()
+ if dy < 1 {
+ dy = 1
+ }
+ sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
+ sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
+ return img.At(sx, sy)
+}
+
+func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
+ c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY)
+ r, g, b, _ := c.RGBA()
+ y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
+ return uint8(y)
+}
+
+func absFloatV2(v float64) float64 {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
+
+func absIntV2(v int) int {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
+
+func buildSliderCursorV2(candidateIndex int, candidateCount int) string {
+ if candidateCount <= 0 {
+ return "[]"
+ }
+ if candidateIndex < 1 {
+ candidateIndex = 1
+ }
+ if candidateIndex > candidateCount {
+ candidateIndex = candidateCount
+ }
+
+ type cursorPoint struct {
+ X int `json:"x"`
+ Y int `json:"y"`
+ }
+
+ startX := 570 + mathrand.Intn(40)
+ startY := 875 + mathrand.Intn(30)
+
+ denom := candidateCount - 1
+ if denom < 1 {
+ denom = 1
+ }
+ baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
+ targetX := baseTargetX + mathrand.Intn(10) - 5
+ targetY := 655 + mathrand.Intn(14)
+
+ points := make([]cursorPoint, 0, 28)
+
+ for i := 0; i < 1+mathrand.Intn(3); i++ {
+ points = append(points, cursorPoint{
+ X: startX + mathrand.Intn(5) - 2,
+ Y: startY + mathrand.Intn(5) - 2,
+ })
+ }
+
+ transitSteps := 2 + mathrand.Intn(3)
+ arcOffX := mathrand.Intn(60) - 30
+ arcOffY := -(mathrand.Intn(30) + 10)
+ for i := 1; i <= transitSteps; i++ {
+ t := float64(i) / float64(transitSteps+1)
+ cx := float64(startX+targetX)/2 + float64(arcOffX)
+ cy := float64(startY+targetY)/2 + float64(arcOffY)
+ bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX)
+ by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY)
+ jitter := int((1-t)*8) + 2
+ points = append(points, cursorPoint{
+ X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter,
+ Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter,
+ })
+ }
+
+ approachSteps := 4 + mathrand.Intn(4)
+ prev := points[len(points)-1]
+ for i := 1; i <= approachSteps; i++ {
+ t := float64(i) / float64(approachSteps)
+ ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2
+ ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2
+ points = append(points, cursorPoint{X: ax, Y: ay})
+ }
+
+ settleCount := 3 + mathrand.Intn(5)
+ for i := 0; i < settleCount; i++ {
+ points = append(points, cursorPoint{
+ X: targetX + mathrand.Intn(7) - 3,
+ Y: targetY + mathrand.Intn(7) - 3,
+ })
+ }
+
+ data, err := json.Marshal(points)
+ if err != nil {
+ return "[]"
+ }
+ return string(data)
+}
diff --git a/pkg/clientcore/cli.go b/pkg/clientcore/cli.go
new file mode 100644
index 0000000..fd71c9e
--- /dev/null
+++ b/pkg/clientcore/cli.go
@@ -0,0 +1,66 @@
+//go:build !ios
+
+package clientcore
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+func RunCLI() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ signalChan := make(chan os.Signal, 1)
+ signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
+ go func() {
+ <-signalChan
+ log.Printf("Terminating...\n")
+ cancel()
+ select {
+ case <-signalChan:
+ case <-time.After(5 * time.Second):
+ }
+ log.Fatalf("Exit...\n")
+ }()
+
+ cfg := Config{}
+ genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
+ flag.StringVar(&cfg.TURNHost, "turn", "", "override TURN server ip")
+ flag.StringVar(&cfg.TURNPort, "port", "", "override TURN port")
+ flag.StringVar(&cfg.Listen, "listen", "127.0.0.1:9000", "listen on ip:port")
+ flag.StringVar(&cfg.VKLink, "vk-link", "", "VK calls invite link \"https://vk.com/call/join/...\"")
+ flag.StringVar(&cfg.YandexLink, "yandex-link", "", "Yandex telemost invite link \"https://telemost.yandex.ru/j/...\"")
+ flag.StringVar(&cfg.PeerAddr, "peer", "", "peer server address (host:port)")
+ flag.IntVar(&cfg.NumStreams, "n", 0, "connections to TURN (default 10 for VK, 1 for Yandex)")
+ flag.BoolVar(&cfg.UseUDP, "udp", false, "connect to TURN with UDP")
+ flag.BoolVar(&cfg.NoDTLS, "no-dtls", false, "connect without obfuscation. DO NOT USE")
+ flag.BoolVar(&cfg.VLESSMode, "vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
+ flag.BoolVar(&cfg.VLESSBond, "vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
+ flag.BoolVar(&cfg.WrapMode, "wrap", false, "WRAP mode: SRTP-like AEAD obfuscation for DTLS packets before they reach TURN ChannelData")
+ flag.StringVar(&cfg.WrapKeyHex, "wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
+ flag.IntVar(&cfg.StreamsPerCred, "streams-per-cred", streamsPerCache, "number of TURN streams sharing one VK credential cache")
+ flag.BoolVar(&cfg.Debug, "debug", false, "enable debug logging")
+ flag.BoolVar(&cfg.ManualCaptcha, "manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
+ flag.StringVar(&cfg.CaptchaSolver, "captcha-solver", "v2", "auto captcha solver implementation: v1|v2")
+ flag.StringVar(&cfg.CaptchaHost, "captcha-host", "", "manual captcha host:port to expose in addition to localhost:8765")
+ flag.Parse()
+
+ if *genWrapKey {
+ key, err := genWrapKeyHex()
+ if err != nil {
+ log.Panicf("%v", err)
+ }
+ fmt.Println(key)
+ return
+ }
+ if err := Run(ctx, cfg); err != nil {
+ log.Panicf("%v", err)
+ }
+}
diff --git a/pkg/clientcore/ish_listener_linux_386.go b/pkg/clientcore/ish_listener_linux_386.go
new file mode 100644
index 0000000..ee822f1
--- /dev/null
+++ b/pkg/clientcore/ish_listener_linux_386.go
@@ -0,0 +1,135 @@
+//go:build linux && 386
+
+package clientcore
+
+import (
+ "io"
+ "net"
+ "os"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+type ishListener struct {
+ net.Listener
+ f *os.File
+ fd int
+}
+
+// wrapISHListener overrides the standard net.Listener with a legacy syscall listener
+// designed specifically for the iSH simulator on iOS, which lacks modern `accept4`.
+func wrapISHListener(ln net.Listener) (net.Listener, error) {
+ tl, ok := ln.(*net.TCPListener)
+ if !ok {
+ return ln, nil
+ }
+ f, err := tl.File()
+ if err != nil {
+ return nil, err
+ }
+
+ // Keep a reference to *os.File so the garbage collector doesn't close the FD.
+ return &ishListener{Listener: ln, f: f, fd: int(f.Fd())}, nil
+}
+
+func (l *ishListener) Accept() (net.Conn, error) {
+ // Set the listener socket to blocking mode. Go makes it non-blocking by default.
+ // This avoids using time.Sleep in a spin-loop, which triggers futex_time64 SIGSYS in modern Go on iSH.
+ if err := syscall.SetNonblock(l.fd, false); err != nil {
+ return nil, err
+ }
+
+ for {
+ addr := make([]byte, 128)
+ addrlen := uintptr(128)
+
+ // i386 network syscalls are multiplexed via socketcall (102).
+ // SYS_ACCEPT is subcall 5.
+ args := [3]uintptr{uintptr(l.fd), uintptr(unsafe.Pointer(&addr[0])), uintptr(unsafe.Pointer(&addrlen))}
+
+ // Use Syscall6 to ensure we have enough arguments registers for the platform.
+ r1, _, errno := syscall.Syscall6(102, 5, uintptr(unsafe.Pointer(&args)), 0, 0, 0, 0)
+ if errno != 0 {
+ if errno == syscall.EINTR {
+ continue
+ }
+ return nil, errno
+ }
+
+ nfd := int(r1)
+ _ = syscall.SetsockoptInt(nfd, syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1)
+ _ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 256*1024)
+ _ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 256*1024)
+
+ // We avoid Go's net.FileConn because it tries to register the fd with Go's epoll poller,
+ // which in iSH emulator consistency fails with EEXIST (file exists).
+ // Instead, we return a custom blocking net.Conn wrapper.
+ conn := &ishConn{fd: nfd}
+ return conn, nil
+ }
+}
+
+func (l *ishListener) Close() error {
+ // Close both the duplicated FD and the original listener.
+ err1 := l.f.Close()
+ err2 := l.Listener.Close()
+ if err1 != nil {
+ return err1
+ }
+ return err2
+}
+
+// ishConn bypasses Go's network poller to prevent EEXIST bugs in iSH
+type ishConn struct {
+ fd int
+}
+
+func (c *ishConn) Read(b []byte) (n int, err error) {
+ for {
+ n, err = syscall.Read(c.fd, b)
+ if err == syscall.EINTR {
+ continue
+ }
+ if err != nil {
+ return n, err
+ }
+ if n == 0 {
+ return 0, os.ErrClosed
+ }
+ return n, nil
+ }
+}
+
+func (c *ishConn) Write(b []byte) (n int, err error) {
+ for n < len(b) {
+ written, writeErr := syscall.Write(c.fd, b[n:])
+ if writeErr == syscall.EINTR {
+ continue
+ }
+ if writeErr != nil {
+ return n, writeErr
+ }
+ if written == 0 {
+ return n, io.ErrShortWrite
+ }
+ n += written
+ }
+ return n, nil
+}
+
+func (c *ishConn) Close() error {
+ return syscall.Close(c.fd)
+}
+
+func (c *ishConn) LocalAddr() net.Addr {
+ return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9000}
+}
+
+func (c *ishConn) RemoteAddr() net.Addr {
+ return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}
+}
+
+func (c *ishConn) SetDeadline(t time.Time) error { return nil }
+func (c *ishConn) SetReadDeadline(t time.Time) error { return nil }
+func (c *ishConn) SetWriteDeadline(t time.Time) error { return nil }
diff --git a/pkg/clientcore/ish_listener_other.go b/pkg/clientcore/ish_listener_other.go
new file mode 100644
index 0000000..7facae2
--- /dev/null
+++ b/pkg/clientcore/ish_listener_other.go
@@ -0,0 +1,10 @@
+//go:build !(linux && 386)
+
+package clientcore
+
+import "net"
+
+// wrapISHListener is a no-op for architectures that don't need the legacy socketcall accept bypass.
+func wrapISHListener(ln net.Listener) (net.Listener, error) {
+ return ln, nil
+}
diff --git a/pkg/clientcore/main.go b/pkg/clientcore/main.go
new file mode 100644
index 0000000..e9ef3e3
--- /dev/null
+++ b/pkg/clientcore/main.go
@@ -0,0 +1,3194 @@
+// SPDX-FileCopyrightText: 2023 The Pion community
+// SPDX-License-Identifier: MIT
+
+package clientcore
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "math/rand"
+ "net"
+ "net/http"
+ neturl "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ fhttp "github.com/bogdanfinn/fhttp"
+ tlsclient "github.com/bogdanfinn/tls-client"
+ "github.com/bogdanfinn/tls-client/profiles"
+
+ "github.com/bschaatsbergen/dnsdialer"
+ "github.com/cacggghp/vk-turn-proxy/tcputil"
+ "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/transport/v4"
+ "github.com/pion/turn/v5"
+ "github.com/xtaci/smux"
+)
+
+type getCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error)
+
+type directNet struct{}
+
+type directDialer struct {
+ *net.Dialer
+}
+
+type directListenConfig struct {
+ *net.ListenConfig
+}
+
+// Global state trackers
+var (
+ activeLocalPeer atomic.Value
+ globalCaptchaLockout atomic.Int64
+ connectedStreams atomic.Int32
+ globalAppCancel context.CancelFunc
+ handshakeSem = make(chan struct{}, 3)
+ isDebug bool
+ manualCaptcha bool
+ autoCaptchaSliderPOC bool
+ captchaSolverVersion string
+)
+
+func debugf(format string, v ...any) {
+ if isDebug {
+ log.Printf(format, v...)
+ }
+}
+
+type captchaSolveMode int
+
+const (
+ captchaSolveModeAuto captchaSolveMode = iota
+ captchaSolveModeSliderPOC
+ captchaSolveModeManual
+)
+
+func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
+ if manualOnly {
+ return captchaSolveModeManual, attempt == 0
+ }
+
+ switch attempt {
+ case 0:
+ return captchaSolveModeAuto, true
+ case 1:
+ if enableSliderPOC {
+ return captchaSolveModeSliderPOC, true
+ }
+ return captchaSolveModeManual, true
+ case 2:
+ if enableSliderPOC {
+ return captchaSolveModeManual, true
+ }
+ }
+
+ return 0, false
+}
+
+func captchaSolveModeLabel(mode captchaSolveMode) string {
+ switch mode {
+ case captchaSolveModeAuto:
+ return "auto captcha"
+ case captchaSolveModeSliderPOC:
+ return "auto captcha slider POC"
+ case captchaSolveModeManual:
+ return "manual captcha"
+ default:
+ return "captcha"
+ }
+}
+
+type UDPPacket struct {
+ Data []byte
+ N int
+}
+
+var packetPool = sync.Pool{
+ New: func() any { return &UDPPacket{Data: make([]byte, 2048)} },
+}
+
+type throughputStats struct {
+ tx atomic.Uint64
+ rx atomic.Uint64
+}
+
+func (s *throughputStats) addTx(n int) {
+ if n > 0 {
+ s.tx.Add(uint64(n))
+ }
+}
+
+func (s *throughputStats) addRx(n int) {
+ if n > 0 {
+ s.rx.Add(uint64(n))
+ }
+}
+
+func (s *throughputStats) logEvery(ctx context.Context, label, txName, rxName string) {
+ if !isDebug {
+ return
+ }
+
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ var prevTx, prevRx uint64
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ tx := s.tx.Load()
+ rx := s.rx.Load()
+ deltaTx := tx - prevTx
+ deltaRx := rx - prevRx
+ prevTx = tx
+ prevRx = rx
+
+ if deltaTx == 0 && deltaRx == 0 {
+ continue
+ }
+
+ debugf(
+ "%s throughput: %s=%s %s=%s total_%s=%s total_%s=%s",
+ label,
+ txName,
+ formatBitsPerSecond(deltaTx, 5*time.Second),
+ rxName,
+ formatBitsPerSecond(deltaRx, 5*time.Second),
+ txName,
+ formatByteCount(tx),
+ rxName,
+ formatByteCount(rx),
+ )
+ }
+ }
+}
+
+func formatBitsPerSecond(bytes uint64, interval time.Duration) string {
+ if interval <= 0 {
+ interval = time.Second
+ }
+
+ bps := float64(bytes*8) / interval.Seconds()
+ if bps >= 1_000_000 {
+ return fmt.Sprintf("%.2f Mbit/s", bps/1_000_000)
+ }
+ if bps >= 1_000 {
+ return fmt.Sprintf("%.1f kbit/s", bps/1_000)
+ }
+ return fmt.Sprintf("%.0f bit/s", bps)
+}
+
+func formatByteCount(bytes uint64) string {
+ if bytes >= 1024*1024 {
+ return fmt.Sprintf("%.2f MiB", float64(bytes)/(1024*1024))
+ }
+ if bytes >= 1024 {
+ return fmt.Sprintf("%.1f KiB", float64(bytes)/1024)
+ }
+ return fmt.Sprintf("%d B", bytes)
+}
+
+type countingConn struct {
+ net.Conn
+ stats *throughputStats
+}
+
+func (c *countingConn) Read(p []byte) (int, error) {
+ n, err := c.Conn.Read(p)
+ c.stats.addRx(n)
+ return n, err
+}
+
+func (c *countingConn) Write(p []byte) (int, error) {
+ n, err := c.Conn.Write(p)
+ c.stats.addTx(n)
+ return n, err
+}
+
+func newDirectNet() transport.Net {
+ return directNet{}
+}
+
+func (directNet) ListenPacket(network string, address string) (net.PacketConn, error) {
+ return net.ListenPacket(network, address)
+}
+
+func (directNet) ListenUDP(network string, locAddr *net.UDPAddr) (transport.UDPConn, error) {
+ return net.ListenUDP(network, locAddr)
+}
+
+func (directNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
+ listener, err := net.ListenTCP(network, laddr)
+ if err != nil {
+ return nil, err
+ }
+
+ return directTCPListener{listener}, nil
+}
+
+func (directNet) Dial(network, address string) (net.Conn, error) {
+ return net.Dial(network, address)
+}
+
+func (directNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) {
+ return net.DialUDP(network, laddr, raddr)
+}
+
+func (directNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) {
+ return net.DialTCP(network, laddr, raddr)
+}
+
+func (directNet) ResolveIPAddr(network, address string) (*net.IPAddr, error) {
+ return net.ResolveIPAddr(network, address)
+}
+
+func (directNet) ResolveUDPAddr(network, address string) (*net.UDPAddr, error) {
+ return net.ResolveUDPAddr(network, address)
+}
+
+func (directNet) ResolveTCPAddr(network, address string) (*net.TCPAddr, error) {
+ return net.ResolveTCPAddr(network, address)
+}
+
+func (directNet) Interfaces() ([]*transport.Interface, error) {
+ return nil, transport.ErrNotSupported
+}
+
+func (directNet) InterfaceByIndex(index int) (*transport.Interface, error) {
+ return nil, fmt.Errorf("%w: index=%d", transport.ErrInterfaceNotFound, index)
+}
+
+func (directNet) InterfaceByName(name string) (*transport.Interface, error) {
+ return nil, fmt.Errorf("%w: %s", transport.ErrInterfaceNotFound, name)
+}
+
+func (directNet) CreateDialer(dialer *net.Dialer) transport.Dialer {
+ return directDialer{Dialer: dialer}
+}
+
+func (directNet) CreateListenConfig(listenerConfig *net.ListenConfig) transport.ListenConfig {
+ return directListenConfig{ListenConfig: listenerConfig}
+}
+
+func (d directDialer) Dial(network, address string) (net.Conn, error) {
+ return d.Dialer.Dial(network, address)
+}
+
+func (d directListenConfig) Listen(ctx context.Context, network, address string) (net.Listener, error) {
+ return d.ListenConfig.Listen(ctx, network, address)
+}
+
+func (d directListenConfig) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) {
+ return d.ListenConfig.ListenPacket(ctx, network, address)
+}
+
+type directTCPListener struct {
+ *net.TCPListener
+}
+
+func (l directTCPListener) AcceptTCP() (transport.TCPConn, error) {
+ return l.TCPListener.AcceptTCP()
+}
+
+// region Helper: HTTP Headers Injection
+
+// applyBrowserProfile applies consistent User-Agent and Client Hints to bypass WAFs
+func applyBrowserProfile(req *http.Request, profile Profile) {
+ req.Header.Set("User-Agent", profile.UserAgent)
+ req.Header.Set("sec-ch-ua", profile.SecChUa)
+ req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
+ req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
+ req.Header.Set("DNT", "1")
+}
+
+func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
+ req.Header.Set("User-Agent", profile.UserAgent)
+ req.Header.Set("sec-ch-ua", profile.SecChUa)
+ req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
+ req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
+ req.Header.Set("DNT", "1")
+}
+
+func generateBrowserFp(profile Profile) string {
+ // Fallback logic for generating a fingerprint if no saved profile is available.
+ // This uses a simple MD5 hash of UA and a fixed resolution.
+ data := profile.UserAgent + profile.SecChUa + "1536x864x24"
+ h := md5.Sum([]byte(data))
+ return hex.EncodeToString(h[:])
+}
+
+/*
+func generateFakeCursor() string {
+ startX := 600 + rand.Intn(400)
+ startY := 300 + rand.Intn(200)
+ startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000)
+ var points []string
+ for i := 0; i < 15+rand.Intn(10); i++ {
+ startX += rand.Intn(15) - 5
+ startY += rand.Intn(15) + 2
+ startTime += int64(rand.Intn(40) + 10)
+ points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime))
+ }
+ return "[" + strings.Join(points, ",") + "]"
+}
+
+// generateCheckboxCursor simulates a mouse moving from a random starting position
+// towards the VK captcha checkbox area, decelerating as it approaches the target.
+// This looks more like a real click than either a stationary cursor or pure random jitter.
+func generateCheckboxCursor() string {
+ type point struct {
+ X int `json:"x"`
+ Y int `json:"y"`
+ T int64 `json:"t"`
+ }
+
+ // Target is roughly where VK renders the checkbox
+ targetX := 290 + rand.Intn(20) - 10
+ targetY := 437 + rand.Intn(10) - 5
+
+ // Starting position: somewhere to the upper-right of the checkbox
+ startX := targetX + 200 + rand.Intn(300)
+ startY := targetY - 80 - rand.Intn(120)
+
+ steps := 14 + rand.Intn(6)
+ startTime := time.Now().Add(-time.Duration(400+rand.Intn(600)) * time.Millisecond).UnixMilli()
+
+ points := make([]point, 0, steps)
+ for i := 0; i < steps; i++ {
+ // Ease-out: fast at start, slow near target
+ t := float64(i) / float64(steps-1)
+ ease := 1 - (1-t)*(1-t)
+ x := startX + int(float64(targetX-startX)*ease) + rand.Intn(5) - 2
+ y := startY + int(float64(targetY-startY)*ease) + rand.Intn(5) - 2
+ dt := int64(15 + rand.Intn(25) + int(20*t)) // slower near target
+ startTime += dt
+ points = append(points, point{X: x, Y: y, T: startTime})
+ }
+
+ data, err := json.Marshal(points)
+ if err != nil {
+ return "[]"
+ }
+ return string(data)
+}
+*/
+
+func getCustomNetDialer() net.Dialer {
+ return net.Dialer{
+ Timeout: 20 * time.Second,
+ KeepAlive: 30 * time.Second,
+ Resolver: &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ var d net.Dialer
+ dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
+ var lastErr error
+ for _, dns := range dnsServers {
+ conn, err := d.DialContext(ctx, "udp", dns)
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ return nil, lastErr
+ },
+ },
+ }
+}
+
+// endregion
+
+// region Automatic Captcha Solver & Authentication
+
+type VkCaptchaError struct {
+ ErrorCode int
+ ErrorMsg string
+ CaptchaSid string
+ CaptchaImg string
+ RedirectURI string
+ IsSoundCaptchaAvailable bool
+ SessionToken string
+ CaptchaTs string
+ CaptchaAttempt string
+}
+
+func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
+ // Extract error_code
+ codeFloat, ok := errData["error_code"].(float64)
+ if !ok {
+ log.Printf("missing error_code in captcha error data")
+ return nil
+ }
+ code := int(codeFloat)
+
+ // Extract redirect_uri
+ RedirectURI, ok := errData["redirect_uri"].(string)
+ if !ok {
+ log.Printf("missing redirect_uri in captcha error data")
+ return nil
+ }
+
+ // Extract captcha_sid
+ captchaSid, ok := errData["captcha_sid"].(string)
+ if !ok {
+ // try numeric
+ if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
+ captchaSid = fmt.Sprintf("%.0f", sidNum)
+ } else {
+ log.Printf("missing captcha_sid in captcha error data")
+ return nil
+ }
+ }
+
+ // Extract captcha_img
+ captchaImg, ok := errData["captcha_img"].(string)
+ if !ok {
+ log.Printf("missing captcha_img in captcha error data")
+ return nil
+ }
+
+ // Extract error_msg
+ errorMsg, ok := errData["error_msg"].(string)
+ if !ok {
+ log.Printf("missing error_msg in captcha error data")
+ return nil
+ }
+
+ // Extract session token
+ var sessionToken string
+ if RedirectURI != "" {
+ if parsed, err := neturl.Parse(RedirectURI); err == nil {
+ sessionToken = parsed.Query().Get("session_token")
+ } else {
+ log.Printf("failed to parse redirect_uri: %v", err)
+ return nil
+ }
+ }
+ // Fallback to top-level session_token field if not in redirect_uri
+ if sessionToken == "" {
+ if st, stOk := errData["session_token"].(string); stOk {
+ sessionToken = st
+ }
+ }
+
+ // Extract is_sound_captcha_available
+ isSound, ok := errData["is_sound_captcha_available"].(bool)
+ if !ok {
+ isSound = false
+ }
+
+ // Extract captcha_ts
+ var captchaTs string
+ if tsFloat, ok := errData["captcha_ts"].(float64); ok {
+ captchaTs = fmt.Sprintf("%.0f", tsFloat)
+ } else if tsStr, ok := errData["captcha_ts"].(string); ok {
+ captchaTs = tsStr
+ }
+
+ // Extract captcha_attempt
+ var captchaAttempt string
+ if attFloat, ok := errData["captcha_attempt"].(float64); ok {
+ captchaAttempt = fmt.Sprintf("%.0f", attFloat)
+ } else if attStr, ok := errData["captcha_attempt"].(string); ok {
+ captchaAttempt = attStr
+ }
+
+ // Build VkCaptchaError
+ return &VkCaptchaError{
+ ErrorCode: code,
+ ErrorMsg: errorMsg,
+ CaptchaSid: captchaSid,
+ CaptchaImg: captchaImg,
+ RedirectURI: RedirectURI,
+ IsSoundCaptchaAvailable: isSound,
+ SessionToken: sessionToken,
+ CaptchaTs: captchaTs,
+ CaptchaAttempt: captchaAttempt,
+ }
+}
+
+func (e *VkCaptchaError) IsCaptchaError() bool {
+ return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != ""
+}
+
+func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) {
+ if useSliderPOC {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID)
+ } else {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID)
+ }
+
+ if captchaErr.SessionToken == "" {
+ return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
+ }
+ if captchaErr.RedirectURI == "" {
+ return "", fmt.Errorf("no redirect_uri for auto-solve")
+ }
+
+ // Try to load saved profile from disk
+ var savedProfile *SavedProfile
+ if sp, err := LoadProfileFromDisk(); err == nil {
+ log.Printf("[STREAM %d] [Captcha] Using saved real browser profile", streamID)
+ savedProfile = sp
+ profile = sp.Profile // Use saved headers/UA
+ }
+
+ if !useSliderPOC && !strings.EqualFold(captchaSolverVersion, "v1") {
+ successToken, v2Err := solveVkCaptchaV2(ctx, captchaErr, streamID, client, profile, savedProfile)
+ if v2Err == nil {
+ log.Printf("[STREAM %d] [Captcha] v2 solver succeeded", streamID)
+ return successToken, nil
+ }
+ if errors.Is(v2Err, errCaptchaV2RateLimit) {
+ return "", v2Err
+ }
+ log.Printf("[STREAM %d] [Captcha] v2 solver failed, falling back to legacy solver: %v", streamID, v2Err)
+ }
+
+ bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
+ if err != nil {
+ return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
+ }
+
+ log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
+
+ hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
+ log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash)
+
+ var successToken string
+ if useSliderPOC {
+ successToken, err = callCaptchaNotRobotWithSliderPOC(
+ ctx,
+ captchaErr.SessionToken,
+ hash,
+ streamID,
+ client,
+ profile,
+ bootstrap.Settings,
+ savedProfile, // Pass savedProfile if available
+ )
+ } else {
+ successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile, savedProfile)
+ }
+ if err != nil {
+ return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
+ }
+
+ log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID)
+ return successToken, nil
+}
+
+func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) {
+ parsedURL, err := neturl.Parse(redirectURI)
+ if err != nil {
+ return nil, err
+ }
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Sec-Fetch-Site", "none")
+ req.Header.Set("Sec-Fetch-Mode", "navigate")
+ req.Header.Set("Sec-Fetch-Dest", "document")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return parseCaptchaBootstrapHTML(string(body))
+}
+
+func solvePoW(powInput string, difficulty int) string {
+ target := strings.Repeat("0", difficulty)
+ for nonce := 1; nonce <= 10000000; nonce++ {
+ data := powInput + strconv.Itoa(nonce)
+ hash := sha256.Sum256([]byte(data))
+ hexHash := hex.EncodeToString(hash[:])
+ if strings.HasPrefix(hexHash, target) {
+ return hexHash
+ }
+ }
+ return ""
+}
+
+func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile, savedProfile *SavedProfile) (string, error) {
+ vkReq := func(method string, postData string) (map[string]interface{}, error) {
+ reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
+ parsedURL, err := neturl.Parse(reqURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse request URL: %w", err)
+ }
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Origin", "https://api.vk.ru")
+ req.Header.Set("Referer", fmt.Sprintf("https://api.vk.ru/not_robot_captcha?domain=vk.com&session_token=%s&variant=popup&blank=1", sessionToken))
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+
+ httpResp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(httpResp.Body)
+
+ 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
+ }
+
+ adFpBytes := make([]byte, 16)
+ for i := range adFpBytes {
+ adFpBytes[i] = byte(rand.Intn(256))
+ }
+ adFp := base64.RawURLEncoding.EncodeToString(adFpBytes)[:21]
+
+ baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=%s&access_token=", neturl.QueryEscape(sessionToken), neturl.QueryEscape(adFp))
+
+ log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
+ if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
+ return "", fmt.Errorf("settings failed: %w", err)
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
+ browserFp := generateBrowserFp(profile)
+ deviceJSON := buildCaptchaDeviceJSON(profile)
+ if savedProfile != nil {
+ browserFp = savedProfile.BrowserFp
+ deviceJSON = savedProfile.DeviceJSON
+ }
+ componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
+
+ if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
+ return "", fmt.Errorf("componentDone failed: %w", err)
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
+ // The real browser sends an empty array for cursor on the first check.
+ cursorJSON := "[]"
+ answer := base64.StdEncoding.EncodeToString([]byte("{}"))
+
+ // The real browser sends a static SHA-256 hash for debug_info.
+ // We use the exact one captured from the real browser's session.
+ debugInfo := "f3ef768dab7a20f574c6461f34e4257894d2a3c30a53d8727a3edaf7ab70847d"
+
+ connectionRtt := "[250,250,250,250,250]"
+ connectionDownlink := "[1.45,1.45,1.45,1.45,1.45]"
+
+ checkData := baseParams + fmt.Sprintf(
+ "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s",
+ neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), neturl.QueryEscape("[]"),
+ neturl.QueryEscape(cursorJSON), neturl.QueryEscape("[]"), neturl.QueryEscape(connectionRtt),
+ neturl.QueryEscape(connectionDownlink),
+ browserFp, hash, answer, debugInfo,
+ )
+
+ checkResp, err := vkReq("captchaNotRobot.check", checkData)
+ if err != nil {
+ return "", fmt.Errorf("check failed: %w", err)
+ }
+
+ respObj, ok := checkResp["response"].(map[string]interface{})
+ if !ok {
+ return "", fmt.Errorf("invalid check response: %v", checkResp)
+ }
+ status, ok := respObj["status"].(string)
+ if !ok || status != "OK" {
+ return "", fmt.Errorf("check status: %s", status)
+ }
+ successToken, ok := respObj["success_token"].(string)
+ if !ok || successToken == "" {
+ return "", fmt.Errorf("success_token not found")
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", streamID)
+ _, err = vkReq("captchaNotRobot.endSession", baseParams)
+ if err != nil {
+ log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", streamID, err)
+ }
+
+ return successToken, nil
+}
+
+// endregion
+
+// region VK Credentials Layer
+
+type VKCredentials struct {
+ ClientID string
+ ClientSecret string
+}
+
+var vkCredentialsList = []VKCredentials{
+ {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
+ {ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
+ {ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID
+ {ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID
+ {ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP
+}
+
+type TurnCredentials struct {
+ Username string
+ Password string
+ ServerAddrs []string
+ ExpiresAt time.Time
+ Link string
+}
+
+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
+ turnServerCooldown = 30 * time.Second
+)
+
+var streamsPerCache = 10
+
+func getCacheID(streamID int) int {
+ return streamID / streamsPerCache
+}
+
+func vkDelayRandom(minMs, maxMs int) {
+ ms := minMs + rand.Intn(maxMs-minMs+1)
+ time.Sleep(time.Duration(ms) * time.Millisecond)
+}
+
+var credentialsStore = struct {
+ mu sync.RWMutex
+ caches map[int]*StreamCredentialsCache
+}{
+ caches: make(map[int]*StreamCredentialsCache),
+}
+
+var streamServerOffsets sync.Map // map[int]*atomic.Uint64
+var turnServerCooldowns sync.Map // map[string]*atomic.Int64
+
+func streamServerOffset(streamID int) *atomic.Uint64 {
+ v, _ := streamServerOffsets.LoadOrStore(streamID, &atomic.Uint64{})
+ offset, ok := v.(*atomic.Uint64)
+ if !ok {
+ panic(fmt.Sprintf("unexpected streamServerOffsets value type: %T", v))
+ }
+ return offset
+}
+
+func turnServerCooldownUntil(addr string) *atomic.Int64 {
+ v, _ := turnServerCooldowns.LoadOrStore(addr, &atomic.Int64{})
+ until, ok := v.(*atomic.Int64)
+ if !ok {
+ panic(fmt.Sprintf("unexpected turnServerCooldowns value type: %T", v))
+ }
+ return until
+}
+
+func getStreamServerOffset(streamID int) uint64 {
+ return streamServerOffset(streamID).Load()
+}
+
+func rotateStreamServer(streamID int) uint64 {
+ return streamServerOffset(streamID).Add(1)
+}
+
+func pickStreamServerAddr(streamID int, addrs []string) string {
+ start := (uint64(streamID) + getStreamServerOffset(streamID)) % uint64(len(addrs))
+ for i := uint64(0); i < uint64(len(addrs)); i++ {
+ idx := (start + i) % uint64(len(addrs))
+ addr := addrs[idx]
+ if isTURNServerAvailable(addr) {
+ return addr
+ }
+ }
+ return addrs[start]
+}
+
+func markTURNServerCooldown(addr string) {
+ turnServerCooldownUntil(addr).Store(time.Now().Add(turnServerCooldown).UnixNano())
+}
+
+func isTURNServerAvailable(addr string) bool {
+ v, ok := turnServerCooldowns.Load(addr)
+ if !ok {
+ return true
+ }
+ until, ok := v.(*atomic.Int64)
+ if !ok {
+ panic(fmt.Sprintf("unexpected turnServerCooldowns value type: %T", v))
+ }
+ return time.Now().UnixNano() >= until.Load()
+}
+
+func getStreamCache(streamID int) *StreamCredentialsCache {
+ cacheID := getCacheID(streamID)
+
+ credentialsStore.mu.RLock()
+ cache, exists := credentialsStore.caches[cacheID]
+ credentialsStore.mu.RUnlock()
+
+ if exists {
+ return cache
+ }
+
+ credentialsStore.mu.Lock()
+ defer credentialsStore.mu.Unlock()
+
+ if cache, exists = credentialsStore.caches[cacheID]; exists {
+ return cache
+ }
+
+ cache = &StreamCredentialsCache{}
+ credentialsStore.caches[cacheID] = cache
+ return cache
+}
+
+func isAuthError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := err.Error()
+ return strings.Contains(errStr, "401") ||
+ strings.Contains(errStr, "Unauthorized") ||
+ strings.Contains(errStr, "authentication") ||
+ strings.Contains(errStr, "invalid credential") ||
+ strings.Contains(errStr, "stale nonce")
+}
+
+func handleAuthError(streamID int) bool {
+ cache := getStreamCache(streamID)
+ cacheID := getCacheID(streamID)
+
+ now := time.Now().Unix()
+
+ if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) {
+ cache.errorCount.Store(0)
+ }
+
+ count := cache.errorCount.Add(1)
+ cache.lastErrorTime.Store(now)
+
+ log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors)
+
+ if count >= maxCacheErrors {
+ log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID)
+ cache.invalidate(streamID)
+ return true
+ }
+ return false
+}
+
+func (c *StreamCredentialsCache) invalidate(streamID int) {
+ c.mutex.Lock()
+ c.creds = TurnCredentials{}
+ c.mutex.Unlock()
+
+ c.errorCount.Store(0)
+ c.lastErrorTime.Store(0)
+
+ log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID)
+}
+
+func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
+ cache := getStreamCache(streamID)
+ cacheID := getCacheID(streamID)
+
+ cache.mutex.RLock()
+ if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
+ expires := time.Until(cache.creds.ExpiresAt)
+ u, p := cache.creds.Username, cache.creds.Password
+ addr := pickStreamServerAddr(streamID, cache.creds.ServerAddrs)
+ cache.mutex.RUnlock()
+ if isDebug {
+ log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)", streamID, cacheID, expires, addr)
+ }
+ return u, p, addr, nil
+ }
+ cache.mutex.RUnlock()
+
+ cache.mutex.Lock()
+ defer cache.mutex.Unlock()
+
+ // Double-check inside lock
+ if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
+ addr := pickStreamServerAddr(streamID, cache.creds.ServerAddrs)
+ return cache.creds.Username, cache.creds.Password, addr, nil
+ }
+
+ user, pass, addrs, err := fetchVkCredsSerialized(ctx, link, streamID, dialer)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddrs: addrs, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link}
+ addr := pickStreamServerAddr(streamID, addrs)
+ return user, pass, addr, nil
+}
+
+var (
+ vkRequestMu sync.Mutex
+ globalLastVkFetchTime time.Time
+)
+
+func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, []string, error) {
+ vkRequestMu.Lock()
+ defer vkRequestMu.Unlock()
+
+ // Ensure a minimum cooldown between credential requests to avoid VK rate limits
+ minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond
+ elapsed := time.Since(globalLastVkFetchTime)
+
+ if !globalLastVkFetchTime.IsZero() && elapsed < minInterval {
+ wait := minInterval - elapsed
+ log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond))
+ select {
+ case <-ctx.Done():
+ return "", "", nil, ctx.Err()
+ case <-time.After(wait):
+ }
+ }
+
+ defer func() {
+ globalLastVkFetchTime = time.Now()
+ }()
+
+ return fetchVkCreds(ctx, link, streamID, dialer)
+}
+
+func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, []string, error) {
+ // Check Global Lockout to prevent API bans
+ if time.Now().Unix() < globalCaptchaLockout.Load() {
+ return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
+ }
+
+ var lastErr error
+ jar := tlsclient.NewCookieJar()
+
+ for _, creds := range vkCredentialsList {
+ log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
+
+ user, pass, addrs, err := getTokenChain(ctx, link, streamID, creds, dialer, jar)
+
+ if err == nil {
+ log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
+ return user, pass, addrs, nil
+ }
+
+ lastErr = err
+ log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
+
+ // Hard abort on captcha/fatal conditions instead of trying next creds
+ if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") {
+ return "", "", nil, err
+ }
+
+ if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") {
+ log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID)
+ }
+ }
+
+ return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr)
+}
+
+func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, []string, error) {
+ profile := Profile{
+ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
+ SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`,
+ SecChUaMobile: "?0",
+ SecChUaPlatform: `"Windows"`,
+ }
+
+ client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
+ tlsclient.WithTimeoutSeconds(20),
+ tlsclient.WithClientProfile(profiles.Chrome_146),
+ tlsclient.WithCookieJar(jar),
+ tlsclient.WithDialer(getCustomNetDialer()),
+ )
+ if err != nil {
+ return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err)
+ }
+
+ name := generateName()
+ escapedName := neturl.QueryEscape(name)
+
+ log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
+
+ doRequest := func(data string, url string) (resp map[string]interface{}, err error) {
+ parsedURL, err := neturl.Parse(url)
+ if err != nil {
+ return nil, fmt.Errorf("parse request URL: %w", err)
+ }
+ domain := parsedURL.Hostname()
+
+ req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Host = domain
+ applyBrowserProfileFhttp(req, profile)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Origin", "https://vk.ru")
+ req.Header.Set("Referer", "https://vk.ru/")
+ req.Header.Set("Sec-Fetch-Site", "same-site")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Priority", "u=1, i")
+
+ httpResp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := httpResp.Body.Close(); closeErr != nil {
+ log.Printf("close response body: %s", closeErr)
+ }
+ }()
+
+ body, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ err = json.Unmarshal(body, &resp)
+ if err != nil {
+ return nil, err
+ }
+ return resp, nil
+ }
+
+ // Token 1
+ data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID)
+ resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token")
+ if err != nil {
+ return "", "", nil, err
+ }
+ dataMap, ok := resp["data"].(map[string]interface{})
+ if !ok {
+ return "", "", nil, fmt.Errorf("unexpected anon token response: %v", resp)
+ }
+ token1, ok := dataMap["access_token"].(string)
+ if !ok {
+ return "", "", nil, fmt.Errorf("missing access_token in response: %v", resp)
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 1 -> getCallPreview
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1)
+ _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID)
+ if err != nil {
+ log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err)
+ }
+
+ vkDelayRandom(200, 400)
+
+ // Token 2
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1)
+ urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID)
+
+ var token2 string
+ for attempt := 0; ; attempt++ {
+ resp, err = doRequest(data, urlAddr)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr {
+ captchaErr := ParseVkCaptchaError(errObj)
+ if captchaErr != nil && captchaErr.IsCaptchaError() {
+ solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC)
+ if !hasSolveMode {
+ log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1)
+
+ // Engage global lockout to protect API
+ globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
+
+ if connectedStreams.Load() == 0 {
+ log.Printf("[STREAM %d] [FATAL] 0 connected streams and captcha solve modes exhausted.", streamID)
+ return "", "", nil, fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS")
+ }
+
+ return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
+ }
+
+ var successToken string
+ var captchaKey string
+ var solveErr error
+
+ switch solveMode {
+ case captchaSolveModeAuto:
+ if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr)
+ }
+ } else {
+ solveErr = fmt.Errorf("missing fields for auto solve")
+ }
+ case captchaSolveModeSliderPOC:
+ if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true)
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr)
+ }
+ } else {
+ solveErr = fmt.Errorf("missing fields for slider POC auto solve")
+ }
+ case captchaSolveModeManual:
+ log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
+ // Use context.Background() so that a short deadline on the parent ctx
+ // (e.g. the overall auth timeout) doesn't cut the user's solve time short.
+ manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute)
+
+ type manualRes struct {
+ token string
+ key string
+ err error
+ }
+ resCh := make(chan manualRes, 1)
+
+ go func() {
+ var t, k string
+ var e error
+ if captchaErr.RedirectURI != "" {
+ t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer)
+ } else if captchaErr.CaptchaImg != "" {
+ k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg)
+ } else {
+ e = fmt.Errorf("no redirect_uri or captcha_img")
+ }
+ resCh <- manualRes{t, k, e}
+ }()
+
+ select {
+ case res := <-resCh:
+ successToken = res.token
+ captchaKey = res.key
+ solveErr = res.err
+ // Token may be present even when err != nil (e.g. srv.Shutdown
+ // timed out on iSH after the token was already received).
+ // Treat a non-empty token as success regardless of the error.
+ if successToken != "" || captchaKey != "" {
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr)
+ solveErr = nil
+ }
+ log.Printf("[STREAM %d] [Captcha] Successfully got token from browser", streamID)
+ } else if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] solveCaptchaViaProxy returned error: %v", streamID, solveErr)
+ }
+ case <-manualCtx.Done():
+ if manualCtx.Err() == context.DeadlineExceeded {
+ solveErr = fmt.Errorf("manual captcha timed out after 3m")
+ } else {
+ solveErr = fmt.Errorf("manual captcha interrupted: %w", manualCtx.Err())
+ }
+ }
+ manualCancel()
+ }
+
+ // If solving failed (auto or manual) or timed out
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr)
+
+ nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha, autoCaptchaSliderPOC)
+ if hasNextSolveMode {
+ log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode))
+ continue
+ }
+
+ // Engage global lockout to protect API
+ globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
+
+ // If we have 0 streams alive, this is fatal
+ if connectedStreams.Load() == 0 {
+ log.Printf("[STREAM %d] [FATAL] 0 connected streams and manual captcha failed/timed out.", streamID)
+ return "", "", nil, fmt.Errorf("FATAL_CAPTCHA_FAILED_NO_STREAMS")
+ }
+
+ return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
+ }
+
+ if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" {
+ captchaErr.CaptchaAttempt = "1"
+ }
+
+ if captchaKey != "" {
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
+ link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1)
+ } else {
+ data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s",
+ link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1)
+ }
+ continue
+ }
+ return "", "", nil, fmt.Errorf("VK API error: %v", errObj)
+ }
+
+ respMap, okLoop := resp["response"].(map[string]interface{})
+ if !okLoop {
+ return "", "", nil, fmt.Errorf("unexpected getAnonymousToken response: %v", resp)
+ }
+ token2, okLoop = respMap["token"].(string)
+ if !okLoop {
+ return "", "", nil, fmt.Errorf("missing token in response: %v", resp)
+ }
+ break
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 3
+ sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
+ data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData))
+ resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
+ if err != nil {
+ return "", "", nil, err
+ }
+ token3, ok := resp["session_key"].(string)
+ if !ok {
+ return "", "", nil, fmt.Errorf("missing session_key in response: %v", resp)
+ }
+
+ vkDelayRandom(100, 150)
+
+ // Token 4 -> TURN Creds
+ data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3)
+ resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ tsRaw, ok := resp["turn_server"].(map[string]interface{})
+ if !ok {
+ return "", "", nil, fmt.Errorf("missing turn_server in response: %v", resp)
+ }
+ user, ok := tsRaw["username"].(string)
+ if !ok {
+ return "", "", nil, fmt.Errorf("missing username in turn_server")
+ }
+ pass, ok := tsRaw["credential"].(string)
+ if !ok {
+ return "", "", nil, fmt.Errorf("missing credential in turn_server")
+ }
+ urlsRaw, ok := tsRaw["urls"].([]interface{})
+ if !ok || len(urlsRaw) == 0 {
+ return "", "", nil, fmt.Errorf("missing or empty urls in turn_server")
+ }
+
+ log.Printf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
+ for i, u := range urlsRaw {
+ log.Printf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
+ }
+
+ var addresses []string
+ for _, u := range urlsRaw {
+ urlStr, ok := u.(string)
+ if !ok {
+ continue
+ }
+ clean := strings.Split(urlStr, "?")[0]
+ address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
+ addresses = append(addresses, address)
+ }
+
+ if len(addresses) == 0 {
+ return "", "", nil, fmt.Errorf("no valid TURN addresses found")
+ }
+
+ return user, pass, addresses, nil
+}
+
+// endregion
+
+func getYandexCreds(link string) (string, string, string, error) {
+ 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")
+
+ profile := getRandomProfile()
+ name := generateName()
+
+ 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
+ tr := &http.Transport{
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 100,
+ IdleConnTimeout: 90 * time.Second,
+ }
+ client := &http.Client{
+ Timeout: 20 * time.Second,
+ Transport: tr,
+ }
+ defer client.CloseIdleConnections()
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ applyBrowserProfile(req, profile)
+ 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 func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Printf("close response body: %s", closeErr)
+ }
+ }()
+ if resp.StatusCode != http.StatusOK {
+ readBody, err2 := io.ReadAll(resp.Body)
+ if err2 != nil {
+ return "", "", "", fmt.Errorf("GetConference: status=%s (failed to read body: %v)", resp.Status, err2)
+ }
+ return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(readBody))
+ }
+
+ 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", profile.UserAgent)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ dialer := websocket.Dialer{}
+ var conn *websocket.Conn
+ conn, resp, err = dialer.DialContext(ctx, data.Wss, h)
+ if err != nil {
+ if resp != nil && resp.Body != nil {
+ _ = resp.Body.Close()
+ }
+ return "", "", "", fmt.Errorf("ws dial: %w", err)
+ }
+ if resp != nil && resp.Body != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ defer func() {
+ if closeErr := conn.Close(); closeErr != nil {
+ log.Printf("close websocket: %s", closeErr)
+ }
+ }()
+
+ req1 := HelloRequest{
+ UID: uuid.New().String(),
+ Hello: HelloPayload{
+ ParticipantMeta: PartMeta{
+ Name: name,
+ Role: "SPEAKER",
+ Description: "",
+ SendAudio: false,
+ SendVideo: false,
+ },
+ ParticipantAttributes: PartAttrs{
+ Name: 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: profile.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 isDebug {
+ 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)
+ }
+
+ if err := conn.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil {
+ return "", "", "", fmt.Errorf("ws set read deadline: %w", err)
+ }
+
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ return "", "", "", fmt.Errorf("ws read: %w", err)
+ }
+ if isDebug {
+ 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 {
+ return nil, err
+ }
+
+ select {
+ case handshakeSem <- struct{}{}:
+ defer func() { <-handshakeSem }()
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+
+ ctx1, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
+ dtlsConn, err := dtls.ClientWithOptions(
+ conn,
+ peer,
+ dtls.WithCertificates(certificate),
+ dtls.WithInsecureSkipVerify(true),
+ dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
+ dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
+ dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if err := dtlsConn.HandshakeContext(ctx1); err != nil {
+ return nil, err
+ }
+ return dtlsConn, nil
+}
+
+func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, inboundChan <-chan *UDPPacket, connchan chan<- net.PacketConn, okchan chan<- struct{}, streamID int) error {
+ time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond)
+
+ dtlsctx, dtlscancel := context.WithCancel(ctx)
+ defer dtlscancel()
+
+ conn1, conn2 := connutil.AsyncPacketPipe()
+ go func() {
+ for {
+ select {
+ case <-dtlsctx.Done():
+ return
+ case connchan <- conn2:
+ }
+ }
+ }()
+ dtlsConn, err1 := dtlsFunc(dtlsctx, conn1, peer)
+ if err1 != nil {
+ return fmt.Errorf("failed to connect DTLS: %s", err1)
+ }
+ defer func() {
+ if closeErr := dtlsConn.Close(); closeErr != nil {
+ log.Printf("[STREAM %d] failed to close DTLS connection: %s", streamID, closeErr)
+ }
+ log.Printf("[STREAM %d] Closed DTLS connection\n", streamID)
+ }()
+ log.Printf("[STREAM %d] Established DTLS connection!\n", streamID)
+
+ if okchan != nil {
+ go func() {
+ select {
+ case okchan <- struct{}{}:
+ case <-dtlsctx.Done():
+ }
+ }()
+ }
+
+ wg := sync.WaitGroup{}
+ wg.Add(1)
+ context.AfterFunc(dtlsctx, func() {
+ if err := dtlsConn.SetDeadline(time.Now()); err != nil {
+ log.Printf("[STREAM %d] Warning: SetDeadline failed: %v", streamID, err)
+ }
+ })
+
+ go func() {
+ defer dtlscancel()
+ for {
+ select {
+ case <-dtlsctx.Done():
+ return
+ case pkt := <-inboundChan:
+ _, _ = dtlsConn.Write(pkt.Data[:pkt.N])
+ packetPool.Put(pkt)
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer dtlscancel()
+ buf := make([]byte, 1600)
+ for {
+ n, err1 := dtlsConn.Read(buf)
+ if err1 != nil {
+ return
+ }
+
+ // Send back to the active WG client
+ if peerAddr := activeLocalPeer.Load(); peerAddr != nil {
+ if addr, ok := peerAddr.(net.Addr); ok {
+ if _, err := listenConn.WriteTo(buf[:n], addr); err != nil {
+ log.Printf("[STREAM %d] failed to forward packet to local peer: %v", streamID, err)
+ }
+ }
+ }
+ }
+ }()
+
+ wg.Wait()
+ if err := dtlsConn.SetDeadline(time.Time{}); err != nil {
+ log.Printf("[STREAM %d] Failed to clear DTLS deadline: %s", streamID, err)
+ }
+ return nil
+}
+
+type connectedUDPConn struct {
+ *net.UDPConn
+}
+
+func (c *connectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) {
+ return c.Write(p)
+}
+
+type turnParams struct {
+ host string
+ port string
+ link string
+ udp bool
+ wrapKey []byte
+ getCreds getCredsFunc
+}
+
+func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) {
+ time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond)
+ var err error
+ defer func() { c <- err }()
+ user, pass, urlTarget, err1 := turnParams.getCreds(ctx, turnParams.link, streamID)
+ if err1 != nil {
+ err = fmt.Errorf("failed to get TURN credentials: %s", err1)
+ return
+ }
+ urlhost, urlport, err1 := net.SplitHostPort(urlTarget)
+ if err1 != nil {
+ err = fmt.Errorf("failed to parse TURN server address: %s", err1)
+ return
+ }
+ if turnParams.host != "" {
+ urlhost = turnParams.host
+ }
+ if turnParams.port != "" {
+ urlport = turnParams.port
+ }
+ var turnServerAddr string
+ turnServerAddr = net.JoinHostPort(urlhost, urlport)
+ turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr)
+ if err1 != nil {
+ err = fmt.Errorf("failed to resolve TURN server address: %s", err1)
+ return
+ }
+ turnServerAddr = turnServerUDPAddr.String()
+ debugf("[STREAM %d] TURN server IP: %s", streamID, turnServerUDPAddr.IP)
+ var cfg *turn.ClientConfig
+ var turnConn net.PacketConn
+ var d net.Dialer
+ ctx1, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if turnParams.udp {
+ conn, err2 := net.DialUDP("udp", nil, turnServerUDPAddr) // nolint: noctx
+ if err2 != nil {
+ err = fmt.Errorf("failed to connect to TURN server: %s", err2)
+ return
+ }
+ defer func() {
+ if err1 = conn.Close(); err1 != nil {
+ err = fmt.Errorf("failed to close TURN server connection: %s", err1)
+ return
+ }
+ }()
+ turnConn = &connectedUDPConn{conn}
+ } else {
+ conn, err2 := d.DialContext(ctx1, "tcp", turnServerAddr)
+ if err2 != nil {
+ err = fmt.Errorf("failed to connect to TURN server: %s", err2)
+ return
+ }
+ defer func() {
+ if err1 = conn.Close(); err1 != nil {
+ err = fmt.Errorf("failed to close TURN server connection: %s", err1)
+ return
+ }
+ }()
+ turnConn = turn.NewSTUNConn(conn)
+ }
+ var addrFamily turn.RequestedAddressFamily
+ if peer.IP.To4() != nil {
+ addrFamily = turn.RequestedAddressFamilyIPv4
+ } else {
+ addrFamily = turn.RequestedAddressFamilyIPv6
+ }
+
+ cfg = &turn.ClientConfig{
+ STUNServerAddr: turnServerAddr,
+ TURNServerAddr: turnServerAddr,
+ Conn: turnConn,
+ Net: newDirectNet(),
+ Username: user,
+ Password: pass,
+ RequestedAddressFamily: addrFamily,
+ LoggerFactory: logging.NewDefaultLoggerFactory(),
+ }
+
+ client, err1 := turn.NewClient(cfg)
+ if err1 != nil {
+ err = fmt.Errorf("failed to create TURN client: %s", err1)
+ return
+ }
+ defer client.Close()
+
+ err1 = client.Listen()
+ if err1 != nil {
+ err = fmt.Errorf("failed to listen: %s", err1)
+ return
+ }
+
+ relayConn, err1 := client.Allocate()
+ if err1 != nil {
+ if isAuthError(err1) {
+ handleAuthError(streamID)
+ }
+ err = fmt.Errorf("failed to allocate: %s", err1)
+ return
+ }
+
+ // Reset error count on successful allocation
+ getStreamCache(streamID).errorCount.Store(0)
+
+ // Safely track active streams globally
+ connectedStreams.Add(1)
+ defer func() {
+ connectedStreams.Add(-1)
+ if err1 := relayConn.Close(); err1 != nil {
+ err = fmt.Errorf("failed to close TURN allocated connection: %s", err1)
+ }
+ }()
+
+ if isDebug {
+ log.Printf("[STREAM %d] relayed-address=%s", streamID, relayConn.LocalAddr().String())
+ }
+
+ wg := sync.WaitGroup{}
+ wg.Add(1)
+ turnctx, turncancel := context.WithCancel(ctx)
+ defer turncancel()
+ stats := &throughputStats{}
+ go stats.logEvery(turnctx, fmt.Sprintf("[STREAM %d] TURN", streamID), "to-turn", "from-turn")
+
+ context.AfterFunc(turnctx, func() {
+ if err := relayConn.SetDeadline(time.Now()); err != nil {
+ log.Printf("Failed to set relay deadline: %s", err)
+ }
+ // Do not set conn2 deadline (conn2 can sometimes be listenConn if direct mode is used)
+ })
+ var internalPipeAddr atomic.Value
+ useWrap := len(turnParams.wrapKey) == wrapKeyLen
+ var wrapTX, wrapRX *wrapConn
+ if useWrap {
+ var wrapErr error
+ wrapTX, wrapErr = newWrapConn(turnParams.wrapKey, false)
+ if wrapErr != nil {
+ log.Printf("[STREAM %d] WRAP init failed: %v", streamID, wrapErr)
+ return
+ }
+ wrapRX, wrapErr = newWrapConn(turnParams.wrapKey, false)
+ if wrapErr != nil {
+ log.Printf("[STREAM %d] UNWRAP init failed: %v", streamID, wrapErr)
+ return
+ }
+ }
+
+ go func() {
+ defer turncancel()
+ buf := make([]byte, 1600)
+ wrapBuf := make([]byte, wrapMaxWire(len(buf)))
+ for {
+ if turnctx.Err() != nil {
+ return
+ }
+ n, addr1, err1 := conn2.ReadFrom(buf)
+ if err1 != nil {
+ return
+ }
+ if turnctx.Err() != nil {
+ return
+ }
+
+ internalPipeAddr.Store(addr1)
+
+ out := buf[:n]
+ if useWrap {
+ m, wrapErr := wrapTX.wrapInto(wrapBuf, out)
+ if wrapErr != nil {
+ log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr)
+ return
+ }
+ out = wrapBuf[:m]
+ }
+
+ written, err1 := relayConn.WriteTo(out, peer)
+ stats.addTx(written)
+ if err1 != nil {
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer turncancel()
+ readBufLen := 1600
+ if useWrap {
+ readBufLen = wrapMaxWire(readBufLen)
+ }
+ buf := make([]byte, readBufLen)
+ plain := make([]byte, 1600)
+ for {
+ n, _, err1 := relayConn.ReadFrom(buf)
+ if err1 != nil {
+ return
+ }
+ addr1 := internalPipeAddr.Load()
+ if addr1 == nil {
+ continue
+ }
+
+ if addr, ok := addr1.(net.Addr); ok {
+ payload := buf[:n]
+ if useWrap {
+ m, wrapErr := wrapRX.unwrapPacket(payload, plain)
+ if wrapErr != nil {
+ log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n)
+ continue
+ }
+ payload = plain[:m]
+ }
+ stats.addRx(len(payload))
+ if _, err := conn2.WriteTo(payload, addr); err != nil {
+ return
+ }
+ }
+ }
+ }()
+
+ wg.Wait()
+ if err := relayConn.SetDeadline(time.Time{}); err != nil {
+ log.Printf("Failed to clear relay deadline: %s", err)
+ }
+}
+
+func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, inboundChan <-chan *UDPPacket, connchan chan<- net.PacketConn, okchan chan<- struct{}, streamID int) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ err := oneDtlsConnection(ctx, peer, listenConn, inboundChan, connchan, okchan, streamID)
+ if err != nil {
+ if time.Now().Unix() < globalCaptchaLockout.Load() && strings.Contains(err.Error(), "context deadline exceeded") {
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(time.Duration(10+rand.Intn(20)) * time.Second):
+ }
+ }
+ }
+ }
+}
+
+func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case conn2 := <-connchan:
+ select {
+ case <-t:
+ case <-ctx.Done():
+ return
+ }
+ c := make(chan error)
+ go oneTurnConnection(ctx, turnParams, peer, conn2, streamID, c)
+
+ if err := <-c; err != nil {
+ if strings.Contains(err.Error(), "FATAL_CAPTCHA") {
+ log.Printf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID)
+ if globalAppCancel != nil {
+ globalAppCancel()
+ }
+ return
+ }
+ if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") {
+ if !strings.Contains(err.Error(), "global lockout active") {
+ log.Printf("[STREAM %d] Backing off for 60 seconds to avoid IP ban...", streamID)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(60 * time.Second):
+ }
+ } else {
+ lockoutEnd := globalCaptchaLockout.Load()
+ sleepDuration := time.Until(time.Unix(lockoutEnd, 0))
+ if sleepDuration < 0 {
+ sleepDuration = 5 * time.Second
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(sleepDuration):
+ }
+ }
+ } else {
+ log.Printf("[STREAM %d] %s", streamID, err)
+ time.Sleep(2 * time.Second)
+ }
+ }
+ }
+ }
+}
+
+func setupGlobalResolver() {
+ dialer := &net.Dialer{
+ Timeout: 10 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
+
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ var lastErr error
+ for _, dns := range dnsServers {
+ conn, err := dialer.DialContext(ctx, "udp", dns)
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ return nil, lastErr
+ },
+ }
+}
+
+type Config struct {
+ TURNHost string `json:"turn_host,omitempty"`
+ TURNPort string `json:"turn_port,omitempty"`
+ Listen string `json:"listen,omitempty"`
+ VKLink string `json:"vk_link,omitempty"`
+ YandexLink string `json:"yandex_link,omitempty"`
+ PeerAddr string `json:"peer_addr,omitempty"`
+ NumStreams int `json:"num_streams,omitempty"`
+ UseUDP bool `json:"use_udp,omitempty"`
+ NoDTLS bool `json:"no_dtls,omitempty"`
+ VLESSMode bool `json:"vless_mode,omitempty"`
+ VLESSBond bool `json:"vless_bond,omitempty"`
+ WrapMode bool `json:"wrap_mode,omitempty"`
+ WrapKeyHex string `json:"wrap_key_hex,omitempty"`
+ StreamsPerCred int `json:"streams_per_cred,omitempty"`
+ Debug bool `json:"debug,omitempty"`
+ ManualCaptcha bool `json:"manual_captcha,omitempty"`
+ CaptchaSolver string `json:"captcha_solver,omitempty"`
+ CaptchaHost string `json:"captcha_host,omitempty"`
+}
+
+func (cfg *Config) setDefaults() {
+ if cfg.Listen == "" {
+ cfg.Listen = "127.0.0.1:9000"
+ }
+ if cfg.StreamsPerCred <= 0 {
+ cfg.StreamsPerCred = streamsPerCache
+ }
+ if cfg.CaptchaSolver == "" {
+ cfg.CaptchaSolver = "v2"
+ }
+}
+
+func Run(ctx context.Context, cfg Config) error {
+ setupGlobalResolver()
+ cfg.setDefaults()
+ ctx, cancel := context.WithCancel(ctx)
+ globalAppCancel = cancel
+ defer cancel()
+
+ if cfg.PeerAddr == "" {
+ return fmt.Errorf("need peer address")
+ }
+ peer, err := net.ResolveUDPAddr("udp", cfg.PeerAddr)
+ if err != nil {
+ return err
+ }
+ if (cfg.VKLink == "") == (cfg.YandexLink == "") {
+ return fmt.Errorf("need either vk-link or yandex-link")
+ }
+ if cfg.WrapMode && cfg.NoDTLS {
+ return fmt.Errorf("-wrap requires DTLS; remove -no-dtls")
+ }
+ wrapKey, err := decodeWrapKey(cfg.WrapMode, cfg.WrapKeyHex)
+ if err != nil {
+ return err
+ }
+ if cfg.WrapMode {
+ log.Printf("WRAP mode enabled: peer server must use matching -wrap-key")
+ }
+ if cfg.StreamsPerCred <= 0 {
+ return fmt.Errorf("-streams-per-cred must be positive")
+ }
+ streamsPerCache = cfg.StreamsPerCred
+
+ isDebug = cfg.Debug
+ manualCaptcha = cfg.ManualCaptcha
+ captchaSolverVersion = strings.ToLower(strings.TrimSpace(cfg.CaptchaSolver))
+ if captchaSolverVersion != "v1" && captchaSolverVersion != "v2" {
+ captchaSolverVersion = "v2"
+ }
+ if captchaHostErr := setLocalCaptchaHost(cfg.CaptchaHost); captchaHostErr != nil {
+ return captchaHostErr
+ }
+ autoCaptchaSliderPOC = !manualCaptcha
+
+ var link string
+ var getCreds getCredsFunc
+ if cfg.VKLink != "" {
+ parts := strings.Split(cfg.VKLink, "join/")
+ link = parts[len(parts)-1]
+
+ dialer := dnsdialer.New(
+ dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"),
+ dnsdialer.WithStrategy(dnsdialer.Fallback{}),
+ dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour),
+ )
+
+ getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
+ return getVkCredsCached(ctx, s, streamID, dialer)
+ }
+ if cfg.NumStreams <= 0 {
+ cfg.NumStreams = 10
+ }
+ } else {
+ parts := strings.Split(cfg.YandexLink, "j/")
+ link = parts[len(parts)-1]
+ getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
+ return getYandexCreds(s)
+ }
+ if cfg.NumStreams <= 0 {
+ cfg.NumStreams = 1
+ }
+ }
+ if idx := strings.IndexAny(link, "/?#"); idx != -1 {
+ link = link[:idx]
+ }
+
+ params := &turnParams{
+ host: cfg.TURNHost,
+ port: cfg.TURNPort,
+ link: link,
+ udp: cfg.UseUDP,
+ wrapKey: wrapKey,
+ getCreds: getCreds,
+ }
+
+ if cfg.VLESSMode {
+ runVLESSMode(ctx, params, peer, cfg.Listen, cfg.NumStreams, cfg.VLESSBond)
+ return nil
+ }
+
+ listenConn, err := net.ListenPacket("udp", cfg.Listen)
+ if err != nil {
+ return fmt.Errorf("failed to listen: %w", err)
+ }
+ context.AfterFunc(ctx, func() {
+ if closeErr := listenConn.Close(); closeErr != nil {
+ log.Printf("Failed to close local connection: %s", closeErr)
+ }
+ })
+
+ numStreams := cfg.NumStreams
+ if numStreams <= 0 {
+ numStreams = 1
+ }
+
+ inboundChan := make(chan *UDPPacket, 2000)
+
+ go func() {
+ for {
+ pktIface := packetPool.Get()
+ pkt, ok := pktIface.(*UDPPacket)
+ if !ok {
+ log.Printf("packetPool returned unexpected type: %T", pktIface)
+ continue
+ }
+ nRead, addr, err := listenConn.ReadFrom(pkt.Data)
+ if err != nil {
+ return
+ }
+
+ current := activeLocalPeer.Load()
+ if current == nil {
+ activeLocalPeer.Store(addr)
+ } else if addrStr, ok := current.(net.Addr); ok {
+ if addrStr.String() != addr.String() {
+ activeLocalPeer.Store(addr)
+ }
+ } else {
+ activeLocalPeer.Store(addr)
+ }
+
+ pkt.N = nRead
+
+ select {
+ case inboundChan <- pkt:
+ default:
+ packetPool.Put(pkt)
+ }
+ }
+ }()
+
+ wg1 := sync.WaitGroup{}
+ t := time.Tick(200 * time.Millisecond)
+
+ if cfg.NoDTLS {
+ return fmt.Errorf("direct mode not supported with dispatcher")
+ }
+
+ okchan := make(chan struct{})
+ connchan := make(chan net.PacketConn)
+ wg1.Add(1)
+ go func() {
+ defer wg1.Done()
+ oneDtlsConnectionLoop(ctx, peer, listenConn, inboundChan, connchan, okchan, 0)
+ }()
+ wg1.Add(1)
+ go func() {
+ defer wg1.Done()
+ oneTurnConnectionLoop(ctx, params, peer, connchan, t, 0)
+ }()
+
+ select {
+ case <-okchan:
+ case <-ctx.Done():
+ }
+
+ for i := 1; i < numStreams; i++ {
+ cchan := make(chan net.PacketConn)
+ wg1.Add(1)
+ go func(streamID int) {
+ defer wg1.Done()
+ oneDtlsConnectionLoop(ctx, peer, listenConn, inboundChan, cchan, nil, streamID)
+ }(i)
+ wg1.Add(1)
+ go func(streamID int) {
+ defer wg1.Done()
+ oneTurnConnectionLoop(ctx, params, peer, cchan, t, streamID)
+ }(i)
+ }
+
+ wg1.Wait()
+ return nil
+}
+
+// sessionPool manages a pool of smux sessions for round-robin TCP distribution.
+type pooledSession struct {
+ id int
+ sess *smux.Session
+ active atomic.Int32
+ opened atomic.Uint64
+ closed atomic.Uint64
+ toSession atomic.Uint64
+ fromSession atomic.Uint64
+}
+
+type sessionPool struct {
+ mu sync.RWMutex
+ sessions []*pooledSession
+ counter atomic.Uint64
+ connCounter atomic.Uint64
+}
+
+func (p *sessionPool) add(id int, s *smux.Session) *pooledSession {
+ ps := &pooledSession{id: id, sess: s}
+ p.mu.Lock()
+ p.sessions = append(p.sessions, ps)
+ p.mu.Unlock()
+ return ps
+}
+
+func (p *sessionPool) remove(ps *pooledSession) {
+ p.mu.Lock()
+ for i, sess := range p.sessions {
+ if sess == ps {
+ p.sessions = append(p.sessions[:i], p.sessions[i+1:]...)
+ break
+ }
+ }
+ p.mu.Unlock()
+}
+
+func (p *sessionPool) pick() *pooledSession {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ n := len(p.sessions)
+ if n == 0 {
+ return nil
+ }
+ idx := (p.counter.Add(1) - 1) % uint64(n)
+ return p.sessions[idx]
+}
+
+func (p *sessionPool) nextConnID() uint64 {
+ return p.connCounter.Add(1)
+}
+
+func (p *sessionPool) snapshot() []*pooledSession {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ out := make([]*pooledSession, 0, len(p.sessions))
+ for _, ps := range p.sessions {
+ if !ps.sess.IsClosed() {
+ out = append(out, ps)
+ }
+ }
+ return out
+}
+
+func (p *sessionPool) count() int {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ return len(p.sessions)
+}
+
+const (
+ bondVersion = 1
+ bondMagic = "VLB1"
+
+ bondFrameData byte = 1
+ bondFrameFIN byte = 2
+
+ bondMaxChunk = 16 * 1024
+)
+
+type bondFrame struct {
+ typ byte
+ seq uint64
+ data []byte
+}
+
+type bondClientLane struct {
+ ps *pooledSession
+ stream *smux.Stream
+ mu sync.Mutex
+ dead atomic.Bool
+}
+
+func writeBondHello(w io.Writer, connID uint64, laneIndex, laneCount uint16) error {
+ var hdr [17]byte
+ copy(hdr[0:4], bondMagic)
+ hdr[4] = bondVersion
+ binary.BigEndian.PutUint64(hdr[5:13], connID)
+ binary.BigEndian.PutUint16(hdr[13:15], laneIndex)
+ binary.BigEndian.PutUint16(hdr[15:17], laneCount)
+ _, err := w.Write(hdr[:])
+ return err
+}
+
+func writeBondFrame(w io.Writer, typ byte, seq uint64, data []byte) error {
+ var hdr [13]byte
+ hdr[0] = typ
+ binary.BigEndian.PutUint64(hdr[1:9], seq)
+ binary.BigEndian.PutUint32(hdr[9:13], uint32(len(data)))
+ if _, err := w.Write(hdr[:]); err != nil {
+ return err
+ }
+ if len(data) == 0 {
+ return nil
+ }
+ _, err := w.Write(data)
+ return err
+}
+
+func readBondFrame(r io.Reader) (bondFrame, error) {
+ var hdr [13]byte
+ if _, err := io.ReadFull(r, hdr[:]); err != nil {
+ return bondFrame{}, err
+ }
+ size := binary.BigEndian.Uint32(hdr[9:13])
+ if size > 4*1024*1024 {
+ return bondFrame{}, fmt.Errorf("bond frame too large: %d", size)
+ }
+ f := bondFrame{
+ typ: hdr[0],
+ seq: binary.BigEndian.Uint64(hdr[1:9]),
+ }
+ if size > 0 {
+ f.data = make([]byte, size)
+ if _, err := io.ReadFull(r, f.data); err != nil {
+ return bondFrame{}, err
+ }
+ }
+ return f, nil
+}
+
+func closeWrite(conn net.Conn) {
+ type closeWriter interface {
+ CloseWrite() error
+ }
+ if cw, ok := conn.(closeWriter); ok {
+ if err := cw.CloseWrite(); err != nil && isDebug {
+ log.Printf("CloseWrite failed: %v", err)
+ }
+ }
+}
+
+func handleBondedTCP(ctx context.Context, tcpConn net.Conn, connID uint64, candidates []*pooledSession) {
+ defer func() { _ = tcpConn.Close() }()
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ lanes := make([]*bondClientLane, 0, len(candidates))
+ laneIDs := make([]string, 0, len(candidates))
+ for i, ps := range candidates {
+ if ps.sess.IsClosed() {
+ continue
+ }
+ stream, err := ps.sess.OpenStream()
+ if err != nil {
+ log.Printf("[bond %d] session %d open stream error: %s", connID, ps.id, err)
+ continue
+ }
+ if err := writeBondHello(stream, connID, uint16(i), uint16(len(candidates))); err != nil {
+ log.Printf("[bond %d] session %d hello error: %s", connID, ps.id, err)
+ _ = stream.Close()
+ continue
+ }
+ ps.opened.Add(1)
+ ps.active.Add(1)
+ lanes = append(lanes, &bondClientLane{ps: ps, stream: stream})
+ laneIDs = append(laneIDs, strconv.Itoa(ps.id))
+ }
+
+ if len(lanes) == 0 {
+ log.Printf("[bond %d] no usable lanes, rejecting TCP from %s", connID, tcpConn.RemoteAddr())
+ return
+ }
+ context.AfterFunc(ctx, func() {
+ now := time.Now()
+ if err := tcpConn.SetDeadline(now); err != nil && isDebug {
+ log.Printf("[bond %d] local TCP deadline error: %v", connID, err)
+ }
+ for _, lane := range lanes {
+ if err := lane.stream.SetDeadline(now); err != nil && isDebug {
+ log.Printf("[bond %d] session %d stream deadline error: %v", connID, lane.ps.id, err)
+ }
+ }
+ })
+
+ debugf("[bond %d] TCP accept from=%s lanes=%d [%s]", connID, tcpConn.RemoteAddr(), len(lanes), strings.Join(laneIDs, ","))
+ defer func() {
+ for _, lane := range lanes {
+ _ = lane.stream.Close()
+ active := lane.ps.active.Add(-1)
+ closed := lane.ps.closed.Add(1)
+ debugf("[bond %d] lane session %d close active=%d closed=%d totals: to-session=%s from-session=%s",
+ connID, lane.ps.id, active, closed,
+ formatByteCount(lane.ps.toSession.Load()), formatByteCount(lane.ps.fromSession.Load()))
+ }
+ }()
+
+ recvCh := make(chan bondFrame, 1024)
+ var readWG sync.WaitGroup
+ for _, lane := range lanes {
+ readWG.Add(1)
+ go func(l *bondClientLane) {
+ defer readWG.Done()
+ for {
+ f, err := readBondFrame(l.stream)
+ if err != nil {
+ l.dead.Store(true)
+ select {
+ case <-ctx.Done():
+ default:
+ if err != io.EOF {
+ debugf("[bond %d] session %d read frame error: %v", connID, l.ps.id, err)
+ }
+ }
+ return
+ }
+ if f.typ == bondFrameData {
+ l.ps.fromSession.Add(uint64(len(f.data)))
+ }
+ select {
+ case recvCh <- f:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }(lane)
+ }
+ go func() {
+ readWG.Wait()
+ close(recvCh)
+ }()
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ copyTCPToBond(ctx, connID, tcpConn, lanes)
+ }()
+ go func() {
+ defer wg.Done()
+ copyBondToTCP(ctx, connID, tcpConn, recvCh)
+ cancel()
+ }()
+ wg.Wait()
+}
+
+func copyTCPToBond(ctx context.Context, connID uint64, tcpConn net.Conn, lanes []*bondClientLane) {
+ buf := make([]byte, bondMaxChunk)
+ var seq uint64
+ var laneIdx uint64
+ for {
+ n, err := tcpConn.Read(buf)
+ if n > 0 {
+ data := make([]byte, n)
+ copy(data, buf[:n])
+ lane, writeErr := writeBondFrameToNextLane(ctx, lanes, bondFrameData, seq, data, &laneIdx)
+ if writeErr != nil {
+ log.Printf("[bond %d] write data error: %v", connID, writeErr)
+ return
+ }
+ lane.ps.toSession.Add(uint64(n))
+ seq++
+ }
+ if err != nil {
+ if isDebug && err != io.EOF {
+ log.Printf("[bond %d] local TCP read finished with error: %v", connID, err)
+ }
+ for _, lane := range lanes {
+ if lane.dead.Load() {
+ continue
+ }
+ lane.mu.Lock()
+ writeErr := writeBondFrame(lane.stream, bondFrameFIN, seq, nil)
+ lane.mu.Unlock()
+ if writeErr != nil && ctx.Err() == nil {
+ log.Printf("[bond %d] session %d write FIN error: %v", connID, lane.ps.id, writeErr)
+ }
+ }
+ debugf("[bond %d] upload finished chunks=%d", connID, seq)
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ }
+}
+
+func writeBondFrameToNextLane(ctx context.Context, lanes []*bondClientLane, typ byte, seq uint64, data []byte, laneIdx *uint64) (*bondClientLane, error) {
+ for attempts := 0; attempts < len(lanes); attempts++ {
+ idx := *laneIdx % uint64(len(lanes))
+ *laneIdx++
+ lane := lanes[idx]
+ if lane.dead.Load() {
+ continue
+ }
+ lane.mu.Lock()
+ err := writeBondFrame(lane.stream, typ, seq, data)
+ lane.mu.Unlock()
+ if err == nil {
+ return lane, nil
+ }
+ lane.dead.Store(true)
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ }
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ return nil, fmt.Errorf("no live bond lanes")
+}
+
+func copyBondToTCP(ctx context.Context, connID uint64, tcpConn net.Conn, recvCh <-chan bondFrame) {
+ pending := make(map[uint64][]byte)
+ var expect uint64
+ var finSeq *uint64
+
+ for {
+ if finSeq != nil && expect == *finSeq {
+ closeWrite(tcpConn)
+ debugf("[bond %d] download finished chunks=%d", connID, expect)
+ return
+ }
+
+ select {
+ case <-ctx.Done():
+ return
+ case f, ok := <-recvCh:
+ if !ok {
+ return
+ }
+ switch f.typ {
+ case bondFrameData:
+ pending[f.seq] = f.data
+ case bondFrameFIN:
+ v := f.seq
+ if finSeq == nil || v < *finSeq {
+ finSeq = &v
+ }
+ default:
+ log.Printf("[bond %d] unknown frame type %d", connID, f.typ)
+ return
+ }
+
+ for {
+ data, ok := pending[expect]
+ if !ok {
+ break
+ }
+ delete(pending, expect)
+ if len(data) > 0 {
+ if _, err := tcpConn.Write(data); err != nil {
+ log.Printf("[bond %d] local TCP write error: %v", connID, err)
+ return
+ }
+ }
+ expect++
+ }
+ }
+ }
+}
+
+// runVLESSMode implements TCP forwarding with round-robin across N TURN sessions.
+func runVLESSMode(ctx context.Context, tp *turnParams, peer *net.UDPAddr, listenAddr string, numSessions int, bond bool) {
+ pool := &sessionPool{}
+
+ // Start N session maintainers with staggered startup
+ var wgMaint sync.WaitGroup
+ for i := 0; i < numSessions; i++ {
+ wgMaint.Add(1)
+ go func(id int) {
+ defer wgMaint.Done()
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(time.Duration(id) * 300 * time.Millisecond):
+ }
+ maintainVLESSSession(ctx, tp, peer, id, pool)
+ }(i)
+ }
+
+ // Wait for at least one session
+ log.Printf("VLESS mode: waiting for sessions to connect (total: %d)...", numSessions)
+ for {
+ select {
+ case <-ctx.Done():
+ wgMaint.Wait()
+ return
+ case <-time.After(100 * time.Millisecond):
+ }
+ if pool.count() > 0 {
+ break
+ }
+ }
+
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ log.Panicf("TCP listen: %s", err)
+ }
+
+ wrappedListener, err := wrapISHListener(listener)
+ if err != nil {
+ log.Printf("Warning: failed to wrap listener: %v", err)
+ wrappedListener = listener
+ }
+
+ context.AfterFunc(ctx, func() { _ = wrappedListener.Close() })
+ if bond {
+ log.Printf("VLESS bond mode: listening on %s (striping each TCP connection across active sessions)", listenAddr)
+ } else {
+ log.Printf("VLESS mode: listening on %s (round-robin across %d sessions)", listenAddr, numSessions)
+ }
+
+ var wgConn sync.WaitGroup
+ for {
+ tcpConn, err := wrappedListener.Accept()
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ wgConn.Wait()
+ wgMaint.Wait()
+ return
+ default:
+ }
+ log.Printf("TCP accept error: %s", err)
+ continue
+ }
+
+ if bond {
+ connID := (uint64(time.Now().UnixNano()) << 16) ^ pool.nextConnID()
+ lanes := pool.snapshot()
+ if len(lanes) == 0 {
+ log.Printf("No active sessions, rejecting connection")
+ _ = tcpConn.Close()
+ continue
+ }
+
+ wgConn.Add(1)
+ go func(tc net.Conn, connID uint64, lanes []*pooledSession) {
+ defer wgConn.Done()
+ handleBondedTCP(ctx, tc, connID, lanes)
+ }(tcpConn, connID, lanes)
+ continue
+ }
+
+ ps := pool.pick()
+ if ps == nil || ps.sess.IsClosed() {
+ log.Printf("No active sessions, rejecting connection")
+ _ = tcpConn.Close()
+ continue
+ }
+
+ connID := pool.nextConnID()
+ opened := ps.opened.Add(1)
+ active := ps.active.Add(1)
+ debugf("[session %d] TCP accept #%d from=%s active=%d opened=%d pool=%d",
+ ps.id, connID, tcpConn.RemoteAddr(), active, opened, pool.count())
+
+ wgConn.Add(1)
+ go func(tc net.Conn, ps *pooledSession, connID uint64) {
+ defer wgConn.Done()
+ defer func() { _ = tc.Close() }()
+ defer func() {
+ active := ps.active.Add(-1)
+ closed := ps.closed.Add(1)
+ debugf("[session %d] TCP close #%d active=%d closed=%d totals: to-session=%s from-session=%s",
+ ps.id, connID, active, closed,
+ formatByteCount(ps.toSession.Load()), formatByteCount(ps.fromSession.Load()))
+ }()
+
+ stream, err := ps.sess.OpenStream()
+ if err != nil {
+ log.Printf("[session %d] smux open stream error for TCP #%d: %s", ps.id, connID, err)
+ return
+ }
+ defer func() { _ = stream.Close() }()
+ fromSession, toSession := pipe(ctx, tc, stream)
+ ps.fromSession.Add(uint64(fromSession))
+ ps.toSession.Add(uint64(toSession))
+ debugf("[session %d] TCP done #%d local<-session=%s local->session=%s",
+ ps.id, connID, formatByteCount(uint64(fromSession)), formatByteCount(uint64(toSession)))
+ }(tcpConn, ps, connID)
+ }
+}
+
+// maintainVLESSSession keeps one TURN+DTLS+KCP+smux session alive, reconnecting on failure.
+func maintainVLESSSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int, pool *sessionPool) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ smuxSess, cleanup, err := createSmuxSession(ctx, tp, peer, id)
+ if err != nil {
+ if shouldRotateTURNServer(err) {
+ offset := rotateStreamServer(id)
+ if addr, ok := turnSetupAddr(err); ok {
+ markTURNServerCooldown(addr)
+ debugf("[session %d] cooling down TURN server %s for %s after setup failure (offset=%d)", id, addr, turnServerCooldown, offset)
+ } else {
+ debugf("[session %d] rotating TURN server after setup failure (offset=%d)", id, offset)
+ }
+ }
+ log.Printf("[session %d] setup error: %s, retrying...", id, err)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(3 * time.Second):
+ }
+ continue
+ }
+
+ ps := pool.add(id, smuxSess)
+ log.Printf("[session %d] connected (active: %d)", id, pool.count())
+
+ for !smuxSess.IsClosed() {
+ select {
+ case <-ctx.Done():
+ pool.remove(ps)
+ cleanup()
+ return
+ case <-time.After(1 * time.Second):
+ }
+ }
+
+ pool.remove(ps)
+ cleanup()
+ log.Printf("[session %d] disconnected (active: %d), reconnecting...", id, pool.count())
+
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+type turnSetupError struct {
+ addr string
+ err error
+}
+
+func (e *turnSetupError) Error() string {
+ return e.err.Error()
+}
+
+func (e *turnSetupError) Unwrap() error {
+ return e.err
+}
+
+func turnSetupAddr(err error) (string, bool) {
+ var setupErr *turnSetupError
+ if errors.As(err, &setupErr) && setupErr.addr != "" {
+ return setupErr.addr, true
+ }
+ return "", false
+}
+
+func shouldRotateTURNServer(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := err.Error()
+ return strings.Contains(errStr, "dial TURN") ||
+ strings.Contains(errStr, "TURN allocate") ||
+ strings.Contains(errStr, "DTLS handshake")
+}
+
+// createSmuxSession establishes a full TURN+DTLS+KCP+smux pipeline and returns
+// the smux session along with a cleanup function to tear down all layers.
+func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int) (*smux.Session, func(), error) {
+ var cleanupFns []func()
+ cleanup := func() {
+ for i := len(cleanupFns) - 1; i >= 0; i-- {
+ cleanupFns[i]()
+ }
+ }
+
+ // 1. Get TURN credentials
+ user, pass, rawURL, err := tp.getCreds(ctx, tp.link, id)
+ if err != nil {
+ return nil, nil, fmt.Errorf("get TURN creds: %w", err)
+ }
+ urlhost, urlport, err := net.SplitHostPort(rawURL)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse TURN addr: %w", err)
+ }
+ if tp.host != "" {
+ urlhost = tp.host
+ }
+ if tp.port != "" {
+ urlport = tp.port
+ }
+ turnServerAddr := net.JoinHostPort(urlhost, urlport)
+ turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr)
+ if err != nil {
+ return nil, nil, fmt.Errorf("resolve TURN addr: %w", err)
+ }
+ turnServerAddr = turnServerUDPAddr.String()
+ debugf("[session %d] TURN server IP: %s", id, turnServerUDPAddr.IP)
+
+ // 2. Connect to TURN server
+ var turnConn net.PacketConn
+ ctx1, cancel1 := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel1()
+ if tp.udp {
+ c, err1 := net.DialUDP("udp", nil, turnServerUDPAddr)
+ if err1 != nil {
+ return nil, nil, &turnSetupError{addr: turnServerAddr, err: fmt.Errorf("dial TURN (udp): %w", err1)}
+ }
+ cleanupFns = append(cleanupFns, func() { _ = c.Close() })
+ turnConn = &connectedUDPConn{c}
+ } else {
+ var d net.Dialer
+ c, err1 := d.DialContext(ctx1, "tcp", turnServerAddr)
+ if err1 != nil {
+ return nil, nil, &turnSetupError{addr: turnServerAddr, err: fmt.Errorf("dial TURN (tcp): %w", err1)}
+ }
+ cleanupFns = append(cleanupFns, func() { _ = c.Close() })
+ turnConn = turn.NewSTUNConn(c)
+ }
+
+ // 3. Create TURN client and allocate relay
+ var addrFamily turn.RequestedAddressFamily
+ if peer.IP.To4() != nil {
+ addrFamily = turn.RequestedAddressFamilyIPv4
+ } else {
+ addrFamily = turn.RequestedAddressFamilyIPv6
+ }
+ cfg := &turn.ClientConfig{
+ STUNServerAddr: turnServerAddr,
+ TURNServerAddr: turnServerAddr,
+ Conn: turnConn,
+ Net: newDirectNet(),
+ Username: user,
+ Password: pass,
+ RequestedAddressFamily: addrFamily,
+ LoggerFactory: logging.NewDefaultLoggerFactory(),
+ }
+ turnClient, err := turn.NewClient(cfg)
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("create TURN client: %w", err)
+ }
+ cleanupFns = append(cleanupFns, func() { turnClient.Close() })
+ if err = turnClient.Listen(); err != nil {
+ cleanup()
+ return nil, nil, &turnSetupError{addr: turnServerAddr, err: fmt.Errorf("TURN listen: %w", err)}
+ }
+ relayConn, err := turnClient.Allocate()
+ if err != nil {
+ cleanup()
+ return nil, nil, &turnSetupError{addr: turnServerAddr, err: fmt.Errorf("TURN allocate: %w", err)}
+ }
+ cleanupFns = append(cleanupFns, func() { _ = relayConn.Close() })
+ debugf("relayed-address=%s", relayConn.LocalAddr().String())
+
+ // 4. Establish DTLS over TURN relay
+ certificate, err := selfsign.GenerateSelfSigned()
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("generate cert: %w", err)
+ }
+ dtlsPC, err := newRelayPacketConn(relayConn, peer, tp.wrapKey)
+ if err != nil {
+ cleanup()
+ return nil, nil, err
+ }
+ dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer,
+ dtls.WithCertificates(certificate),
+ dtls.WithInsecureSkipVerify(true),
+ dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
+ dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
+ dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()),
+ )
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("DTLS client create: %w", err)
+ }
+ ctx2, cancel2 := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel2()
+ if err = dtlsConn.HandshakeContext(ctx2); err != nil {
+ _ = dtlsConn.Close()
+ cleanup()
+ return nil, nil, &turnSetupError{addr: turnServerAddr, err: fmt.Errorf("DTLS handshake: %w", err)}
+ }
+ cleanupFns = append(cleanupFns, func() { _ = dtlsConn.Close() })
+ debugf("DTLS connection established")
+
+ // 5. Create KCP session over DTLS
+ statsCtx, statsCancel := context.WithCancel(ctx)
+ cleanupFns = append(cleanupFns, statsCancel)
+ stats := &throughputStats{}
+ go stats.logEvery(statsCtx, fmt.Sprintf("[session %d] VLESS", id), "to-turn", "from-turn")
+
+ kcpSess, err := tcputil.NewKCPOverDTLS(&countingConn{Conn: dtlsConn, stats: stats}, false)
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("KCP session: %w", err)
+ }
+ cleanupFns = append(cleanupFns, func() { _ = kcpSess.Close() })
+ debugf("KCP session established")
+
+ // 6. Create smux client session over KCP
+ smuxSess, err := smux.Client(kcpSess, tcputil.DefaultSmuxConfig())
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("smux client: %w", err)
+ }
+ cleanupFns = append(cleanupFns, func() { _ = smuxSess.Close() })
+ debugf("smux session established")
+
+ return smuxSess, cleanup, nil
+}
+
+// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer.
+// When wrapTX/wrapRX are set, packets are wrapped/unwrapped with SRTP-mimicry AEAD.
+type relayPacketConn struct {
+ relay net.PacketConn
+ peer net.Addr
+ wrapTX *wrapConn
+ wrapRX *wrapConn
+}
+
+func newRelayPacketConn(relay net.PacketConn, peer net.Addr, wrapKey []byte) (*relayPacketConn, error) {
+ r := &relayPacketConn{relay: relay, peer: peer}
+ if len(wrapKey) != wrapKeyLen {
+ return r, nil
+ }
+ var err error
+ r.wrapTX, err = newWrapConn(wrapKey, false)
+ if err != nil {
+ return nil, fmt.Errorf("wrap tx init: %w", err)
+ }
+ r.wrapRX, err = newWrapConn(wrapKey, false)
+ if err != nil {
+ return nil, fmt.Errorf("wrap rx init: %w", err)
+ }
+ return r, nil
+}
+
+func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
+ if r.wrapRX == nil {
+ return r.relay.ReadFrom(b)
+ }
+ buf := make([]byte, wrapMaxWire(len(b)))
+ n, addr, err := r.relay.ReadFrom(buf)
+ if err != nil {
+ return 0, addr, err
+ }
+ m, err := r.wrapRX.unwrapPacket(buf[:n], b)
+ if err != nil {
+ return 0, addr, err
+ }
+ return m, addr, nil
+}
+
+func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
+ if r.wrapTX == nil {
+ return r.relay.WriteTo(b, r.peer)
+ }
+ out := make([]byte, wrapMaxWire(len(b)))
+ n, err := r.wrapTX.wrapInto(out, b)
+ if err != nil {
+ return 0, err
+ }
+ if _, err = r.relay.WriteTo(out[:n], r.peer); err != nil {
+ return 0, err
+ }
+ return len(b), nil
+}
+
+func (r *relayPacketConn) Close() error { return r.relay.Close() }
+func (r *relayPacketConn) LocalAddr() net.Addr { return r.relay.LocalAddr() }
+func (r *relayPacketConn) SetDeadline(t time.Time) error { return r.relay.SetDeadline(t) }
+func (r *relayPacketConn) SetReadDeadline(t time.Time) error { return r.relay.SetReadDeadline(t) }
+func (r *relayPacketConn) SetWriteDeadline(t time.Time) error { return r.relay.SetWriteDeadline(t) }
+
+// pipe copies data bidirectionally between two connections.
+// It returns bytes copied as c1<-c2 and c2<-c1.
+func pipe(ctx context.Context, c1, c2 net.Conn) (int64, int64) {
+ ctx2, cancel := context.WithCancel(ctx)
+ context.AfterFunc(ctx2, func() {
+ if err := c1.SetDeadline(time.Now()); err != nil {
+ log.Printf("pipe: failed to set deadline c1: %v", err)
+ }
+ if err := c2.SetDeadline(time.Now()); err != nil {
+ log.Printf("pipe: failed to set deadline c2: %v", err)
+ }
+ })
+
+ var wg sync.WaitGroup
+ var c1FromC2 int64
+ var c2FromC1 int64
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ defer cancel()
+ n, err := io.Copy(c1, c2)
+ c1FromC2 = n
+ if err != nil {
+ if isDebug {
+ log.Printf("pipe: c1<-c2 copy error: %v", err)
+ }
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ defer cancel()
+ n, err := io.Copy(c2, c1)
+ c2FromC1 = n
+ if err != nil {
+ if isDebug {
+ log.Printf("pipe: c2<-c1 copy error: %v", err)
+ }
+ }
+ }()
+ wg.Wait()
+ if err := c1.SetDeadline(time.Time{}); err != nil {
+ if isDebug {
+ log.Printf("pipe: failed to reset deadline c1: %v", err)
+ }
+ }
+ if err := c2.SetDeadline(time.Time{}); err != nil {
+ if isDebug {
+ log.Printf("pipe: failed to reset deadline c2: %v", err)
+ }
+ }
+ return c1FromC2, c2FromC1
+}
diff --git a/client/main_test.go b/pkg/clientcore/main_test.go
similarity index 98%
rename from client/main_test.go
rename to pkg/clientcore/main_test.go
index e0f2d77..c71efd6 100644
--- a/client/main_test.go
+++ b/pkg/clientcore/main_test.go
@@ -1,4 +1,4 @@
-package main
+package clientcore
import "testing"
diff --git a/client/manual_captcha.go b/pkg/clientcore/manual_captcha.go
similarity index 58%
rename from client/manual_captcha.go
rename to pkg/clientcore/manual_captcha.go
index 826478c..588e2a7 100644
--- a/client/manual_captcha.go
+++ b/pkg/clientcore/manual_captcha.go
@@ -1,4 +1,4 @@
-package main
+package clientcore
import (
"bytes"
@@ -14,6 +14,7 @@ import (
"net/http/httputil"
neturl "net/url"
"os/exec"
+ "regexp"
"runtime"
"strings"
"time"
@@ -23,28 +24,79 @@ import (
const captchaListenPort = "8765"
+var customCaptchaHost string
+
type browserCommand struct {
name string
args []string
}
+func setLocalCaptchaHost(host string) error {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ customCaptchaHost = ""
+ return nil
+ }
+ if strings.Contains(host, "://") {
+ return fmt.Errorf("-captcha-host must be host:port without scheme")
+ }
+ hostname, port, err := net.SplitHostPort(host)
+ if err != nil {
+ return fmt.Errorf("-captcha-host must be host:port: %w", err)
+ }
+ if hostname == "" || port == "" {
+ return fmt.Errorf("-captcha-host must include both host and port")
+ }
+
+ u := &neturl.URL{Scheme: "http", Host: host}
+ if u.String() == "" {
+ return fmt.Errorf("-captcha-host is invalid")
+ }
+ customCaptchaHost = host
+ return nil
+}
+
+func localCaptchaHost() string {
+ if customCaptchaHost != "" {
+ return customCaptchaHost
+ }
+ return "localhost:" + captchaListenPort
+}
+
func localCaptchaOrigin() string {
- return "http://localhost:" + captchaListenPort
+ return (&neturl.URL{Scheme: "http", Host: localCaptchaHost()}).String()
}
func localCaptchaListenAddrs() []string {
- return []string{
+ addrs := []string{
"127.0.0.1:" + captchaListenPort,
"[::1]:" + captchaListenPort,
}
+ if customCaptchaHost != "" {
+ addrs = appendUniqueFold(addrs, customCaptchaHost)
+ }
+ return addrs
}
func localCaptchaHosts() []string {
- return []string{
+ hosts := []string{
"localhost:" + captchaListenPort,
"127.0.0.1:" + captchaListenPort,
"[::1]:" + captchaListenPort,
}
+ if customCaptchaHost != "" {
+ hosts = appendUniqueFold(hosts, customCaptchaHost)
+ }
+ return hosts
+}
+
+func appendUniqueFold(values []string, value string) []string {
+ for _, existing := range values {
+ if strings.EqualFold(existing, value) {
+ return values
+ }
+ }
+ return append(values, value)
}
func isLocalCaptchaHost(host string) bool {
@@ -59,7 +111,7 @@ func isLocalCaptchaHost(host string) bool {
func localCaptchaURLForTarget(targetURL *neturl.URL) string {
localURL := &neturl.URL{
Scheme: "http",
- Host: "localhost:" + captchaListenPort,
+ Host: localCaptchaHost(),
Path: targetURL.Path,
RawPath: targetURL.RawPath,
RawQuery: targetURL.RawQuery,
@@ -164,11 +216,100 @@ func rewriteProxyCookies(header http.Header) {
}
}
+// htmlURLAttrDoubleRe matches src/href/action attributes with double-quoted absolute or protocol-relative URLs.
+var htmlURLAttrDoubleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)"((?:https?:)?//[^"]+)"`)
+
+// htmlURLAttrSingleRe matches src/href/action attributes with single-quoted absolute or protocol-relative URLs.
+var htmlURLAttrSingleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)'((?:https?:)?//[^']+)'`)
+
+// htmlScriptContentRe matches )`)
+
+// htmlStyleContentRe matches )`)
+
+// rewriteHTMLAttrsServerSide rewrites absolute and protocol-relative URLs in src/href/action
+// attributes of raw HTML. URLs matching the upstream origin are redirected to localhost;
+// all other absolute URLs are routed through /generic_proxy so that cross-domain resources
+// (st.vk.com, userapi.com, etc.) load correctly through the proxy.
+func rewriteHTMLAttrsServerSide(html string, targetURL *neturl.URL) string {
+ localOrigin := localCaptchaOrigin()
+ upstreamOrigin := targetOrigin(targetURL)
+
+ rewriteURL := func(rawURL string) string {
+ // Normalise protocol-relative URL to absolute using the upstream scheme
+ absURL := rawURL
+ if strings.HasPrefix(rawURL, "//") {
+ absURL = targetURL.Scheme + ":" + rawURL
+ }
+ if strings.HasPrefix(absURL, upstreamOrigin) {
+ return localOrigin + absURL[len(upstreamOrigin):]
+ }
+ // Already points to local proxy — leave as-is
+ if strings.HasPrefix(absURL, localOrigin) {
+ return rawURL
+ }
+ // Any other absolute URL → route through generic_proxy
+ return "/generic_proxy?proxy_url=" + neturl.QueryEscape(absURL)
+ }
+
+ var placeholders = make(map[string]string)
+
+ html = htmlScriptContentRe.ReplaceAllStringFunc(html, func(match string) string {
+ groups := htmlScriptContentRe.FindStringSubmatch(match)
+ if len(groups) < 4 {
+ return match
+ }
+ id := fmt.Sprintf("@@CONTENT_%d@@", len(placeholders))
+ placeholders[id] = groups[2]
+ return groups[1] + id + groups[3]
+ })
+
+ html = htmlStyleContentRe.ReplaceAllStringFunc(html, func(match string) string {
+ groups := htmlStyleContentRe.FindStringSubmatch(match)
+ if len(groups) < 4 {
+ return match
+ }
+ id := fmt.Sprintf("@@CONTENT_%d@@", len(placeholders))
+ placeholders[id] = groups[2]
+ return groups[1] + id + groups[3]
+ })
+
+ html = htmlURLAttrDoubleRe.ReplaceAllStringFunc(html, func(match string) string {
+ groups := htmlURLAttrDoubleRe.FindStringSubmatch(match)
+ if len(groups) < 3 {
+ return match
+ }
+ return groups[1] + `"` + rewriteURL(groups[2]) + `"`
+ })
+
+ html = htmlURLAttrSingleRe.ReplaceAllStringFunc(html, func(match string) string {
+ groups := htmlURLAttrSingleRe.FindStringSubmatch(match)
+ if len(groups) < 3 {
+ return match
+ }
+ return groups[1] + `'` + rewriteURL(groups[2]) + `'`
+ })
+
+ for id, content := range placeholders {
+ html = strings.Replace(html, id, content, 1)
+ }
+
+ return html
+}
+
func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
localOrigin := localCaptchaOrigin()
upstreamOrigin := targetOrigin(targetURL)
+
+ // Step 1: plain text replacement for the primary upstream origin
html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
+ // Step 2: rewrite all other absolute URLs in HTML attributes server-side.
+ // This is critical: the browser begins downloading
`, localOrigin, upstreamOrigin)
+ // Step 3: inject the client-side script as early as possible — at the opening tag
+ // so that XHR/fetch overrides are active before any inline