From f2a414b94b295b2afcabb793b22b206d7ea57c46 Mon Sep 17 00:00:00 2001 From: alexmac6574 <215134852+alexmac6574@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:47:42 +0300 Subject: [PATCH] feat: Improve stability and performance for automatic captcha solver, fallback to manual captcha --- .github/workflows/release.yml | 20 +- README.md | 115 +++++- client/main.go | 687 ++++++++++++++++++++++++---------- client/manual_captcha.go | 578 ++++++++++++++++++++++++++++ client/profiles.go | 101 +++-- go.mod | 2 +- 6 files changed, 1237 insertions(+), 266 deletions(-) create mode 100644 client/manual_captcha.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 505b1ad..3013fe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: cgo: 0 - goos: linux goarch: riscv64 - cgo: 0 + cgo: 0 - goos: darwin goarch: amd64 cgo: 0 @@ -135,7 +135,7 @@ jobs: go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/client-${GOOS}-${GOARCH}${EXT}" \ ./client - + GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/server-${GOOS}-${GOARCH}${EXT}" \ @@ -243,21 +243,21 @@ jobs: { if [ -n "$BREAKING" ]; then - echo "### Несовместимые изменения" + echo "### Breaking Changes" echo printf '%s\n' "$BREAKING" | sed 's/^/- /' echo fi if [ -n "$FEATURES" ]; then - echo "### Новые функции" + echo "### Features" echo printf '%s\n' "$FEATURES" | sed 's/^/- /' echo fi if [ -n "$FIXES" ]; then - echo "### Исправление багов" + echo "### Bug Fixes" echo printf '%s\n' "$FIXES" | sed 's/^/- /' echo @@ -298,10 +298,10 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Prepare image name id: image @@ -311,7 +311,7 @@ jobs: echo "name=ghcr.io/$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -319,7 +319,7 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ steps.image.outputs.name }} tags: | @@ -327,7 +327,7 @@ jobs: type=raw,value=${{ needs.release.outputs.tag_name }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile diff --git a/README.md b/README.md index 983e4e6..6a24c2b 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,35 @@ Только для учебных целей! ## Похожие проекты -- 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/nullcstring/turnbridge - клиент для IOS + +> [!WARNING] +> Авторы данного репозитория не несут ответственности за другие похожие проекты. + +#### 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 ВК и Яндекс Телемоста +#### Android +- https://github.com/MYSOREZ/vk-turn-proxy-android - клиент для андроида +- https://github.com/WINGS-N/WINGSV - клиент для андроида с One UI, WireGuard, раздачей VPN с root +- https://github.com/kiper292/wireguard-turn-android - клиент для андроида интегрированный в WireGuard +- https://github.com/oxsidee/vkpn - клиент для андроида (кроссплатформенный Flutter) + +#### iOS +- https://github.com/nullcstring/turnbridge - клиент для iOS + +#### macOS +- https://github.com/denny4-user/vk-turn-proxy-macos-gui - клиент для macOS + + ## Настройка Нам понадобится: 1. Ссылка на действующий ВК звонок: создаём свой (нужен аккаунт вк), или гуглим `"https://vk.com/call/join/"`. Ссылка действительна вечно, если не нажимать "завершить звонок для всех" -2. Или ссыска на звонок Яндекс телемоста: `"https://telemost.yandex.ru/j/"`. Её лучше не гуглить, так как видно подключение к конференции +2. Или ссылка на звонок Яндекс телемоста: `"https://telemost.yandex.ru/j/"`. Её лучше не гуглить, так как видно подключение к конференции 3. VPS с установленным WireGuard 4. Для андроида: скачать Termux из F-Droid @@ -123,11 +137,7 @@ docker run -p 56000:56000/udp -e CONNECT_ADDR=192.168.1.10:51820 vk-turn-proxy #### Android -**Рекомендуемый способ:** -Использовать нативное Android-приложение [vk-turn-proxy-android](https://github.com/MYSOREZ/vk-turn-proxy-android). - -- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 -- **Добавляем приложение в исключения WireGuard. Нажимаем "сохранить".** +См. [клиенты](#android). **Альтернативный способ (через Termux):** @@ -145,7 +155,7 @@ termux-wake-lock termux-wake-unlock ``` -Скачиваем бинарник в локальную папку, даём права на исполнение, в команде указаана самая популярная архитектура `client-android-arm64`: +Скачиваем бинарник в локальную папку, даём права на исполнение, в команде указана самая популярная архитектура `client-android-arm64`: ```bash curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-android-arm64 && chmod +x client @@ -165,8 +175,53 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down **Если после включения VPN в терминале вылезают ошибки DNS, попробуйте в Wireguard включить VPN только для нужных приложений.** -#### IOS -- https://github.com/cacggghp/vk-turn-proxy/issues/76 +#### iOS + +См. [клиенты](#ios). + +**Альтернативный способ (через iSH Shell):** + +Скачать приложение [iSH Shell](https://apps.apple.com/ru/app/ish-shell/id1436902243): + +```bash +# Установить curl, если его нет +apk update +apk add curl +``` + +```bash +# Скачать бинарник и дать права на запуск +curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-linux-386 && chmod +x client +``` + +```bash +# Запустить клиент +./client -listen 127.0.0.1:9000 -peer :56000 -vk-link +``` + +#### +В конфиге WireGuard (WG) есть строка +``` +AllowedIPs = 0.0.0.0/0, ::0 +``` +Что означает разрешить весь интернет. + +Для данной утилиты, нужно исключить 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 +``` + +Строка со всеми исключёнными адресами ВК: +``` +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 +``` + +Утилита, где самому можно добавить ещё свои адреса в исключения: https://www.procustodibus.com/blog/2021/03/wireguard-allowedips-calculator/ + +Конфиг WG с изменённым полем `AllowedIPs` добавить в любом клиенте WG и запустить. + #### Linux В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 @@ -183,6 +238,36 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn) +#### macOS + +См. [клиенты](#macos). + +**Альтернативный способ (через Terminal):** + +- В клиентском конфиге WireGuard меняем адрес сервера на 127.0.0.1:9000, ставим MTU 1280. +- Добавляем Terminal в исключения WireGuard. Нажимаем "сохранить". + +В Terminal: + +Скачать бинарник (Apple Silicon): + +```bash +curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-darwin-arm64 && chmod +x client +``` + +Скачать бинарник (Intel): + +```bash +curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/download/client-darwin-amd64 && chmod +x client +``` + +Запустить клиент + +```bash +./client -listen 127.0.0.1:9000 -peer :56000 -vk-link +``` + + #### Windows В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 @@ -205,7 +290,7 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down Если не работает TCP, попробуйте добавить флаг `-udp`. -Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 Мбит/с для ВК) +Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 МБит/с для ВК) ## Яндекс телемост @@ -233,7 +318,7 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down ## v2ray -Вместо WireGuard можно использовать любое V2Ray-ядро которое его поддерживает (например, xray или sing-box) и любой V2Ray-клиент который использует это ядро (например, v2rayN или v2rayNG). С помощью их вы сможете добавить больше входящих интерфейсов (например, SOCKS) и реализовать точечный роутинг. +Вместо WireGuard можно использовать любое V2Ray-ядро, которое его поддерживает (например, xray или sing-box) и любой V2Ray-клиент, который использует это ядро (например, v2rayN или v2rayNG). С помощью их вы сможете добавить больше входящих интерфейсов (например, SOCKS) и реализовать точечный роутинг. Пример конфигов: diff --git a/client/main.go b/client/main.go index 07a2be4..a3f7570 100644 --- a/client/main.go +++ b/client/main.go @@ -40,7 +40,7 @@ import ( "github.com/pion/turn/v5" ) -type getCredsFunc func(string) (string, string, string, error) +type getCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error) type directNet struct{} @@ -140,23 +140,39 @@ func (l directTCPListener) AcceptTCP() (transport.TCPConn, error) { return l.TCPListener.AcceptTCP() } -// region automatic captcha solver +// region Helper: HTTP Headers Injection -type vkCaptchaError struct { - ErrorCode int - ErrorMsg string - CaptchaSid string - RedirectUri string - SessionToken string - CaptchaTs string - CaptchaAttempt string +// 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 parseVkCaptchaError(errData map[string]interface{}) *vkCaptchaError { +// 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 { codeFloat, _ := errData["error_code"].(float64) - redirectUri, _ := errData["redirect_uri"].(string) - errorMsg, _ := errData["error_msg"].(string) + code := int(codeFloat) + redirectUri, _ := errData["redirect_uri"].(string) captchaSid, _ := errData["captcha_sid"].(string) if captchaSid == "" { if sidNum, ok := errData["captcha_sid"].(float64); ok { @@ -164,6 +180,9 @@ func parseVkCaptchaError(errData map[string]interface{}) *vkCaptchaError { } } + captchaImg, _ := errData["captcha_img"].(string) + errorMsg, _ := errData["error_msg"].(string) + var sessionToken string if redirectUri != "" { if parsed, err := neturl.Parse(redirectUri); err == nil { @@ -171,6 +190,8 @@ func parseVkCaptchaError(errData map[string]interface{}) *vkCaptchaError { } } + isSound, _ := errData["is_sound_captcha_available"].(bool) + var captchaTs string if tsFloat, ok := errData["captcha_ts"].(float64); ok { captchaTs = fmt.Sprintf("%.0f", tsFloat) @@ -185,69 +206,111 @@ func parseVkCaptchaError(errData map[string]interface{}) *vkCaptchaError { captchaAttempt = attStr } - return &vkCaptchaError{ - ErrorCode: int(codeFloat), - ErrorMsg: errorMsg, - CaptchaSid: captchaSid, - RedirectUri: redirectUri, - SessionToken: sessionToken, - CaptchaTs: captchaTs, - CaptchaAttempt: captchaAttempt, + return &VkCaptchaError{ + ErrorCode: code, + ErrorMsg: errorMsg, + CaptchaSid: captchaSid, + CaptchaImg: captchaImg, + RedirectUri: redirectUri, + IsSoundCaptchaAvailable: isSound, + SessionToken: sessionToken, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, } } -func solveVkCaptcha(ctx context.Context, captchaErr *vkCaptchaError, dialer *dnsdialer.Dialer) (string, error) { - log.Printf("Solving VK Smart Captcha automatically...") +func (e *VkCaptchaError) IsCaptchaError() bool { + return e.ErrorCode == 14 +} + +func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, dialer *dnsdialer.Dialer, profile Profile) (string, error) { + log.Printf("[STREAM %d] [Captcha] Solving Not Robot Captcha...", streamID) + if captchaErr.SessionToken == "" { - return "", fmt.Errorf("no session_token in redirect_uri") + return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") + } + if captchaErr.RedirectUri == "" { + return "", fmt.Errorf("no redirect_uri for auto-solve") } - powInput, difficulty, err := fetchPowInput(ctx, captchaErr.RedirectUri, dialer) + // HAR Timing: Browser page load & user perception + time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + + powInput, difficulty, cookies, err := fetchPowInput(ctx, captchaErr.RedirectUri, streamID, dialer, profile) if err != nil { return "", fmt.Errorf("failed to fetch PoW input: %w", err) } + log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, powInput, difficulty) + hash := solvePoW(powInput, difficulty) + log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) - successToken, err := callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, dialer) + successToken, err := callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, cookies, streamID, dialer, profile) if err != nil { return "", fmt.Errorf("captchaNotRobot API failed: %w", err) } - log.Printf("VK Smart Captcha Solved Successfully!") + log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID) return successToken, nil } -func fetchPowInput(ctx context.Context, redirectUri string, dialer *dnsdialer.Dialer) (string, int, error) { +func fetchPowInput(ctx context.Context, redirectUri string, streamID int, dialer *dnsdialer.Dialer, profile Profile) (string, int, string, error) { + parsedURL, err := neturl.Parse(redirectUri) + if err != nil { + return "", 0, "", err + } + domain := parsedURL.Hostname() + req, err := http.NewRequestWithContext(ctx, "GET", redirectUri, nil) if err != nil { - return "", 0, err + return "", 0, "", err } - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + + req.Host = domain // Explicitly force Host header + applyBrowserProfile(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") client := &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{ DialContext: dialer.DialContext, + TLSClientConfig: &tls.Config{ + ServerName: domain, // Force SNI for DPI evasion + }, }, } resp, err := client.Do(req) if err != nil { - return "", 0, err + return "", 0, "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + var cookies []string + for _, setCookie := range resp.Header.Values("Set-Cookie") { + cookieParts := strings.Split(setCookie, ";") + cookies = append(cookies, strings.TrimSpace(cookieParts[0])) + } + cookieHeader := strings.Join(cookies, "; ") + if cookieHeader != "" { + log.Printf("[STREAM %d] [Captcha] Captcha page set %d cookie(s)", streamID, len(cookies)) } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", 0, err + return "", 0, "", err } html := string(body) powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) powInputMatch := powInputRe.FindStringSubmatch(html) if len(powInputMatch) < 2 { - return "", 0, fmt.Errorf("powInput not found in captcha HTML") + return "", 0, "", fmt.Errorf("powInput not found in captcha HTML") } powInput := powInputMatch[1] @@ -259,7 +322,7 @@ func fetchPowInput(ctx context.Context, redirectUri string, dialer *dnsdialer.Di difficulty = d } } - return powInput, difficulty, nil + return powInput, difficulty, cookieHeader, nil } func solvePoW(powInput string, difficulty int) string { @@ -275,22 +338,39 @@ func solvePoW(powInput string, difficulty int) string { return "" } -func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, dialer *dnsdialer.Dialer) (string, error) { +func callCaptchaNotRobot(ctx context.Context, sessionToken, hash, cookies string, streamID int, dialer *dnsdialer.Dialer, 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, _ := neturl.Parse(reqURL) + domain := parsedURL.Hostname() + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData)) if err != nil { return nil, err } - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + + req.Host = domain + applyBrowserProfile(req, profile) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Origin", "https://vk.ru") - req.Header.Set("Referer", "https://vk.ru/") + 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("Priority", "u=1, i") + + if cookies != "" { + req.Header.Set("Cookie", cookies) + } client := &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{ DialContext: dialer.DialContext, + TLSClientConfig: &tls.Config{ + ServerName: domain, // Enforce SNI for DPI evasion + }, }, } @@ -298,7 +378,9 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, dialer if err != nil { return nil, err } - defer httpResp.Body.Close() + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(httpResp.Body) body, err := io.ReadAll(httpResp.Body) if err != nil { @@ -313,32 +395,40 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, dialer baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) - // Step 1: settings + 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) - // Step 2: componentDone - browserFp := fmt.Sprintf("%032x", rand.Int63()) + // HAR Timing: settings -> componentDone + time.Sleep(100*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) + browserFp := fmt.Sprintf("%016x%016x", rand.Int63(), rand.Int63()) deviceJSON := `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1920,"innerHeight":945,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":16,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` 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) - // Step 3: check + // HAR Timing: componentDone -> check ≈ 1.95s + statEvents delay ≈ 3.2s total + time.Sleep(1950*time.Millisecond + time.Duration(rand.Intn(1250))*time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) cursorJSON := `[{"x":950,"y":500},{"x":945,"y":510},{"x":940,"y":520},{"x":938,"y":525},{"x":938,"y":525}]` answer := base64.StdEncoding.EncodeToString([]byte("{}")) debugInfo := "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785" + baseDownlink := 8.0 + rand.Float64()*4.0 + downlinkStr := fmt.Sprintf("%.1f", baseDownlink) + connectionDownlink := "[" + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "]" + 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("[]"), - neturl.QueryEscape("[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]"), + neturl.QueryEscape(connectionDownlink), browserFp, hash, answer, debugInfo, ) @@ -360,24 +450,232 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, dialer return "", fmt.Errorf("success_token not found") } - time.Sleep(200 * time.Millisecond) - - // Step 4: endSession - vkReq("captchaNotRobot.endSession", baseParams) + 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 automatic captcha solver +// endregion + +// region VK Credentials & Caching 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 +} + +var vkRequestMu sync.Mutex + +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() + 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 + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) { + expires := time.Until(cache.creds.ExpiresAt) + log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v)", streamID, cacheID, expires) + return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil + } -func getVkCreds(link string, dialer *dnsdialer.Dialer) (string, string, string, error) { + log.Printf("[STREAM %d] [VK Auth] Cache miss (cache=%d), starting credential fetch...", streamID, cacheID) + + select { + case <-ctx.Done(): + return "", "", "", ctx.Err() + default: + } + + 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, + } + + log.Printf("[STREAM %d] [VK Auth] Success! Credentials cached until %v (cache=%d)", streamID, cache.creds.ExpiresAt, cacheID) + return user, pass, addr, nil +} + +func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { + vkRequestMu.Lock() + defer vkRequestMu.Unlock() + return fetchVkCreds(ctx, link, streamID, dialer) +} + +func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { + var lastErr error + + 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) + + 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) + + 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) (string, string, string, error) { profile := getRandomProfile() name := generateName() escapedName := neturl.QueryEscape(name) - log.Printf("Connecting Identity - Name: %s | User-Agent: %s", name, profile.UserAgent) + 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, _ := neturl.Parse(url) + domain := parsedURL.Hostname() + client := &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{ @@ -385,17 +683,28 @@ func getVkCreds(link string, dialer *dnsdialer.Dialer) (string, string, string, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, DialContext: dialer.DialContext, + TLSClientConfig: &tls.Config{ + ServerName: domain, // Force SNI for DPI evasion + }, }, } defer client.CloseIdleConnections() - req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) if err != nil { return nil, err } - req.Header.Add("User-Agent", profile.UserAgent) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Host = domain + applyBrowserProfile(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 { @@ -416,25 +725,15 @@ func getVkCreds(link string, dialer *dnsdialer.Dialer) (string, string, string, if err != nil { return nil, err } - return resp, nil } - var resp map[string]interface{} - defer func() { - if r := recover(); r != nil { - log.Panicf("get TURN creds error: %v\n\n", resp) - } - }() - - data := "client_id=6287487&token_type=messages&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487" - url := "https://login.vk.ru/?act=get_anonym_token" - - resp, err := doRequest(data, url) + // 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 "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - dataMap, ok := resp["data"].(map[string]interface{}) if !ok { return "", "", "", fmt.Errorf("unexpected anon token response: %v", resp) @@ -444,42 +743,76 @@ func getVkCreds(link string, dialer *dnsdialer.Dialer) (string, string, string, return "", "", "", fmt.Errorf("missing access_token in response: %v", resp) } + vkDelayRandom(100, 200) + + // Token 1 -> getCallPreview + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) + _, _ = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) + + vkDelayRandom(500, 1000) // HAR: Delay updated + + // Token 2 data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1) - url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.274&client_id=6287487" + urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID) var token2 string const maxCaptchaAttempts = 3 for attempt := 0; attempt <= maxCaptchaAttempts; attempt++ { - resp, err = doRequest(data, url) + resp, err = doRequest(data, urlAddr) if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - // Check for captcha error if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr { - errCode, _ := errObj["error_code"].(float64) - if errCode == 14 { - if attempt == maxCaptchaAttempts { - return "", "", "", fmt.Errorf("captcha failed after %d attempts", maxCaptchaAttempts) - } - - captchaErr := parseVkCaptchaError(errObj) - if captchaErr.SessionToken != "" { - successToken, solveErr := solveVkCaptcha(context.Background(), captchaErr, dialer) + captchaErr := ParseVkCaptchaError(errObj) + if captchaErr != nil && captchaErr.IsCaptchaError() { + var successToken string + var captchaKey string + var solveErr error + + // Try automatic if possible and attempts remain + if attempt < maxCaptchaAttempts && captchaErr.SessionToken != "" && captchaErr.RedirectUri != "" { + successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, dialer, profile) if solveErr != nil { - return "", "", "", fmt.Errorf("auto captcha solve error: %w", solveErr) + log.Printf("[STREAM %d] [Captcha] Auto solve failed: %v. Falling back to manual...", streamID, solveErr) } + } else if attempt >= maxCaptchaAttempts { + log.Printf("[STREAM %d] [Captcha] Max auto attempts reached. Falling back to manual...", streamID) + solveErr = fmt.Errorf("max attempts reached") + } else { + log.Printf("[STREAM %d] [Captcha] Missing fields for auto solve. Falling back to manual...", streamID) + solveErr = fmt.Errorf("missing fields") + } - if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" { - captchaErr.CaptchaAttempt = "1" + // If auto failed, or we skipped it, drop to manual fallback + if solveErr != nil { + if captchaErr.RedirectUri != "" { + successToken, solveErr = solveCaptchaViaProxy(captchaErr.RedirectUri, dialer) + if solveErr != nil { + return "", "", "", fmt.Errorf("manual proxy captcha solve error: %w", solveErr) + } + } else if captchaErr.CaptchaImg != "" { + captchaKey, solveErr = solveCaptchaViaHTTP(captchaErr.CaptchaImg) + if solveErr != nil { + return "", "", "", fmt.Errorf("manual HTTP captcha solve error: %w", solveErr) + } + } else { + return "", "", "", fmt.Errorf("cannot solve captcha manually: no redirect_uri or captcha_img") } + } - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s", - link, escapedName, token1, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt) - continue + 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 { - return "", "", "", fmt.Errorf("old image captcha detected - not supported in auto solver") + 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) } @@ -495,40 +828,48 @@ func getVkCreds(link string, dialer *dnsdialer.Dialer) (string, string, string, break } - data = fmt.Sprintf("%s%s%s", "session_data=%7B%22version%22%3A2%2C%22device_id%22%3A%22", uuid.New(), "%22%2C%22client_version%22%3A1.1%2C%22client_type%22%3A%22SDK_JS%22%7D&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA") - url = "https://calls.okcdn.ru/fb.do" + vkDelayRandom(100, 200) - resp, err = doRequest(data, url) + // 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 "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - token3 := resp["session_key"].(string) - data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3) - url = "https://calls.okcdn.ru/fb.do" + vkDelayRandom(100, 200) - resp, err = doRequest(data, url) + // 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 "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - user := resp["turn_server"].(map[string]interface{})["username"].(string) - pass := resp["turn_server"].(map[string]interface{})["credential"].(string) - turn := resp["turn_server"].(map[string]interface{})["urls"].([]interface{})[0].(string) + ts := resp["turn_server"].(map[string]interface{}) + user := ts["username"].(string) + pass := ts["credential"].(string) + urls := ts["urls"].([]interface{}) + urlStr := urls[0].(string) - clean := strings.Split(turn, "?")[0] + clean := strings.Split(urlStr, "?")[0] address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + vkDelayRandom(4000, 5000) // HAR: Final matching delay + return user, pass, address, nil } +// endregion + func getYandexCreds(link string) (string, string, string, error) { const debug = false const telemostConfHost = "cloud-api.yandex.ru" telemostConfPath := fmt.Sprintf("%s%s%s", "/telemost_front/v2/telemost/conferences/https%3A%2F%2Ftelemost.yandex.ru%2Fj%2F", link, "/connection?next_gen_media_platform_allowed=false") + profile := getRandomProfile() - userAgent := profile.UserAgent name := generateName() type ConferenceResponse struct { @@ -657,7 +998,8 @@ func getYandexCreds(link string) (string, string, string, error) { if err != nil { return "", "", "", err } - req.Header.Set("User-Agent", userAgent) + + 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") @@ -689,7 +1031,7 @@ func getYandexCreds(link string) (string, string, string, error) { } h := http.Header{} h.Set("Origin", "https://telemost.yandex.ru") - h.Set("User-Agent", userAgent) + h.Set("User-Agent", profile.UserAgent) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -731,7 +1073,7 @@ func getYandexCreds(link string) (string, string, string, error) { SdkInfo: SdkInfo{ Implementation: "browser", Version: "5.15.0", - UserAgent: userAgent, + UserAgent: profile.UserAgent, HwConcurrency: 4, }, SdkInitializationID: uuid.New().String(), @@ -874,7 +1216,6 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa }() log.Printf("Established DTLS connection!\n") - // Trigger the okchan safely to spawn the rest of the threads if okchan != nil { go func() { select { @@ -895,7 +1236,6 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa } }) - // Start read-loop on listenConn go func() { defer wg.Done() defer dtlscancel() @@ -912,7 +1252,7 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa return } - globalClientWGAddr.Store(addr1) // store local WG peer address globally + globalClientWGAddr.Store(addr1) _, err1 = dtlsConn.Write(buf[:n]) if err1 != nil { @@ -922,7 +1262,6 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa } }() - // Start read-loop on dtlsConn go func() { defer wg.Done() defer dtlscancel() @@ -941,7 +1280,6 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa addr1, ok := globalClientWGAddr.Load().(net.Addr) if !ok { - // Safely drop packet if wireguard hasn't sent an initial packet yet continue } @@ -978,16 +1316,16 @@ type turnParams struct { getCreds getCredsFunc } -func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, c chan<- error) { +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 = nil defer func() { c <- err }() - user, pass, url, err1 := turnParams.getCreds(turnParams.link) + 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(url) + urlhost, urlport, err1 := net.SplitHostPort(urlTarget) if err1 != nil { err = fmt.Errorf("failed to parse TURN server address: %s", err1) return @@ -1006,8 +1344,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD return } turnServerAddr = turnServerUdpAddr.String() - fmt.Println(turnServerUdpAddr.IP) - // Dial TURN Server + var cfg *turn.ClientConfig var turnConn net.PacketConn var d net.Dialer @@ -1046,8 +1383,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } else { addrFamily = turn.RequestedAddressFamilyIPv6 } - // Start a new TURN Client and wrap our net.Conn in a STUNConn - // This allows us to simulate datagram based communication over a net.Conn + cfg = &turn.ClientConfig{ STUNServerAddr: turnServerAddr, TURNServerAddr: turnServerAddr, @@ -1066,18 +1402,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } defer client.Close() - // Start listening on the conn provided. err1 = client.Listen() if err1 != nil { err = fmt.Errorf("failed to listen: %s", err1) return } - // Allocate a relay socket on the TURN server. On success, it - // will return a net.PacketConn which represents the remote - // socket. relayConn, err1 := client.Allocate() if err1 != nil { + if isAuthError(err1) { + handleAuthError(streamID) + } err = fmt.Errorf("failed to allocate: %s", err1) return } @@ -1087,9 +1422,10 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } }() - // The relayConn's local address is actually the transport - // address assigned on the TURN server. - log.Printf("relayed-address=%s", relayConn.LocalAddr().String()) + // Reset error count on successful allocation + getStreamCache(streamID).errorCount.Store(0) + + log.Printf("[STREAM %d] relayed-address=%s", streamID, relayConn.LocalAddr().String()) wg := sync.WaitGroup{} wg.Add(2) @@ -1103,7 +1439,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } }) var internalPipeAddr atomic.Value - // Start read-loop on conn2 (output of DTLS) + go func() { defer wg.Done() defer turncancel() @@ -1120,7 +1456,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD return } - internalPipeAddr.Store(addr1) // store local async pipe peer + internalPipeAddr.Store(addr1) _, err1 = relayConn.WriteTo(buf[:n], peer) if err1 != nil { @@ -1130,7 +1466,6 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } }() - // Start read-loop on relayConn go func() { defer wg.Done() defer turncancel() @@ -1184,84 +1519,27 @@ func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnCha } } -func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time) { +func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) { for { select { case <-ctx.Done(): return case conn2 := <-connchan: - // Ensure we block cleanly until the tick signals to proceed select { case <-t: case <-ctx.Done(): return } c := make(chan error) - go oneTurnConnection(ctx, turnParams, peer, conn2, c) + go oneTurnConnection(ctx, turnParams, peer, conn2, streamID, c) if err := <-c; err != nil { - log.Printf("%s", err) + log.Printf("[STREAM %d] %s", streamID, err) } } } } -type turnCred struct { - user, pass, addr string -} - -// poolCreds allows retrieving unique TURN credentials for N distinct connections. -// Because it natively handles the automatic captcha bypass, every request gets a unique identity safely. -func poolCreds(f getCredsFunc, poolSize int) getCredsFunc { - var mu sync.Mutex - var pool []turnCred - var cTime time.Time - var idx int - - return func(link string) (string, string, string, error) { - mu.Lock() - defer mu.Unlock() - - // Refresh identities every 10 minutes - if !cTime.IsZero() && time.Since(cTime) > 10*time.Minute { - pool = nil - cTime = time.Time{} - } - - if len(pool) < poolSize { - u, p, a, err := f(link) - if err == nil { - pool = append(pool, turnCred{u, p, a}) - cTime = time.Now() - log.Printf("Successfully registered User Identity %d/%d", len(pool), poolSize) - - // Space out requests by 1000ms to avoid API limits - if len(pool) < poolSize { - time.Sleep(1000 * time.Millisecond) - } - - c := pool[len(pool)-1] - idx++ - return c.user, c.pass, c.addr, nil - } - - log.Printf("Failed to get unique TURN identity: %v", err) - if len(pool) > 0 { - log.Printf("Falling back to reusing a previous identity...") - c := pool[idx%len(pool)] - idx++ - return c.user, c.pass, c.addr, nil - } - return "", "", "", err - } - - c := pool[idx%len(pool)] - idx++ - return c.user, c.pass, c.addr, nil - } -} - -func main() { //nolint:cyclop - rand.Seed(time.Now().UnixNano()) +func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() signalChan := make(chan os.Signal, 1) @@ -1305,13 +1583,13 @@ func main() { //nolint:cyclop 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"), + 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(s string) (string, string, string, error) { - return getVkCreds(s, dialer) + getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) { + return getVkCredsCached(ctx, s, streamID, dialer) } if *n <= 0 { *n = 10 @@ -1319,7 +1597,9 @@ func main() { //nolint:cyclop } else { parts := strings.Split(*yalink, "j/") link = parts[len(parts)-1] - getCreds = getYandexCreds + getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) { + return getYandexCreds(s) + } if *n <= 0 { *n = 1 } @@ -1327,12 +1607,13 @@ func main() { //nolint:cyclop if idx := strings.IndexAny(link, "/?#"); idx != -1 { link = link[:idx] } + params := &turnParams{ host: *host, port: *port, link: link, udp: *udp, - getCreds: poolCreds(getCreds, *n), + getCreds: getCreds, } listenConnChan := make(chan net.PacketConn) @@ -1360,10 +1641,10 @@ func main() { //nolint:cyclop if *direct { for i := 0; i < *n; i++ { wg1.Add(1) - go func() { + go func(streamID int) { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t) - }() + oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t, streamID) + }(i) } } else { okchan := make(chan struct{}) @@ -1378,7 +1659,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) + oneTurnConnectionLoop(ctx, params, peer, connchan, t, 0) }() select { @@ -1393,10 +1674,10 @@ func main() { //nolint:cyclop oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil) }() wg1.Add(1) - go func() { + go func(streamID int) { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) - }() + oneTurnConnectionLoop(ctx, params, peer, connchan, t, streamID) + }(i + 1) } } diff --git a/client/manual_captcha.go b/client/manual_captcha.go new file mode 100644 index 0000000..204240e --- /dev/null +++ b/client/manual_captcha.go @@ -0,0 +1,578 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + neturl "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/bschaatsbergen/dnsdialer" +) + +const captchaListenPort = "8765" + +type browserCommand struct { + name string + args []string +} + +func localCaptchaOrigin() string { + return "http://localhost:" + captchaListenPort +} + +func localCaptchaListenAddrs() []string { + return []string{ + "127.0.0.1:" + captchaListenPort, + "[::1]:" + captchaListenPort, + } +} + +func localCaptchaHosts() []string { + return []string{ + "localhost:" + captchaListenPort, + "127.0.0.1:" + captchaListenPort, + "[::1]:" + captchaListenPort, + } +} + +func isLocalCaptchaHost(host string) bool { + for _, localHost := range localCaptchaHosts() { + if strings.EqualFold(host, localHost) { + return true + } + } + return false +} + +func localCaptchaURLForTarget(targetURL *neturl.URL) string { + localURL := &neturl.URL{ + Scheme: "http", + Host: "localhost:" + captchaListenPort, + Path: targetURL.Path, + RawPath: targetURL.RawPath, + RawQuery: targetURL.RawQuery, + } + if localURL.Path == "" { + localURL.Path = "/" + } + return localURL.String() +} + +func targetOrigin(targetURL *neturl.URL) string { + return targetURL.Scheme + "://" + targetURL.Host +} + +func rewriteProxyHeaderURL(raw string, targetURL *neturl.URL) string { + if raw == "" { + return raw + } + parsed, err := neturl.Parse(raw) + if err != nil { + return raw + } + if parsed.Scheme != "http" || !isLocalCaptchaHost(parsed.Host) { + return raw + } + parsed.Scheme = targetURL.Scheme + parsed.Host = targetURL.Host + return parsed.String() +} + +func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) { + req.URL.Scheme = targetURL.Scheme + req.URL.Host = targetURL.Host + if req.URL.Path == "" { + req.URL.Path = targetURL.Path + } + req.Host = targetURL.Host + + req.Header.Del("Accept-Encoding") + for _, headerName := range []string{"Origin", "Referer"} { + if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" { + req.Header.Set(headerName, rewritten) + } else { + req.Header.Del(headerName) + } + } +} + +func extractSuccessToken(body []byte) string { + var payload struct { + Response struct { + SuccessToken string `json:"success_token"` + } `json:"response"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + return payload.Response.SuccessToken +} + +func rewriteProxyCookies(header http.Header) { + cookies := (&http.Response{Header: header}).Cookies() + if len(cookies) == 0 { + return + } + header.Del("Set-Cookie") + for _, cookie := range cookies { + cookie.Domain = "" + cookie.Secure = false + cookie.Partitioned = false + if cookie.SameSite == http.SameSiteNoneMode || cookie.SameSite == http.SameSiteStrictMode { + cookie.SameSite = http.SameSiteLaxMode + } + header.Add("Set-Cookie", cookie.String()) + } +} + +func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { + localOrigin := localCaptchaOrigin() + upstreamOrigin := targetOrigin(targetURL) + html = strings.ReplaceAll(html, upstreamOrigin, localOrigin) + + script := fmt.Sprintf(` + +`, localOrigin, upstreamOrigin) + + switch { + case strings.Contains(html, ""): + return strings.Replace(html, "", script+"", 1) + case strings.Contains(html, ""): + return strings.Replace(html, "", script+"", 1) + default: + return html + script + } +} + +func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + } + if dialer != nil { + transport.DialContext = dialer.DialContext + } + return transport +} + +func startCaptchaServer(srv *http.Server, logPrefix string) error { + var listenErrs []string + var listening bool + + for _, addr := range localCaptchaListenAddrs() { + listener, err := net.Listen("tcp", addr) + if err != nil { + listenErrs = append(listenErrs, fmt.Sprintf("%s (%v)", addr, err)) + continue + } + listening = true + go func(listener net.Listener) { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("%s: %s", logPrefix, err) + } + }(listener) + } + + if listening { + return nil + } + + return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; ")) +} + +// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token. +func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) { + srv := &http.Server{Handler: handler} + + if err := startCaptchaServer(srv, logPrefix); err != nil { + return "", err + } + + fmt.Println("\n==============================================") + fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED") + fmt.Println("Open this URL in your browser: " + captchaURL) + fmt.Println("==============================================") + fmt.Println() + openBrowser(captchaURL) + + key := <-keyCh + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + return "", err + } + + return key, nil +} + +// notifyKey pushes the key string to the given channel without blocking +func notifyKey(keyCh chan<- string, key string) { + if key != "" { + select { + case keyCh <- key: + default: + } + } +} + +func solveCaptchaViaHTTP(captchaImg string) (string, error) { + keyCh := make(chan string, 1) + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, ` + + + + +

Solve the Captcha

+captcha +
+
+
+
`, captchaImg) + }) + + mux.HandleFunc("/solve", func(w http.ResponseWriter, r *http.Request) { + notifyKey(keyCh, r.URL.Query().Get("key")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprint(w, `

Done!

`) + }) + + return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") +} + +func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { + keyCh := make(chan string, 1) + + targetURL, err := neturl.Parse(redirectURI) + if err != nil { + return "", fmt.Errorf("invalid redirect URI: %v", err) + } + + transport := newCaptchaProxyTransport(dialer) + + proxy := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(req *httputil.ProxyRequest) { + rewriteProxyRequest(req.Out, targetURL) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("captcha proxy error for %s: %v", r.URL.String(), err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = fmt.Fprintf(w, `

Captcha proxy error

%v

`, err) + }, + ModifyResponse: func(res *http.Response) error { + rewriteProxyCookies(res.Header) + + if res.StatusCode >= 300 && res.StatusCode < 400 { + if loc := res.Header.Get("Location"); loc != "" { + if strings.HasPrefix(loc, "/") { + res.Header.Set("Location", loc) + } else if strings.HasPrefix(loc, targetOrigin(targetURL)) { + res.Header.Set("Location", strings.Replace(loc, targetOrigin(targetURL), localCaptchaOrigin(), 1)) + } + } + } + + contentType := res.Header.Get("Content-Type") + shouldInspectBody := strings.Contains(contentType, "text/html") || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + if !shouldInspectBody { + return nil + } + + reader := res.Body + if res.Header.Get("Content-Encoding") == "gzip" { + gzReader, err := gzip.NewReader(res.Body) + if err == nil { + reader = gzReader + defer func() { + if err := gzReader.Close(); err != nil { + log.Printf("failed to close gzip reader: %v", err) + } + }() + } + } + + bodyBytes, err := io.ReadAll(reader) + if err != nil { + return err + } + if err := res.Body.Close(); err != nil { + return err + } + + if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") { + notifyKey(keyCh, extractSuccessToken(bodyBytes)) + } + + if strings.Contains(contentType, "text/html") { + for _, headerName := range []string{ + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "X-Content-Security-Policy", + "X-WebKit-CSP", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Embedder-Policy", + "Cross-Origin-Resource-Policy", + "X-Frame-Options", + } { + res.Header.Del(headerName) + } + + bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL)) + res.Header.Del("Content-Encoding") + } + + res.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + res.ContentLength = int64(len(bodyBytes)) + res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes))) + + return nil + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) { + notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = fmt.Fprint(w, "ok") + }) + + mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) { + targetAuthUrl := r.URL.Query().Get("proxy_url") + targetParsed, err := neturl.Parse(targetAuthUrl) + if err != nil || targetParsed.Host == "" { + http.Error(w, "Bad URL", http.StatusBadRequest) + return + } + genericReverse := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(req *httputil.ProxyRequest) { + req.Out.URL.Path = targetParsed.Path + req.Out.URL.RawQuery = targetParsed.RawQuery + rewriteProxyRequest(req.Out, targetParsed) + }, + } + genericReverse.ServeHTTP(w, r) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" { + http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) + return + } + proxy.ServeHTTP(w, r) + }) + + return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error") +} + +func openBrowser(url string) { + for _, cmd := range browserOpenCommands(runtime.GOOS, url) { + if err := exec.Command(cmd.name, cmd.args...).Start(); err == nil { + return + } + } +} + +func browserOpenCommands(goos string, url string) []browserCommand { + switch goos { + case "windows": + return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}} + case "darwin": + return []browserCommand{{name: "open", args: []string{url}}} + case "linux": + return []browserCommand{ + {name: "xdg-open", args: []string{url}}, + {name: "gio", args: []string{"open", url}}, + } + case "android": + return []browserCommand{ + {name: "termux-open-url", args: []string{url}}, + {name: "/system/bin/am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "xdg-open", args: []string{url}}, + } + case "ios": + return []browserCommand{ + {name: "open", args: []string{url}}, + {name: "uiopen", args: []string{url}}, + } + } + return nil +} diff --git a/client/profiles.go b/client/profiles.go index 7df1dc7..44b4141 100644 --- a/client/profiles.go +++ b/client/profiles.go @@ -5,51 +5,78 @@ import ( ) type Profile struct { - UserAgent string + UserAgent string + SecChUa string + SecChUaMobile string + SecChUaPlatform string } -// profiles contains realistic user-agent strings for different browsers and platforms. -// Add or remove profiles as needed. +// profiles contain paired User-Agent and Client Hints strings to harden bot detection. var profiles = []Profile{ - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}, + // Windows Chrome + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"}, + // Windows Edge + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 YaBrowser/24.1.0.0 Yowser/2.5 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 YaBrowser/24.1.2.0 Yowser/2.5 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 YaBrowser/23.12.0.0 Yowser/2.5 Safari/537.36"}, + // macOS Chrome + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 OPR/112.0.0.0"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 OPR/111.0.0.0"}, - - {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"}, - - {"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"}, - - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"}, - {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}, + // Linux Chrome + { + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, + { + UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, } -// getRandomProfile returns a random user-agent profile. +// getRandomProfile returns a paired User-Agent and Client Hints profile. func getRandomProfile() Profile { return profiles[rand.Intn(len(profiles))] } diff --git a/go.mod b/go.mod index 63d5b25..8a7c99f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/pion/dtls/v3 v3.0.11 github.com/pion/logging v0.2.4 + github.com/pion/transport/v4 v4.0.1 github.com/pion/turn/v5 v5.0.2 ) @@ -17,7 +18,6 @@ require ( github.com/miekg/dns v1.1.69 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.1.1 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.30.0 // indirect