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). + + + + + Star History Chart + + 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