156 Commits

Author SHA1 Message Date
samokrutka d0dbec3e03 chore(master): release 4.0.1 2026-09-24 23:31:22 +03:00
samosvalishe a2d9a88333 fix: naturalyiiBlondinNaVsyStranuTakoy1
как хорошо что исходники под рукой
2026-09-24 23:29:34 +03:00
samokrutka d16e9226ce chore(master): release 4.0.0 2026-09-24 01:44:23 +03:00
samosvalishe 58924dc91a feat: rebranding 2026-09-24 01:31:55 +03:00
samosvalishe 6f84a18b43 feat(tcp): вернуть поддержку bond 2026-09-22 22:33:56 +03:00
samosvalishe a8b8363b79 refactor: сложность функций под лимиты линтера 2026-09-22 20:05:10 +03:00
samosvalishe c13490ff55 fix(udp): ошибка obf до аллокации, без петли allocate 2026-09-22 20:05:02 +03:00
samosvalishe 5b175bf4be fix(udp): датаграммы до 2048 байт на всём пути 2026-09-22 20:04:44 +03:00
samosvalishe 6f81528a34 fix(turn): отмена контекста прерывает allocate 2026-09-22 20:04:26 +03:00
samosvalishe 639582c876 fix(task): заглушки mobile-dist в windows 2026-09-22 20:04:06 +03:00
samosvalishe 5b6ac18332 perf(tcp): смерть smux-сессии без поллинга 2026-09-22 20:04:05 +03:00
samosvalishe b0e3cfe20a fix(dns): проба udp/53 не теряет второй сервер, doh до смены сети 2026-09-22 20:04:05 +03:00
samosvalishe 5a1c6e299c fix(mobile): отказ protect возвращать ошибкой сокета 2026-09-22 20:04:05 +03:00
samosvalishe b1cbafda73 perf(netconn): адрес пайпа без аллокации на каждое чтение 2026-09-22 20:04:05 +03:00
samosvalishe f4e8e0cfe0 fix(udp): завершать релей при отказе чтения локального сокета 2026-09-22 20:03:54 +03:00
samosvalishe 192dd27a1e fix: гард для релизера 2026-09-22 10:21:43 +03:00
samosvalishe af33b47fb2 feat: загрузка локально собранного бинаря на сервер 2026-09-22 10:21:10 +03:00
samosvalishe 05bbe996c1 fix: отбрасывать опоздавший повтор client id, общий backoff квоты 2026-09-22 00:43:06 +03:00
samosvalishe 1e3782c546 docs: прямой режим и новый установщик, без внутренней механики 2026-09-22 00:07:29 +03:00
samosvalishe d3e4cfc1db feat(auth)!: подтверждение Client ID с повтором по таймеру 2026-09-22 00:07:29 +03:00
samosvalishe caa8fea5bf fix(udp): логировать причину обрыва DTLS-сессии перед паузой 2026-09-22 00:07:29 +03:00
samosvalishe 5508cd65a7 fix(udp): ограничить очередь между сокетом и DTLS 2026-09-22 00:07:29 +03:00
samosvalishe f0f6c3ee6d ci(release): публиковать install.sh в релизе вместе с checksums 2026-09-22 00:07:29 +03:00
samosvalishe 0ee0d5333e feat(install)!: install.sh с JSON RPC proto 3 и голым WireGuard вместо AWG 2026-09-22 00:07:29 +03:00
samosvalishe ee30afc922 feat(client): провайдер direct - прямо на сервер без TURN-реле
Тот же DTLS с обфускацией, сервер не меняется. По умолчанию один поток.
2026-09-22 00:07:28 +03:00
samosvalishe 7df3a3fd91 refactor(dtls): убрать дублирующий doc.go 2026-09-21 13:43:52 +03:00
samosvalishe c116e427f7 test: убрать гонки в проверках учёта трафика 2026-09-21 13:43:21 +03:00
samosvalishe 7e5b2d4cde refactor(wake): убрать неиспользуемый Notifier 2026-09-21 13:43:20 +03:00
samosvalishe 273a5fa977 fix(netconn): отдавать остаток очереди при закрытии пира 2026-09-21 13:43:20 +03:00
samosvalishe a0c4656025 perf(udp): убрать сисколлы и аллокации с горячего пути релея 2026-09-21 13:43:20 +03:00
samosvalishe ac43e63ef8 fix(clients): фиксировать базу на диск через fsync 2026-09-21 13:43:13 +03:00
samosvalishe b621182ce4 fix(clients): останавливать горячую перезагрузку базы по контексту 2026-09-21 13:43:01 +03:00
samosvalishe fd5c4f92a5 fix(server): не ронять процесс паникой в обработчике соединения 2026-09-21 13:42:24 +03:00
samosvalishe f18e694f1a fix(cli): предупреждать, что встроенный туннель из ссылки не поднимается 2026-09-21 13:42:01 +03:00
samosvalishe 69dd5f9488 refactor(cli): общий обработчик сигналов для клиента и сервера 2026-09-21 13:41:59 +03:00
samosvalishe 0a3becc74d refactor(vk): не держать write-lock кеша реквизитов во время запроса 2026-09-21 13:41:00 +03:00
samosvalishe 3f9b3b6cd0 fix(vk): определять ошибки авторизации по sentinel, а не по тексту 2026-09-21 13:40:43 +03:00
samosvalishe dd0bc211b7 refactor(config): брать режимы dns из dnsdial 2026-09-21 13:39:53 +03:00
samosvalishe 8b3bea7b1a fix(sub): резолвить подписку через dns-диалер приложения 2026-09-21 13:39:53 +03:00
samosvalishe ece494aa1b fix(dns): не залипать на первом резолвере doh-форвардера 2026-09-21 13:39:46 +03:00
samosvalishe 3b48a17173 fix(captcha): слать баннер ручной капчи в логгер, а не в stdout 2026-09-21 13:39:22 +03:00
samosvalishe 56dfc9aedd refactor(logx): общий Holder вместо пакетных переменных Log 2026-09-21 13:39:06 +03:00
samosvalishe 322c7001fb refactor(wire): один listener на все профили обфускации 2026-09-21 13:36:27 +03:00
samosvalishe f422acb927 perf(obf): держать поток ChaCha8 в соединении rtpopus3 2026-09-21 13:36:27 +03:00
samosvalishe e32008bba2 perf(randx): rejection sampling вместо math/big 2026-09-21 13:36:19 +03:00
samosvalishe eb8ae03b50 docs(uri): добавить rtpopus3 в список профилей обфускации 2026-09-21 13:36:19 +03:00
samosvalishe 802ea7d0af ci: запинить версии gomobile, goimports и docker-действия 2026-09-21 13:36:18 +03:00
samosvalishe a7ec2b949a feat(mobile): отключаемый кольцевой буфер логов
Хосту, который ведёт лог через EventSink, кольцо для DumpLogs не нужно.
2026-09-19 23:54:42 +03:00
samosvalishe 4865a73b5f fix(dns): сбрасывать udp-резолверы, если сессия их не задала
Глобальный список переживал сессию, и следующая наследовала резолверы прошлой сессии или сети.
2026-09-19 23:54:42 +03:00
samosvalishe 64f8b63548 feat(uri): ссылка на звонок и пейсинг обфускации в freeturn://
vk применяется, только если своих ссылок не задано; timing - только вместе с обфускацией.
2026-09-19 23:54:42 +03:00
samosvalishe e102acbc4b feat(diagnostics): связывать события сессии и попытки восстановления 2026-09-12 17:33:50 +03:00
samosvalishe 061e3b882b fix(clients): сохранять авторизацию при ошибке записи базы 2026-09-12 17:33:46 +03:00
samosvalishe 6570cd9dd7 fix(relay): восстановление потоков при отказах turn и занятой квоте 2026-09-12 17:29:09 +03:00
samosvalishe e72157f4f7 feat(installer): токен на клиента и TTL для веб-раздачи 2026-09-12 15:14:45 +03:00
samosvalishe 065385e656 feat(installer): раздача QR-кодов и конфигов по веб-ссылкам без вывода в терминал
- Полный отказ от вывода громоздких QR-кодов в терминал
- Автоматический фоновый веб-сервер для раздачи файлов и картинок QR из каталога clients
- Компактная карточка M3 со списком прямых кликабельных ссылок и каталогом файлов
- Прямой вывод ссылок на первого клиента в финальной рамке без подтверждения
- Добавлена опция --web-port (по умолчанию 8080) и сохранение в install.conf
- Обновлена документация и CLI команды freeturn client qr
2026-09-07 19:17:35 +03:00
samosvalishe 092ea5916a feat(installer): вывод всех 3 форматов QR-кодов и поддержка скачивания картинок на ПК
- Разделение QR-кодов на 3 чётких формата с подробными подсказками:
  1. AmneziaWG Direct (AWG 3.1) для официального приложения AmneziaWG
  2. FreeTurn App (релей + вшитый VPN) со ссылкой freeturn:// для мобильного приложения FreeTurn
  3. AmneziaWG Relay (127.0.0.1:9000) для связки с отдельным CLI/Termux клиентом
- Вывод по умолчанию всех 3 QR-кодов при завершении установки и по команде freeturn client qr
- Инструкции и готовая SCP-команда для скачивания всех 3 картинок (*.png) на ПК
- Добавлена команда freeturn client web для временной раздачи картинок через браузер по HTTP
- Поддержка протокола OSC 1337 для автоматического сохранения файлов в поддерживаемых терминалах
2026-09-07 18:41:03 +03:00
samosvalishe 8d551c61ae feat(installer): глобальный CLI freeturn, авторизация по Client ID по умолчанию и компактный QR
- Включение авторизации по Client ID (allowlist) по умолчанию в мастере установки
- Создание глобального симлинка /usr/local/bin/freeturn для вызова из любой директории
- Умный поиск имени клиента с поддержкой дефисов/регистра (client1 -> client-1)
- Очистка от комментариев и Low ECC (-l L) для компактного QR-кода (~51 колонка) в PowerShell/терминалах
- Интерактивный запрос на вывод QR-кода первого клиента сразу после завершения установки
- Обновление подсказок и документации на команду freeturn
2026-09-07 18:15:56 +03:00
samosvalishe b894608110 feat(installer): генерация QR в PNG, M3 TUI для клиентов и фикс утечки escape-последовательностей 2026-09-07 17:16:49 +03:00
samosvalishe 5568670f51 fix(ui): убрать утечку OSC 11/CPR escape-последовательностей при выводе плашек 2026-09-07 12:11:47 +03:00
samosvalishe 5dc5095e2a fix(installer): передача obf_profile, obf_key и proxy_mode в systemd и docker args 2026-09-07 11:44:49 +03:00
samokrutka e1bf369db1 chore(master): release 3.4.0 2026-09-07 10:00:05 +03:00
samosvalishe 9da2c8ebd2 feat: профиль freeturn vpn со вшитым wg и фикс ui_spin
- фикс вызова функций в ui_spin при наличии gum (устранена ошибка _dl: command not found)

- поддержка поля wg в freeturn:// ссылках для автонастройки VPN в мобильном приложении

- генерация отдельного профиля и выбор QR-кода со вшитым VPN в install.sh
2026-09-07 09:57:41 +03:00
samokrutka 2544be2ed6 chore(master): release 3.3.2 2026-09-07 09:01:28 +03:00
samosvalishe c435605fe2 fix: небольшие улучшения 2026-09-07 08:59:17 +03:00
samosvalishe 82907f3cc6 fix: собственный iface для awg 2026-09-07 08:40:15 +03:00
samokrutka 4b8c75ee92 chore(master): release 3.3.1 2026-09-07 03:42:41 +03:00
samosvalishe c447fc405b fix: release awg img 2026-09-07 03:40:52 +03:00
samosvalishe 408b68e960 fix: installer 2026-09-07 03:32:18 +03:00
samosvalishe 032d1fac56 fix: installer 2026-09-07 03:12:58 +03:00
samokrutka ace2ab54c6 chore(master): release 3.3.0 2026-09-07 02:50:05 +03:00
samosvalishe 02c696fa28 fix(tunnel): race 2026-09-07 02:45:48 +03:00
samosvalishe daf8cece80 feat(scripts): поддержка amneziawg 3.1, управление клиентами и qr-коды в установщике
- генерация 18 параметров amneziawg 3.1 и docker-compose для freeturn-awg
- команды cli и rpc v2 для управления пирами и вывода qr-кодов (direct и relay)
- раздельная установка, переконфигурация и раздельное удаление компонентов
- набор из 27 автоматических bats-тестов установщика
- актуализация документации по прямому режиму и развёртыванию
2026-09-07 02:37:55 +03:00
samosvalishe 1cc5e93d03 fix(tunnel): обработка защиты сокетов и стабильность прямого режима 2026-09-07 02:37:54 +03:00
samosvalishe 67f9a4fe4e fix: fmt 2026-09-07 02:37:53 +03:00
samosvalishe 2812ded2ed feat: прямой туннель без релея 2026-09-07 02:37:53 +03:00
samokrutka fa9549e6e0 chore(master): release 3.2.0 2026-08-25 16:04:23 +03:00
samosvalishe d82fe0f962 chore: go 1.26.7 2026-08-25 15:44:16 +03:00
samosvalishe c2058dc013 chore(deps): обновление зависимостей 2026-08-25 15:44:16 +03:00
samosvalishe 77fbdec707 feat(tunnel): параметры обфускации awg 3+ 2026-08-25 15:44:16 +03:00
samosvalishe c6fc903c25 chore(deps): amneziawg-go v0.2.19 -> v3.1.20260814 2026-08-25 15:44:16 +03:00
samosvalishe f7129099f7 feat(proxy): tcp-режим для xray/sing-box поверх turn 2026-08-25 15:44:16 +03:00
samosvalishe e63279db18 perf(udprelay): общий шлюз на allocate вместо per-stream джиттера 2026-08-25 15:44:16 +03:00
samosvalishe 03eb41e2fb fix(udprelay): фатальная ошибка не ждёт барьер прогрева 2026-08-25 15:44:16 +03:00
samosvalishe 748d5a07ce fix(core): паника горутины больше не убивает процесс приложения 2026-08-25 15:44:16 +03:00
samosvalishe 623ef46f2f fix(vkauth): не считать отменённый поход за реквизитами нагрузкой на провайдера 2026-08-25 15:44:16 +03:00
samosvalishe 1547aaeed7 fix(captcha): не жечь персону, когда страница не дошла до вердикта 2026-08-25 15:44:16 +03:00
samosvalishe f61dc8c957 feat(session): реконнект релея без пересоздания туннеля 2026-08-25 15:44:16 +03:00
samosvalishe 48631616f1 fix(udprelay): не зависать на фатальной ошибке провайдера
Дедлайн, будивший runListener, ставился по родительскому ctx, а фатальная ошибка отменяет только runCtx: на молчащем LocalPipe wg.Wait() не возвращался никогда.
2026-08-25 15:44:15 +03:00
samosvalishe 7fc7fe2e79 fix(vkauth): сбрасывать реквизиты при неподтверждённом deallocate 2026-08-25 15:44:15 +03:00
samosvalishe c2a9f0f1d7 fix(logs): убрать пострим-шум из лога без debug
TURN urls, персона, попытки кредов и DTLS connect/close - на debug: на 8 стримах это десятки строк на старте.
2026-08-25 15:44:15 +03:00
samokrutka aed2839c7d chore(master): release 3.1.1 2026-08-19 20:04:08 +03:00
samosvalishe 9d454dbd41 fix(captcha): адаптация 2026-08-19 20:02:00 +03:00
samokrutka ec1b1fce77 chore(master): release 3.1.0 2026-08-18 17:21:50 +03:00
samosvalishe c3fc9396d4 fix: ci 2026-08-18 15:59:07 +03:00
samosvalishe 022692ea18 fix: ci 2026-08-18 15:51:43 +03:00
samosvalishe c6703783c4 feat(server): адрес клиента в логах сессии и handshake 2026-08-18 15:29:32 +03:00
samosvalishe 60e3a69a7d fix(vkauth): отмена капчи не сжигает персону
свои же рестарты стоили поколения персоны и лока на минуту.
2026-08-18 15:29:32 +03:00
samosvalishe 9c2cc93a1c fix(session): рецикл после пробуждения только при тишине в канале
гэп-детектор сносил живые аллокации: сон сам по себе их не убивает, а
keepalive туннеля идёт через тот же relay. пинок теперь проверяет rx в окне
и схлопывает пачку сигналов в один рецикл.
2026-08-18 15:29:32 +03:00
samosvalishe fd9c6abf1e fix(udprelay): рецикл turn-аллокации пересоздаёт dtls-сессию
смерть аллокации теперь роняет и dtls: у новой аллокации другой relayed-адрес,
а миграция по dtls connection id под obf-профилем на сервере не работает.
логи turn-клиента уведены в logx - коды отказа vk не было видно нигде.
2026-08-18 15:29:32 +03:00
samokrutka bc0673c19e Merge pull request #60 from samosvalishe/release-please--branches--master
chore(master): release 3.0.0
2026-08-18 00:24:39 +03:00
samokrutka d3271ab4ab chore(master): release 3.0.0 2026-08-18 00:22:06 +03:00
samosvalishe 49eecb3478 fix: устранение предупреждений линтера 2026-08-18 00:21:35 +03:00
samosvalishe 65bc8a8993 fix: устранение предупреждений линтера 2026-08-18 00:15:52 +03:00
samosvalishe 53b8345aa0 fix: возврат фонового детектора сна для рецикла аллокаций 2026-08-18 00:07:34 +03:00
samosvalishe 0bef2e8ab5 fix: soft reconnect on network handover without tunnel drop 2026-08-17 23:29:19 +03:00
samosvalishe 5fdd8c3112 fix(vkauth): ловим 486 как auth-error для рецикла аллокации 2026-08-17 18:45:31 +03:00
samosvalishe d208a0d3df fix: readme 2026-08-17 17:33:43 +03:00
samosvalishe 6bc4faf942 fix: убрал лишний артефакт 2026-08-17 17:16:12 +03:00
samosvalishe 3cf78316df perf: меньше фоновых таймеров при поднятом туннеле и опрос метрик только при видимом окне 2026-08-17 17:14:10 +03:00
samosvalishe a033cfb9a2 fix(udprelay): рецикл аллокации на простое туннеля 2026-08-17 17:13:44 +03:00
samosvalishe 5b5c93fae1 fix(captcha): устранение регрессий 2026-08-17 15:45:21 +03:00
samosvalishe 74241de764 break!: выпил tcp-режим туннеля
убраны флаги -mode и -bond: остался только udp-релей для wireguard.
удалены tcpfwd, tcpfwdserver, bondclient, bondserver, bondframe, kcptun,
wire/relay; proxy/common переехал в udprelay/dial.

BREAKING CHANGE: флаги -mode и -bond удалены; конфиги и ссылки freeturn://
с tcp-режимом больше не принимаются.
2026-08-17 00:27:23 +03:00
samosvalishe 648c55415b feat: comment cleanup 2026-08-16 23:13:01 +03:00
samosvalishe 6b14a95434 fix(captcha): персонная модель PoW и кэш TURN-кредов 2026-08-16 21:19:15 +03:00
samosvalishe 681dde8a6d feat(mobile): переживание сна и рестарта на Android 2026-08-16 21:19:14 +03:00
samokrutka 0a6b0e0e85 Merge pull request #58 from samosvalishe/release-please--branches--master
chore(master): release 2.1.2
2026-08-14 14:00:54 +03:00
samokrutka 25451e8e25 chore(master): release 2.1.2 2026-08-14 13:57:05 +03:00
samosvalishe 5bc164f78d fix(deps): обновить Go 1.26.5 -> 1.26.6 2026-08-14 13:56:43 +03:00
samosvalishe 95803f481a fix(captcha): fmt 2026-08-14 13:48:05 +03:00
samosvalishe 235ab1a646 fix(captcha): выровнять авторешение по живому браузеру 2026-08-14 13:46:39 +03:00
samokrutka 1dcaa46659 Merge pull request #56 from samosvalishe/release-please--branches--master
chore(master): release 2.1.1
2026-08-04 12:17:47 +03:00
samokrutka 7dc23ce3f0 chore(master): release 2.1.1 2026-08-04 12:11:53 +03:00
samosvalishe 1ca3451b76 refactor(captcha): убрать трассировку запросов
Разовая диагностика под сверку с виджетом; расхождения, которые она нашла,
закрыты коммитом выше.
2026-08-04 12:05:54 +03:00
samosvalishe 59a8c84157 fix(captcha): линтер в трассировке запросов 2026-08-04 12:01:46 +03:00
samosvalishe 4174f29c63 fix(captcha): блокировать рекламные хосты в ручном режиме
Внешние трекеры резались allowlist-ом и раньше, но ads.vk.com и ads.vk.ru
проходили по суффиксу домена. Captcha без них решается: adFp остаётся пустым,
ровно как у браузера с блокировщиком.
2026-08-04 11:56:03 +03:00
samosvalishe 4fa7810afa fix(captcha): привести запросы к поведению живого виджета 1.1.1388
Сверка протокола с бандлом вскрыла три расхождения.

Телеметрия: указатель виджет пишет только через externalCoords, а их заполняет
хост-страница iframe или нативный бридж мобильного SDK. Мы ходим на captcha
прямой навигацией, где живой браузер оставляет cursor и taps пустыми, - вместе с
ними ушла синтетическая траектория руки, недостижимая для этого режима. A/B на
живой captcha: 10/10 сессий против 9/10 со старым поведением.

Заголовки: навигация документа не несла Upgrade-Insecure-Requests и
Sec-Fetch-User, приоритет всем запросам ставился как у fetch, Origin уходил на
GET-ы, где Chrome его не шлёт, Referer навигации указывал на саму цель вместо
страницы-источника, а бандл тянулся профилем fetch вместо тега script. Cookie
отсутствовал в списке порядка и уезжал последним заголовком.

Куки: tls-client отбрасывает удалённые куки только по Max-Age - проверка Expires
в его jar закомментирована. Погашенная сервером remixir=DELETED из-за этого
уходила в каждом запросе. Обёртка в personanet транслирует протухший Expires в
Max-Age, который фильтр библиотеки понимает.

Трассировка запросов под FREETURN_CAPTCHA_TRACE - ей расхождения и найдены.
2026-08-04 11:56:03 +03:00
samosvalishe 8eb3873d27 fix(captcha): разносить попытки слайдера по дорожке
Соседние кандидаты отличаются одним свапом и получают почти равный скор,
поэтому топ-2 ранжирования - всегда пара соседей, и вторая попытка
промахивается вместе с первой. Вторая берётся не ближе трёх позиций от первой,
с откатом на соседей, если дальних кандидатов не хватило.
2026-08-04 10:37:08 +03:00
samosvalishe ee1356308b fix(captcha): не жечь персону на эскалации checkbox -> slider
Эскалация происходит в каждой сессии - слайдер в той же сессии проходит с тем
же browser_fp и device, значит отпечаток VK не отвергал. Сжигание на ней меняло
VisitorID каждый визит, хотя персона выводится из seed именно ради стабильности
между визитами. Сжигание по факту отказа в token_call.go сохранено.
2026-08-04 10:34:16 +03:00
samosvalishe d9a0554489 fix(captcha): убрать reverse-скор из ранжирования slider-кандидатов
Консенсус складывал ранги четырёх метрик с равным весом, но reverse-скор с
остальными не коррелирует: на корпусе из 8 пазлов luma/rgb/text сходились на
одном кандидате 8/8, reverse - 1/8. Кандидат, первый по всем трём рабочим
метрикам, уезжал в десятый ранг консенсуса и не попадал в две доступные
попытки. Побочно перестал считаться второй seam-скор на каждого кандидата.
2026-08-04 10:24:39 +03:00
samosvalishe ea477a3bc2 fix(captcha): обновить пин debug_info под виджет 1.1.1388 2026-08-04 10:24:39 +03:00
samokrutka 7c0112d494 Merge pull request #55 from samosvalishe/release-please--branches--master
chore(master): release 2.1.0
2026-08-01 15:32:03 +03:00
samokrutka 2a9cf9a0c8 chore(master): release 2.1.0 2026-08-01 15:25:56 +03:00
samosvalishe cdc7ee8d3d fix(routes): linter 2026-08-01 15:25:22 +03:00
samosvalishe 8524284eb8 chore(ci): фикс тг анонса 2026-08-01 15:13:48 +03:00
samosvalishe a7798cd851 feat(proxy): встроенное управление маршрутами к TURN-серверам
- Добавлен пакет `internal/routemgr` для автоматического создания
  host-маршрутов (/32) через шлюз ОС (Windows, Linux, macOS).
- В `config.Client` и CLI добавлен флаг `-routes` (по умолчанию отключен).
- Интегрирован callback `OnTURNServer` в `udprelay` и `tcpfwd`
  для динамического добавления маршрутов при старте стримов.
- Для Android (и переименованных JNI-бинарей Linux) добавлено
  graceful-отключение менеджера маршрутов.
- Обновлена документация (`quickstart.md`): добавлены примеры с
  использованием `-routes` и `-client-id`.
2026-08-01 15:07:37 +03:00
samosvalishe 1f86fda104 refactor: перенос логики сессии в internal/session и обновление mobile-фасада 2026-08-01 03:05:14 +03:00
samokrutka 68485cbee7 chore: update README.md 2026-07-31 00:37:20 +03:00
Anton c5bb9c4d9d Merge pull request #53 from samosvalishe/release-please--branches--master
chore(master): release 2.0.2
2026-07-30 23:08:20 +03:00
Anton f2b689b81d chore(master): release 2.0.2 2026-07-30 23:03:49 +03:00
samosvalishe 7c28498f23 fix(captcha): адаптация под новую версию виджета 2026-07-30 23:03:17 +03:00
Anton 760c511fe8 Merge pull request #51 from samosvalishe/release-please--branches--master
chore(master): release 2.0.1
2026-07-29 02:17:01 +03:00
Anton 2e08a2bfab chore(master): release 2.0.1 2026-07-29 01:59:54 +03:00
samosvalishe d1b769a062 fix(captcha): дожимать эскалацию checkbox -> slider в той же сессии
VK меняет тип челленджа на captchaNotRobot.check, а решатель считал это
фатальным и переигрывал сессию целиком (новый PoW + settings + initSession).
Теперь content_settings читается и из check-ответа, слайдер решается на месте.
2026-07-29 01:59:27 +03:00
samosvalishe 1d5eb2cdf2 ci: почистить телеграм-анонс релиза
Ссылки на коммиты из чейнджлога срезаются, заголовок - FreeTurn (Go).
2026-07-29 01:59:27 +03:00
samosvalishe 9734b37f38 chore(deps): обновить зависимости и версии инструментов
pion/turn 5.0.12, pion/dtls 3.1.5, x/crypto 0.54.0, x/net 0.57.0 + indirect.
govulncheck v1.6.0, goreleaser v2.17.1, task 3.52.0.
2026-07-29 01:59:27 +03:00
Anton 2506aece56 Merge pull request #49 from samosvalishe/release-please--branches--master
chore(master): release 2.0.0
2026-07-29 00:30:06 +03:00
Anton a63032d42c chore(master): release 2.0.0 2026-07-28 17:42:25 +03:00
samosvalishe 34746e27e0 fix: update deps for vuln pass 2026-07-28 17:41:38 +03:00
samosvalishe 3211ed9ad5 feat(captcha)!: телеметрия виджета и одна Chrome-персона на установку
Персона строится детерминированно из FingerprintSeed (Client ID) и живёт
между перезапусками; при отказе captcha сжигается, и цепочка проходится
заново новой личностью с чистыми куками.

Телеметрия captcha (internal/captcha/telemetry.go) собирает аналитику
виджета: сэмплы сенсоров, connection-поля, курсор/тапы. Жест проживается
в реальном времени, иначе тайминги массивов не сходятся.

internal/personanet - TLS-клиент персоны (JA3 + split ClientHello), общий
для VK-auth и ручной captcha; порядок заголовков задаёт browserprofile.

BREAKING CHANGE: флаг -browser убран, семейство персоны всегда Chrome -
только Chromium даёт NetworkInformation и Generic Sensors, без которых
телеметрия captcha неполна. Класс устройства по-прежнему за -platform.
2026-07-28 16:40:32 +03:00
Anton 62064a5eca Merge pull request #46 from samosvalishe/release-please--branches--master
chore(master): release 1.8.0
2026-07-13 22:54:01 +03:00
Anton d67c829532 chore(master): release 1.8.0 2026-07-13 22:31:36 +03:00
samosvalishe e678ee1fd8 feat: автопубликация телеграм анонса при релизе 2026-07-13 22:31:06 +03:00
samosvalishe e5874e3c30 chore: lint fix 2026-07-13 21:48:45 +03:00
samosvalishe 8311a2e2f9 chore: fmt autofix 2026-07-13 21:37:22 +03:00
samosvalishe 5a347bece9 docs: флаг -platform, persona-модель и пунктуация по конвенции 2026-07-13 21:29:35 +03:00
samosvalishe 305754cd60 fix(captcha): persona-модель вместо захвата профиля браузера 2026-07-13 21:29:34 +03:00
214 changed files with 18792 additions and 7672 deletions
+4
View File
@@ -0,0 +1,4 @@
# Ensure shell scripts and configs always have LF line endings
*.sh text eol=lf
*.bats text eol=lf
*.conf text eol=lf
+21 -5
View File
@@ -12,12 +12,12 @@ concurrency:
cancel-in-progress: true
env:
GO_VERSION: "1.26.5"
GO_VERSION: "1.26.7"
GOLANGCI_VERSION: v2.12.2
GOVULN_VERSION: v1.1.4
GORELEASER_VERSION: v2.15.4
GOMOBILE_VERSION: latest
TASK_VERSION: 3.51.1
GOVULN_VERSION: v1.6.0
GORELEASER_VERSION: v2.17.1
GOMOBILE_VERSION: v0.0.0-20260821190718-4776eadac327
TASK_VERSION: 3.52.0
jobs:
lint:
@@ -82,6 +82,18 @@ jobs:
- run: govulncheck ./...
install-sh:
name: install.sh (bats)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- name: Install bats
run: sudo apt-get update -qq && sudo apt-get install -y -qq bats
- name: Run tests
run: bats scripts/tests
build-snapshot:
name: Snapshot build (all targets)
runs-on: ubuntu-latest
@@ -96,6 +108,10 @@ jobs:
go-version: ${{ env.GO_VERSION }}
cache: true
# checksum.extra_files глобит mobile-dist, а снапшот идёт без mobile-джоб
- name: Stub mobile artifacts
run: mkdir -p mobile-dist && touch mobile-dist/freeturn.aar mobile-dist/freeturn-sources.jar mobile-dist/Mobile.xcframework.zip
- uses: goreleaser/goreleaser-action@v7.2.2
with:
distribution: goreleaser
+45 -5
View File
@@ -10,10 +10,10 @@ permissions:
packages: write
env:
GO_VERSION: "1.26.5"
GORELEASER_VERSION: v2.15.4
GOMOBILE_VERSION: latest
TASK_VERSION: 3.51.1
GO_VERSION: "1.26.7"
GORELEASER_VERSION: v2.17.1
GOMOBILE_VERSION: v0.0.0-20260821190718-4776eadac327
TASK_VERSION: 3.52.0
jobs:
build-ios:
@@ -81,7 +81,9 @@ jobs:
- uses: actions/upload-artifact@v7.0.1
with:
name: android-aar
path: dist/mobile.aar
path: |
dist/freeturn.aar
dist/freeturn-sources.jar
retention-days: 7
goreleaser:
@@ -129,3 +131,41 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
name: Telegram announce
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Send release message
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
BODY=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json body -q .body | head -c 3000)
URL=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json url -q .url)
# GH-markdown -> Telegram-HTML: сначала экранируем HTML, потом размечаем.
# Хвосты "by @user in <PR-url>" и ссылки на коммит из авто-нотов убираем - в посте это шум.
HTML=$(printf '%s' "$BODY" \
| sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' \
| sed -E \
-e 's| by @[[:alnum:]_-]+ in https://[^[:space:]]+||g' \
-e 's|[[:space:]]*\(\[[0-9a-f]{6,}\]\(https?://[^)]+/commit/[^)]+\)\)||g' \
-e 's|\[([^]]+)\]\((https?://[^)]+)\)|<a href="\2">\1</a>|g' \
-e 's|\*\*([^*]+)\*\*|<b>\1</b>|g' \
-e 's|`([^`]+)`|<code>\1</code>|g' \
-e 's|^#{1,6}[[:space:]]+(.*)$|<b>\1</b>|' \
-e 's|^[-*][[:space:]]+([0-9a-f]{7,40}:[[:space:]]+)?|• |' \
-e 's|\(@([[:alnum:]_-]+)\)|(<a href="https://github.com/\1">\1</a>)|g')
TEXT=$(printf '🐒💨 <b>FreeTurn (Go) %s</b>\n\n%s\n\n<a href="%s">Релиз на GitHub</a>' "$GITHUB_REF_NAME" "$HTML" "$URL")
send() {
curl -fsS -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=$1" \
--data-urlencode "parse_mode=${2:-}" \
--data-urlencode "disable_web_page_preview=true" \
-o /dev/null
}
# Кривой HTML роняет весь sendMessage - фолбэк plain text, чтобы анонс не пропал
send "$TEXT" HTML || send "$(printf '🚀 FreeTurn %s\n\n%s\n\n%s' "$GITHUB_REF_NAME" "$BODY" "$URL")"
+4
View File
@@ -1,6 +1,7 @@
.idea
.claude
CLAUDE.md
AGENTS.md
*.exe
*.so
*.tmp
@@ -15,6 +16,9 @@ coverage.out
.env
clients.json
client_config.json
vk_persona.json
vk_turn_creds.json
captcha_page.html
node_modules
dist
/mobile-dist/
+19
View File
@@ -15,7 +15,17 @@ linters:
- prealloc
- gosec
- staticcheck
- cyclop
- funlen
- nestif
settings:
cyclop:
max-complexity: 15
funlen:
lines: -1
statements: 60
nestif:
min-complexity: 5
errcheck:
check-type-assertions: true
exclude-functions:
@@ -66,6 +76,15 @@ linters:
- gocritic
path: "^cmd/"
text: "exitAfterDefer"
- linters:
- cyclop
- funlen
- nestif
path: "_test\\.go$"
- linters:
- cyclop
- nestif
path: "internal/provider/vk/internal/(captcha|vkauth)/"
issues:
max-issues-per-linter: 0
max-same-issues: 0
+15 -16
View File
@@ -59,12 +59,10 @@ builds:
- windows_386
- freebsd_amd64
# Android client only. Pure Go (CGO=0), packaged as native .so name
# for use as Android native library (JNI / standalone shipment).
# Android client only. Pure Go (CGO=0), для standalone-запуска в Termux.
- id: client-android
main: ./cmd/client
binary: libfreeturn.so
no_unique_dist_dir: false
binary: client
flags:
- -trimpath
ldflags:
@@ -75,14 +73,6 @@ builds:
- android_arm64
archives:
# Android: ship the .so binary directly (no archive wrapper), with
# arch suffix so arm64 and arm coexist on the release page.
- id: android
ids:
- client-android
formats: [binary]
name_template: "libfreeturn-{{ .Version }}-android-{{ .Arch }}"
# Raw, stable-named binaries (no version in filename) so docs and scripts
# can fetch /releases/latest/download/<binary>-<os>-<arch> directly without
# unpacking a tarball. Shipped uncompressed — no tar.gz/zip archives.
@@ -96,8 +86,7 @@ archives:
{{- with .Arm }}v{{ . }}{{ end }}
{{- with .Mips }}-{{ . }}{{ end }}
# Android client as a raw, stable-named binary for Termux standalone use
# (the libfreeturn-* archive above targets Android-app native-lib shipment).
# Android client как raw-бинарь со стабильным именем - для Termux
- id: raw-android
ids:
- client-android
@@ -106,6 +95,13 @@ archives:
checksum:
name_template: checksums.txt
# Mobile artifacts and install.sh ship via release.extra_files and are not build outputs;
# list them here so hosts (Android Gradle fetch) can verify what they download.
extra_files:
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn.aar{{ end }}'
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn-sources.jar{{ end }}'
- glob: '{{ if not .IsSnapshot }}mobile-dist/Mobile.xcframework.zip{{ end }}'
- glob: scripts/install.sh
sboms:
- artifacts: archive
@@ -150,8 +146,11 @@ release:
draft: false
name_template: "{{ .ProjectName }} {{ .Version }}"
extra_files:
- glob: mobile-dist/mobile.aar
- glob: mobile-dist/Mobile.xcframework.zip
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn.aar{{ end }}'
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn-sources.jar{{ end }}'
- glob: '{{ if not .IsSnapshot }}mobile-dist/Mobile.xcframework.zip{{ end }}'
# Android-приложение стримит этот скрипт по SSH: версия RPC = версия релиза.
- glob: scripts/install.sh
dockers_v2:
- id: server
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.7.3"
".": "4.0.1"
}
+293 -2
View File
@@ -1,5 +1,296 @@
# Changelog
## [4.0.1](https://github.com/samosvalishe/free-turn-proxy/compare/v4.0.0...v4.0.1) (2026-09-24)
### Bug Fixes
* naturalyiiBlondinNaVsyStranuTakoy1 ([a2d9a88](https://github.com/samosvalishe/free-turn-proxy/commit/a2d9a88333dc7627216adb7968291078dde8fa23))
## [4.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.4.0...v4.0.0) (2026-09-23)
### ⚠ BREAKING CHANGES
* **auth:** подтверждение Client ID с повтором по таймеру
* **install:** install.sh с JSON RPC proto 3 и голым WireGuard вместо AWG
### Features
* **auth:** подтверждение Client ID с повтором по таймеру ([d3e4cfc](https://github.com/samosvalishe/free-turn-proxy/commit/d3e4cfc1dbd20f121b15e7f9cc07cdbb39bd4eb3))
* **client:** провайдер direct - прямо на сервер без TURN-реле ([ee30afc](https://github.com/samosvalishe/free-turn-proxy/commit/ee30afc922acbfce9f2bf961f0b7e8fc05cb06c8))
* **diagnostics:** связывать события сессии и попытки восстановления ([e102acb](https://github.com/samosvalishe/free-turn-proxy/commit/e102acbc4be2d73704fb3965083bc44153dfedfa))
* **installer:** вывод всех 3 форматов QR-кодов и поддержка скачивания картинок на ПК ([092ea59](https://github.com/samosvalishe/free-turn-proxy/commit/092ea5916a724251a3cdd1b118d605320a2de157))
* **installer:** генерация QR в PNG, M3 TUI для клиентов и фикс утечки escape-последовательностей ([b894608](https://github.com/samosvalishe/free-turn-proxy/commit/b8946081101241620d2ddf8a9e940ae1243c20b8))
* **installer:** глобальный CLI freeturn, авторизация по Client ID по умолчанию и компактный QR ([8d551c6](https://github.com/samosvalishe/free-turn-proxy/commit/8d551c61aedb9b4125834c2b24f4c486a1f495b1))
* **installer:** раздача QR-кодов и конфигов по веб-ссылкам без вывода в терминал ([065385e](https://github.com/samosvalishe/free-turn-proxy/commit/065385e656085a06bf8079958bf1b563901db64f))
* **installer:** токен на клиента и TTL для веб-раздачи ([e72157f](https://github.com/samosvalishe/free-turn-proxy/commit/e72157f4f7dcd6f1ad866211e4e82dface26cd55))
* **install:** install.sh с JSON RPC proto 3 и голым WireGuard вместо AWG ([0ee0d53](https://github.com/samosvalishe/free-turn-proxy/commit/0ee0d5333ea5020e321ae1c5e1368417ea2e42d3))
* **mobile:** отключаемый кольцевой буфер логов ([a7ec2b9](https://github.com/samosvalishe/free-turn-proxy/commit/a7ec2b949a636de4b32f5a2b2a517ad2e89ec543))
* rebranding ([58924dc](https://github.com/samosvalishe/free-turn-proxy/commit/58924dc91aba0a3a23f20398fd5e2aa8bd787a6e))
* **tcp:** вернуть поддержку bond ([6f84a18](https://github.com/samosvalishe/free-turn-proxy/commit/6f84a18b43da17a03fb5fdab2c0d235569a50f9e))
* **uri:** ссылка на звонок и пейсинг обфускации в freeturn:// ([64f8b63](https://github.com/samosvalishe/free-turn-proxy/commit/64f8b63548edff2ab521be013502680fc5f04df7))
* загрузка локально собранного бинаря на сервер ([af33b47](https://github.com/samosvalishe/free-turn-proxy/commit/af33b47fb20d5a5bb5ebe50481b54f5e33b9a838))
### Bug Fixes
* **captcha:** слать баннер ручной капчи в логгер, а не в stdout ([3b48a17](https://github.com/samosvalishe/free-turn-proxy/commit/3b48a171738e3590a305b60d92ef2181c72a65da))
* **clients:** останавливать горячую перезагрузку базы по контексту ([b621182](https://github.com/samosvalishe/free-turn-proxy/commit/b621182ce4330992f101d59a49871f00ddd17b84))
* **clients:** сохранять авторизацию при ошибке записи базы ([061e3b8](https://github.com/samosvalishe/free-turn-proxy/commit/061e3b882b815aca19e46221eea6b3bbab7019ef))
* **clients:** фиксировать базу на диск через fsync ([ac43e63](https://github.com/samosvalishe/free-turn-proxy/commit/ac43e63ef86e7d27625ce74172cff3c47af2a014))
* **cli:** предупреждать, что встроенный туннель из ссылки не поднимается ([f18e694](https://github.com/samosvalishe/free-turn-proxy/commit/f18e694f1aa320c9c84c30fa01e14e83c806d3ff))
* **dns:** не залипать на первом резолвере doh-форвардера ([ece494a](https://github.com/samosvalishe/free-turn-proxy/commit/ece494aa1b3bbcf0609c926acfbd55a64475d671))
* **dns:** проба udp/53 не теряет второй сервер, doh до смены сети ([b0e3cfe](https://github.com/samosvalishe/free-turn-proxy/commit/b0e3cfe20ae87c7f8893de3f032db9dc5d4c0945))
* **dns:** сбрасывать udp-резолверы, если сессия их не задала ([4865a73](https://github.com/samosvalishe/free-turn-proxy/commit/4865a73b5fc8338ca5e64c0409df960a21ee2c76))
* **installer:** передача obf_profile, obf_key и proxy_mode в systemd и docker args ([5dc5095](https://github.com/samosvalishe/free-turn-proxy/commit/5dc5095e2a6c23e73e63398639bfc91b44d70920))
* **mobile:** отказ protect возвращать ошибкой сокета ([5a1c6e2](https://github.com/samosvalishe/free-turn-proxy/commit/5a1c6e299c1493f04bf0f309340fa8d7f9d473e3))
* **netconn:** отдавать остаток очереди при закрытии пира ([273a5fa](https://github.com/samosvalishe/free-turn-proxy/commit/273a5fa977c407c6573bede0847d6d04ab048eae))
* **relay:** восстановление потоков при отказах turn и занятой квоте ([6570cd9](https://github.com/samosvalishe/free-turn-proxy/commit/6570cd9dd760ae00093778cd5a4a095fd6701118))
* **server:** не ронять процесс паникой в обработчике соединения ([fd5c4f9](https://github.com/samosvalishe/free-turn-proxy/commit/fd5c4f92a59f1d93f7dad6eea974a8a04a9c74f9))
* **sub:** резолвить подписку через dns-диалер приложения ([8b3bea7](https://github.com/samosvalishe/free-turn-proxy/commit/8b3bea7b1a4d57a18d7b629b11f80d182893dddc))
* **task:** заглушки mobile-dist в windows ([639582c](https://github.com/samosvalishe/free-turn-proxy/commit/639582c876289a30add9e1844f6633594cda742e))
* **turn:** отмена контекста прерывает allocate ([6f81528](https://github.com/samosvalishe/free-turn-proxy/commit/6f81528a3473bcffefb69186a4448b186534b36a))
* **udp:** датаграммы до 2048 байт на всём пути ([5b175bf](https://github.com/samosvalishe/free-turn-proxy/commit/5b175bf4be4c48e0c609122ec24fee18cacf1eea))
* **udp:** завершать релей при отказе чтения локального сокета ([f4e8e0c](https://github.com/samosvalishe/free-turn-proxy/commit/f4e8e0cfe06b1ed0440a2a0b2009409ac4c87212))
* **udp:** логировать причину обрыва DTLS-сессии перед паузой ([caa8fea](https://github.com/samosvalishe/free-turn-proxy/commit/caa8fea5bf08fec9053c2aa7f475c719556cae49))
* **udp:** ограничить очередь между сокетом и DTLS ([5508cd6](https://github.com/samosvalishe/free-turn-proxy/commit/5508cd65a79812e5352fda83eb0eb7ab1d9c7db5))
* **udp:** ошибка obf до аллокации, без петли allocate ([c13490f](https://github.com/samosvalishe/free-turn-proxy/commit/c13490ff5514e54dcaf6f44483bd3be8e4802d4e))
* **ui:** убрать утечку OSC 11/CPR escape-последовательностей при выводе плашек ([5568670](https://github.com/samosvalishe/free-turn-proxy/commit/5568670f515e34b23fc67b7ae17b63c80b510303))
* **vk:** определять ошибки авторизации по sentinel, а не по тексту ([3f9b3b6](https://github.com/samosvalishe/free-turn-proxy/commit/3f9b3b6cd008dd0d6505eed080f8c78321cb249b))
* гард для релизера ([192dd27](https://github.com/samosvalishe/free-turn-proxy/commit/192dd27a1e7bd5a32beb2e6d57ad59b12867edbe))
* отбрасывать опоздавший повтор client id, общий backoff квоты ([05bbe99](https://github.com/samosvalishe/free-turn-proxy/commit/05bbe996c1c44aa94484a6e7af28daf16aaca23f))
### Performance
* **netconn:** адрес пайпа без аллокации на каждое чтение ([b1cbafd](https://github.com/samosvalishe/free-turn-proxy/commit/b1cbafda736b5efbb607b602379f13a487104f5d))
* **obf:** держать поток ChaCha8 в соединении rtpopus3 ([f422acb](https://github.com/samosvalishe/free-turn-proxy/commit/f422acb92794149eb8a45d9004736152d39f700f))
* **randx:** rejection sampling вместо math/big ([e32008b](https://github.com/samosvalishe/free-turn-proxy/commit/e32008bba28670c7fda00bff007f6f9fb5f42580))
* **tcp:** смерть smux-сессии без поллинга ([5b6ac18](https://github.com/samosvalishe/free-turn-proxy/commit/5b6ac183321bcba27d4890a8a9d30854293d9e0e))
* **udp:** убрать сисколлы и аллокации с горячего пути релея ([a0c4656](https://github.com/samosvalishe/free-turn-proxy/commit/a0c46560251497d021932789aa339618f0caa700))
### Refactoring
* **cli:** общий обработчик сигналов для клиента и сервера ([69dd5f9](https://github.com/samosvalishe/free-turn-proxy/commit/69dd5f9488805b31f2fdf81e569d0228773ddc94))
* **config:** брать режимы dns из dnsdial ([dd0bc21](https://github.com/samosvalishe/free-turn-proxy/commit/dd0bc211b7c9113c385e4a9fde8178de5fb015d7))
* **dtls:** убрать дублирующий doc.go ([7df3a3f](https://github.com/samosvalishe/free-turn-proxy/commit/7df3a3fd91c9d933bf8841e86d1cd45d039f116d))
* **logx:** общий Holder вместо пакетных переменных Log ([56dfc9a](https://github.com/samosvalishe/free-turn-proxy/commit/56dfc9aedd0aa5c6cacdc36c4cf4c62dd40eb9f3))
* **vk:** не держать write-lock кеша реквизитов во время запроса ([0a3becc](https://github.com/samosvalishe/free-turn-proxy/commit/0a3becc74d751fb4e1185bffd597cbb8c9c2c1f7))
* **wake:** убрать неиспользуемый Notifier ([7e5b2d4](https://github.com/samosvalishe/free-turn-proxy/commit/7e5b2d4cde87c73a4d20df17de2e770e1ef37089))
* **wire:** один listener на все профили обфускации ([322c700](https://github.com/samosvalishe/free-turn-proxy/commit/322c7001fb58b489ee8fbb992afadf165719c2de))
* сложность функций под лимиты линтера ([a8b8363](https://github.com/samosvalishe/free-turn-proxy/commit/a8b8363b79495dbc990764c45758e1e70378421c))
## [3.4.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.2...v3.4.0) (2026-09-07)
### Features
* профиль freeturn vpn со вшитым wg и фикс ui_spin ([9da2c8e](https://github.com/samosvalishe/free-turn-proxy/commit/9da2c8ebd27f26e52cd79bceb14dbc5eac732575))
## [3.3.2](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.1...v3.3.2) (2026-09-07)
### Bug Fixes
* небольшие улучшения ([c435605](https://github.com/samosvalishe/free-turn-proxy/commit/c435605fe20ef6b5cbb9b6d6149f374d3aa9a3df))
* собственный iface для awg ([82907f3](https://github.com/samosvalishe/free-turn-proxy/commit/82907f3cc60cdb5a59aafe2633ec99c519f916bb))
## [3.3.1](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.0...v3.3.1) (2026-09-07)
### Bug Fixes
* installer ([408b68e](https://github.com/samosvalishe/free-turn-proxy/commit/408b68e960a668898ad39056f9f0e3b87a187b37))
* installer ([032d1fa](https://github.com/samosvalishe/free-turn-proxy/commit/032d1fac562498b1d4c54f06978d38807b7d435b))
* release awg img ([c447fc4](https://github.com/samosvalishe/free-turn-proxy/commit/c447fc405b0fb918e5ba2576f60bd60f726dd442))
## [3.3.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.2.0...v3.3.0) (2026-09-06)
### Features
* **scripts:** поддержка amneziawg 3.1, управление клиентами и qr-коды в установщике ([daf8cec](https://github.com/samosvalishe/free-turn-proxy/commit/daf8cece80ba388788b67596168c79f4b0741e69))
* прямой туннель без релея ([2812ded](https://github.com/samosvalishe/free-turn-proxy/commit/2812ded2ed4a6c2d9c0f44bf0e79e218a5a801dc))
### Bug Fixes
* fmt ([67f9a4f](https://github.com/samosvalishe/free-turn-proxy/commit/67f9a4fe4e3f6578b5fb88c976878fcc42e87648))
* **tunnel:** race ([02c696f](https://github.com/samosvalishe/free-turn-proxy/commit/02c696fa28ac687e979a4c11f9bf630746ef3098))
* **tunnel:** обработка защиты сокетов и стабильность прямого режима ([1cc5e93](https://github.com/samosvalishe/free-turn-proxy/commit/1cc5e93d0333d107b7193c6d74c87d27b737ab21))
## [3.2.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.1.1...v3.2.0) (2026-08-25)
### Features
* **proxy:** tcp-режим для xray/sing-box поверх turn ([f712909](https://github.com/samosvalishe/free-turn-proxy/commit/f7129099f711d627f6645fd18fd4c9be2b8a1437))
* **session:** реконнект релея без пересоздания туннеля ([f61dc8c](https://github.com/samosvalishe/free-turn-proxy/commit/f61dc8c9578eb76f6cdf0a686af024ebbeab5b6b))
* **tunnel:** параметры обфускации awg 3+ ([77fbdec](https://github.com/samosvalishe/free-turn-proxy/commit/77fbdec70703fd7222d58867cf033b371ab41849))
### Bug Fixes
* **captcha:** не жечь персону, когда страница не дошла до вердикта ([1547aae](https://github.com/samosvalishe/free-turn-proxy/commit/1547aaeed74bb23d45dd15f1d5cc030000c43481))
* **core:** паника горутины больше не убивает процесс приложения ([748d5a0](https://github.com/samosvalishe/free-turn-proxy/commit/748d5a07ce21bec1620e3a4c55b5f2f33171fb99))
* **logs:** убрать пострим-шум из лога без debug ([c2a9f0f](https://github.com/samosvalishe/free-turn-proxy/commit/c2a9f0f1d7054669eda7817a2f9d8d067573dcc4))
* **udprelay:** не зависать на фатальной ошибке провайдера ([4863161](https://github.com/samosvalishe/free-turn-proxy/commit/48631616f143bc07f93d4a6cf6d5d2647022129e))
* **udprelay:** фатальная ошибка не ждёт барьер прогрева ([03eb41e](https://github.com/samosvalishe/free-turn-proxy/commit/03eb41e2fb5cad671c52bb4807d424688145ba36))
* **vkauth:** не считать отменённый поход за реквизитами нагрузкой на провайдера ([623ef46](https://github.com/samosvalishe/free-turn-proxy/commit/623ef46f2f908d4b5d011b1b099928d7a2e3714c))
* **vkauth:** сбрасывать реквизиты при неподтверждённом deallocate ([7fc7fe2](https://github.com/samosvalishe/free-turn-proxy/commit/7fc7fe2e79906d5cd386da65e2064c2e159ae891))
### Performance
* **udprelay:** общий шлюз на allocate вместо per-stream джиттера ([e63279d](https://github.com/samosvalishe/free-turn-proxy/commit/e63279db185ae6205080fab6b4a9d4dd36e5c29c))
## [3.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v3.1.0...v3.1.1) (2026-08-19)
### Bug Fixes
* **captcha:** адаптация ([9d454db](https://github.com/samosvalishe/free-turn-proxy/commit/9d454dbd419961ccdd2eebfeaacede4f40672fa8))
## [3.1.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.0.0...v3.1.0) (2026-08-18)
### Features
* **server:** адрес клиента в логах сессии и handshake ([c670378](https://github.com/samosvalishe/free-turn-proxy/commit/c6703783c4a796045c0c385a8705c2756a1d8710))
### Bug Fixes
* ci ([c3fc939](https://github.com/samosvalishe/free-turn-proxy/commit/c3fc9396d4a38f6c77644a6bce04f1ed615aa397))
* ci ([022692e](https://github.com/samosvalishe/free-turn-proxy/commit/022692ea18e012f8bc8feb2c1e5bb96e4998fc0e))
* **session:** рецикл после пробуждения только при тишине в канале ([9c2cc93](https://github.com/samosvalishe/free-turn-proxy/commit/9c2cc93a1caa2ecbea87be8501dd13e6fb358071))
* **udprelay:** рецикл turn-аллокации пересоздаёт dtls-сессию ([fd9c6ab](https://github.com/samosvalishe/free-turn-proxy/commit/fd9c6abf1e3be6e570af41ab03522c584a06df46))
* **vkauth:** отмена капчи не сжигает персону ([60e3a69](https://github.com/samosvalishe/free-turn-proxy/commit/60e3a69a7d237d1635cd840bbbeaaada6b56a562))
## [3.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.2...v3.0.0) (2026-08-17)
### ⚠ BREAKING CHANGES
* флаги -mode и -bond удалены; конфиги и ссылки freeturn:// с tcp-режимом больше не принимаются.
### Features
* comment cleanup ([648c554](https://github.com/samosvalishe/free-turn-proxy/commit/648c55415b210eb0feea640e0a9f444b1fa07954))
* **mobile:** переживание сна и рестарта на Android ([681dde8](https://github.com/samosvalishe/free-turn-proxy/commit/681dde8a6d98ccab10a2aa9eb865e5b3eaf86595))
### Bug Fixes
* **captcha:** персонная модель PoW и кэш TURN-кредов ([6b14a95](https://github.com/samosvalishe/free-turn-proxy/commit/6b14a95434b31bf505ed02c6764d0b2fce7c5351))
* **captcha:** устранение регрессий ([5b5c93f](https://github.com/samosvalishe/free-turn-proxy/commit/5b5c93fae16dfcaa3de7b125ce34c0991180b831))
* readme ([d208a0d](https://github.com/samosvalishe/free-turn-proxy/commit/d208a0d3df5546ea883662d36b457df980cc416a))
* soft reconnect on network handover without tunnel drop ([0bef2e8](https://github.com/samosvalishe/free-turn-proxy/commit/0bef2e8ab5cd7c3db3e8ff5f3ee9c8ae67f80832))
* **udprelay:** рецикл аллокации на простое туннеля ([a033cfb](https://github.com/samosvalishe/free-turn-proxy/commit/a033cfb9a2097365e144fa79a55ecaf12dc07c3e))
* **vkauth:** ловим 486 как auth-error для рецикла аллокации ([5fdd8c3](https://github.com/samosvalishe/free-turn-proxy/commit/5fdd8c31123f5bcbe9ba4fa2677b604bc6da9294))
* возврат фонового детектора сна для рецикла аллокаций ([53b8345](https://github.com/samosvalishe/free-turn-proxy/commit/53b8345aa07dd4375ba5646a3f14a309351bb147))
* убрал лишний артефакт ([6bc4faf](https://github.com/samosvalishe/free-turn-proxy/commit/6bc4faf9423be9278f46642fbf29ee92bb46b5d3))
* устранение предупреждений линтера ([49eecb3](https://github.com/samosvalishe/free-turn-proxy/commit/49eecb34784017e01d8077e8bbe178ed9c0ba023))
* устранение предупреждений линтера ([65bc8a8](https://github.com/samosvalishe/free-turn-proxy/commit/65bc8a89932bcbc5cdc27163adc46cf118428901))
### Performance
* меньше фоновых таймеров при поднятом туннеле и опрос метрик только при видимом окне ([3cf7831](https://github.com/samosvalishe/free-turn-proxy/commit/3cf78316df14425e3811caaf483bf92eebe52cfc))
### Breaking Changes
* выпил tcp-режим туннеля ([74241de](https://github.com/samosvalishe/free-turn-proxy/commit/74241de764a428d9c3fee1aeae0d917851d1d542))
## [2.1.2](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.1...v2.1.2) (2026-08-14)
### Bug Fixes
* **captcha:** fmt ([95803f4](https://github.com/samosvalishe/free-turn-proxy/commit/95803f481a683056651768225bc0c64e177ad468))
* **captcha:** выровнять авторешение по живому браузеру ([235ab1a](https://github.com/samosvalishe/free-turn-proxy/commit/235ab1a6463a9758223d38f0eacc4c630d80a4a5))
* **deps:** обновить Go 1.26.5 -&gt; 1.26.6 ([5bc164f](https://github.com/samosvalishe/free-turn-proxy/commit/5bc164f78da2b54a2013c3e046f7d02799ceaf20))
## [2.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.0...v2.1.1) (2026-08-04)
### Bug Fixes
* **captcha:** блокировать рекламные хосты в ручном режиме ([4174f29](https://github.com/samosvalishe/free-turn-proxy/commit/4174f29c63c0d5c7ffa645de9b0717a60d6849cb))
* **captcha:** линтер в трассировке запросов ([59a8c84](https://github.com/samosvalishe/free-turn-proxy/commit/59a8c84157750be7b6219d57891c120eb2393230))
* **captcha:** не жечь персону на эскалации checkbox -&gt; slider ([ee13563](https://github.com/samosvalishe/free-turn-proxy/commit/ee1356308bb2a1b67b48c11d841a330d61a1b0cd))
* **captcha:** обновить пин debug_info под виджет 1.1.1388 ([ea477a3](https://github.com/samosvalishe/free-turn-proxy/commit/ea477a3bc21d7fd0b8dedfe6ae201b6950fda301))
* **captcha:** привести запросы к поведению живого виджета 1.1.1388 ([4fa7810](https://github.com/samosvalishe/free-turn-proxy/commit/4fa7810afa35bd086f30b66e3e0660df702ba3fe))
* **captcha:** разносить попытки слайдера по дорожке ([8eb3873](https://github.com/samosvalishe/free-turn-proxy/commit/8eb3873d27ffb679eddc2a2f230b5a58f9d6708a))
* **captcha:** убрать reverse-скор из ранжирования slider-кандидатов ([d9a0554](https://github.com/samosvalishe/free-turn-proxy/commit/d9a05544891e8f7354758bfbc06b0e5e9ea7eba7))
### Refactoring
* **captcha:** убрать трассировку запросов ([1ca3451](https://github.com/samosvalishe/free-turn-proxy/commit/1ca3451b7690179e4d00f318f471251062a9ee65))
## [2.1.0](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.2...v2.1.0) (2026-08-01)
### Features
* **proxy:** встроенное управление маршрутами к TURN-серверам ([a7798cd](https://github.com/samosvalishe/free-turn-proxy/commit/a7798cd851ab7091526a6131e71bf8e5c37a1ff2))
### Bug Fixes
* **routes:** linter ([cdc7ee8](https://github.com/samosvalishe/free-turn-proxy/commit/cdc7ee8d3d71b9d04a6a1b238bbf2d5263f2603e))
### Refactoring
* перенос логики сессии в internal/session и обновление mobile-фасада ([1f86fda](https://github.com/samosvalishe/free-turn-proxy/commit/1f86fda104b09f4b61e065c4b0bda97cec4c04bd))
## [2.0.2](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.1...v2.0.2) (2026-07-30)
### Bug Fixes
* **captcha:** адаптация под новую версию виджета ([7c28498](https://github.com/samosvalishe/free-turn-proxy/commit/7c28498f2336967008e41542389f29c5637b6795))
## [2.0.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.0...v2.0.1) (2026-07-28)
### Bug Fixes
* **captcha:** дожимать эскалацию checkbox -&gt; slider в той же сессии ([d1b769a](https://github.com/samosvalishe/free-turn-proxy/commit/d1b769a0625a6d706e1cda8f6df6ab80871df856))
## [2.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.8.0...v2.0.0) (2026-07-28)
### ⚠ BREAKING CHANGES
* **captcha:** флаг -browser убран, семейство персоны всегда Chrome - только Chromium даёт NetworkInformation и Generic Sensors, без которых телеметрия captcha неполна. Класс устройства по-прежнему за -platform.
### Features
* **captcha:** телеметрия виджета и одна Chrome-персона на установку ([3211ed9](https://github.com/samosvalishe/free-turn-proxy/commit/3211ed9ad56b5e6ed6cfe679e000da197e17437d))
### Bug Fixes
* update deps for vuln pass ([34746e2](https://github.com/samosvalishe/free-turn-proxy/commit/34746e27e03a31ccdcdb8f481414b7e6a6177b55))
## [1.8.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.3...v1.8.0) (2026-07-13)
### Features
* автопубликация телеграм анонса при релизе ([e678ee1](https://github.com/samosvalishe/free-turn-proxy/commit/e678ee1fd85c64eb42da3ba9ccc580fccbc79749))
### Bug Fixes
* **captcha:** persona-модель вместо захвата профиля браузера ([305754c](https://github.com/samosvalishe/free-turn-proxy/commit/305754cd6037fe3b4ec5558b826a89eefc64834b))
## [1.7.3](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.2...v1.7.3) (2026-07-12)
@@ -261,7 +552,7 @@
* **docker:** исправить сборку, добавить compose, убрать VLESS_BOND ([dfbfae2](https://github.com/samosvalishe/free-turn-proxy/commit/dfbfae29014b3cd246550589640e871ee424807e))
* **dtlsdial:** Dial использует хелпер GenerateSelfSignedCert внутри ([21138fe](https://github.com/samosvalishe/free-turn-proxy/commit/21138fef26b61f8ba4b72f8e5389d3b90b7026b2))
* **dtlsdial:** унифицировать генерацию self-signed сертификата ([ef549dc](https://github.com/samosvalishe/free-turn-proxy/commit/ef549dc2b8405a1e8378d9c81f2ae3871495121f))
* **install:** переписать установщик сервера — TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
* **install:** переписать установщик сервера - TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
* **lint:** устранить замечания golangci-lint + переход на dockers_v2 ([dd82332](https://github.com/samosvalishe/free-turn-proxy/commit/dd8233208ccbe60ea17745ddc17df7a8e6c56872))
* **routes:** починить установку маршрутов в routes.ps1 на Windows ([7b0a907](https://github.com/samosvalishe/free-turn-proxy/commit/7b0a9076380bbcdc2f83d6da63ca3800b5d68412))
* **server:** ограниченное ожидание второго сигнала; предупреждение при выключенном -wrap ([fb711c1](https://github.com/samosvalishe/free-turn-proxy/commit/fb711c12f5e1f78ee037ceed434b6cdde5a961d8))
@@ -306,7 +597,7 @@
* **tcpfwd:** заменить busy-loop poll пула на Ready-канал; тихий accept-цикл при shutdown ([2b24471](https://github.com/samosvalishe/free-turn-proxy/commit/2b24471553b7a9acb0c0117d7ce666bac758e97e))
* **udprelay:** разбить на run/loop/listener ([d82258f](https://github.com/samosvalishe/free-turn-proxy/commit/d82258f844ef65ba06ba05aa7de4130c43f2c56e))
* **vkauth:** разбить token.go на файлы по шагам ([0e2d42c](https://github.com/samosvalishe/free-turn-proxy/commit/0e2d42c9126505599db25d8c6f82956036c193be))
* **wire:** переименовать srtpmimicry → rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
* **wire:** переименовать srtpmimicry -> rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
* **wrap:** заменить DTLS-мимикрию на noise-only AEAD ([e2dd09a](https://github.com/samosvalishe/free-turn-proxy/commit/e2dd09a7296f972285850786c6552d66006bf19d))
* **wrap:** перейти на мимикрию под SRTP в обход content-фильтра VK TURN ([729557d](https://github.com/samosvalishe/free-turn-proxy/commit/729557d00d3f7b16e4d1d876adca23404eff738c))
* **wrap:** переписать как мимикрию под DTLS 1.2 ApplicationData с AEAD ([39f95a8](https://github.com/samosvalishe/free-turn-proxy/commit/39f95a8e0c2ca3520c530e425aa55a5b586feadb))
+16 -12
View File
@@ -1,7 +1,5 @@
<div align="center">
<img src="logo.webp" height="250">
![License](https://img.shields.io/badge/license-Happy_Bunny-ff69b4?style=flat-square&logoColor=white&labelColor=0D1117)
![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white&labelColor=0D1117)
![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white&labelColor=0D1117)
@@ -10,7 +8,21 @@
## О проекте
**Free Turn Proxy** - универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, Xray/VLESS) до сервера на VPS, используя DTLS и мощные механизмы маскировки пакетов.
**Free Turn Proxy** - универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, AmneziaWG, Xray/VLESS) до сервера на VPS, используя DTLS и механизмы маскировки пакетов.
## Android-приложение - рекомендуемый способ использования
- Добавляйте подключения по ссылке, QR-коду или вручную.
- Устанавливайте и настраивайте сервер на VPS из приложения.
- Переключайте серверы, настраивайте раздельное туннелирование и делитесь доступом.
- Сохраняйте настройки в зашифрованную резервную копию.
[Скачать APK](https://github.com/samosvalishe/turn-proxy-android/releases/latest)
Требования: Android 7.0+ и устройство ARM (arm64-v8a или armeabi-v7a). Для собственного подключения нужен VPS; в режиме Relay также нужна ссылка на звонок.
> [!WARNING]
> При переходе Android-приложения на версию 5 потребуется настройка с нуля. Перед обновлением сохраните данные доступа к VPS и параметры подключения. Затем заново добавьте сервер или импортируйте новую ссылку и проверьте параметры VPN и раздельного туннелирования.
## Разработка
@@ -32,7 +44,7 @@ task # список доступных задач
task build # собрать client + server в dist/ для текущего хоста
task build:all # кросс-сборка всех target через goreleaser snapshot
task build:ios # сборка XCFramework для iOS в dist/Mobile.xcframework
task build:android # сборка AAR архива для Android в dist/mobile.aar
task build:android # сборка AAR архива для Android в dist/freeturn.aar
task test # go test -race
task test:cover # тесты + покрытие -> cover.html
task lint # golangci-lint
@@ -64,11 +76,3 @@ task clean # удалить dist/, cover.out, cover.html
- [@Moroka8](https://github.com/Moroka8)
- [@alxmcp](https://github.com/alxmcp)
- [@TheAirBlow](https://github.com/TheAirBlow)
---
<div align="center">
Telegram канал: [Free Turn](https://t.me/+5BdkU4q_CGQyNTdi)
</div>
+33 -8
View File
@@ -6,9 +6,10 @@ vars:
sh: git describe --tags --always --dirty 2>/dev/null || echo dev
LDFLAGS: -s -w -checklinkname=0 -X main.version={{.VERSION}}
GOLANGCI_VERSION: v2.12.2
GOVULN_VERSION: v1.1.4
GORELEASER_VERSION: v2.15.4
TASK_VERSION: v3.51.1
GOVULN_VERSION: v1.6.0
GOIMPORTS_VERSION: v0.49.0
GORELEASER_VERSION: v2.17.1
TASK_VERSION: v3.52.0
tasks:
default:
@@ -25,6 +26,11 @@ tasks:
build:all:
desc: Cross-compile all targets via goreleaser snapshot
cmds:
# checksum.extra_files глобит mobile-dist, а снапшот идёт без mobile-таргетов
- cmd: mkdir -p mobile-dist && touch mobile-dist/freeturn.aar mobile-dist/freeturn-sources.jar mobile-dist/Mobile.xcframework.zip
platforms: [linux, darwin]
- cmd: powershell -NoProfile -Command 'New-Item -ItemType Directory -Force mobile-dist | Out-Null; "freeturn.aar","freeturn-sources.jar","Mobile.xcframework.zip" | ForEach-Object { $p = "mobile-dist/$_"; if (-not (Test-Path $p)) { New-Item -ItemType File $p | Out-Null } }'
platforms: [windows]
- goreleaser release --snapshot --clean --skip=publish,sign
build:ios:
@@ -36,14 +42,23 @@ tasks:
- gomobile bind -target ios,iossimulator -ldflags "-checklinkname=0" -o dist/Mobile.xcframework ./mobile
build:android:
desc: Build Android Archive (AAR) to dist/mobile.aar
desc: Build Android Archive (AAR) to dist/freeturn.aar
vars:
# ABI ровно те, что собирает приложение (splits в build.gradle.kts).
# androidapi 24 - minSdk приложения.
# -s -w срезают отладочную информацию: 23 МБ -> 13 МБ на оба ABI.
GOMOBILE_FLAGS: >-
-target android/arm64,android/arm
-androidapi 24
-javapkg com.freeturn.core
-ldflags "-checklinkname=0 -s -w -X github.com/samosvalishe/free-turn-proxy/mobile.version={{.VERSION}}"
cmds:
- rm -rf dist/mobile.aar dist/mobile-sources.jar
- rm -rf dist/freeturn.aar dist/freeturn-sources.jar
- mkdir -p dist
- cmd: gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.aar ./mobile
- cmd: gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile
platforms: [linux, darwin]
- cmd: |
powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.aar ./mobile'
powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile'
platforms: [windows]
test:
@@ -51,6 +66,11 @@ tasks:
cmds:
- go test -race -count=1 ./...
test:sh:
desc: Run install.sh tests (bats)
cmds:
- bats scripts/tests
test:cover:
desc: Run tests with coverage report (cover.html)
cmds:
@@ -89,6 +109,11 @@ tasks:
cmds:
- govulncheck ./...
captcha:page:
desc: Fetch a live VK captcha page (diff it against the parser fixture)
cmds:
- bash scripts/captcha_page.sh {{.CLI_ARGS | default "captcha_page.html"}}
tidy:
desc: Tidy go.mod / go.sum
cmds:
@@ -101,7 +126,7 @@ tasks:
tools:install:
desc: Install dev tools (golangci-lint, govulncheck, goreleaser, goimports)
cmds:
- go install golang.org/x/tools/cmd/goimports@latest
- go install golang.org/x/tools/cmd/goimports@{{.GOIMPORTS_VERSION}}
- go install golang.org/x/vuln/cmd/govulncheck@{{.GOVULN_VERSION}}
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}}
- go install github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}}
+33 -240
View File
@@ -6,45 +6,29 @@ import (
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"crypto/rand"
"encoding/hex"
"encoding/json"
"path/filepath"
"github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial"
"github.com/samosvalishe/free-turn-proxy/internal/clientid"
"github.com/samosvalishe/free-turn-proxy/internal/config"
"github.com/samosvalishe/free-turn-proxy/internal/logx"
"github.com/samosvalishe/free-turn-proxy/internal/provider"
"github.com/samosvalishe/free-turn-proxy/internal/provider/multi"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bondclient"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpfwd"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/udprelay"
"github.com/samosvalishe/free-turn-proxy/internal/session"
"github.com/samosvalishe/free-turn-proxy/internal/shutdown"
"github.com/samosvalishe/free-turn-proxy/internal/sub"
"github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
)
// version is populated at build time via -ldflags "-X main.version=...".
var version = "dev"
const dtlsHandshakeConcurrency = 3
func main() {
args := os.Args[1:]
// -sub: тянем список серверов до парсинга и подсовываем URI первой ноды
// (Nodes[0], без failover) позиционным freeturn:// - ParseClient применит его
// тем же путём, что и URI из CLI. Подписка должна стоять до парсинга: она даёт
// peer, без которого ParseClient падает на валидации.
// Резолв подписки до парсинга даёт обязательный peer для валидации.
if subURL := config.PeekSubURL(args); subURL != "" {
sub.SetLogger(logx.New(false))
s, ferr := sub.Fetch(context.Background(), subURL)
if ferr != nil {
log.Fatalf("failed to fetch subscription: %v", ferr)
@@ -57,250 +41,59 @@ func main() {
cfg, err := config.ParseClient(args, os.Stderr)
if err != nil {
// -help/-h: usage уже напечатан в ParseClient, выходим штатно.
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
// логгер ещё не создан - единственный fatal до его инициализации.
log.Fatalf("%v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg.ClientID = resolveClientID(cfg.ClientID)
logger := logx.New(cfg.Log.Debug)
logger.Infof("Free Turn Proxy client version=%s", version)
logger.Infof("Client ID: %s", cfg.ClientID)
dnsdial.SetLogger(logger)
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-signalChan
logger.Infof("Terminating...")
cancel()
select {
case <-signalChan:
case <-time.After(5 * time.Second):
}
logger.Errorf("Exit...")
cancel()
os.Exit(1)
}()
if cfg.DNS.Servers != nil {
dnsdial.SetUDPDNSServers(cfg.DNS.Servers)
logger.Infof("[DNS] using custom UDP servers: %v", cfg.DNS.Servers)
}
appDialer := dnsdial.AppDialer(cfg.DNS.Mode)
dnsdial.InstallGlobalResolver(cfg.DNS.Mode)
if cfg.Obf.GenKey {
key, gerr := rtpopus.GenKeyHex()
if gerr != nil {
logger.Errorf("gen-obf-key: %v", gerr)
os.Exit(1)
log.Fatalf("gen-obf-key: %v", gerr)
}
fmt.Println(key)
return
}
peer, err := net.ResolveUDPAddr("udp", cfg.Proxy.Peer)
logger := logx.New(cfg.Log.Debug)
logger.Infof("Free Turn Proxy client version=%s", version)
idPaths := clientid.DefaultPaths()
id, persisted, err := clientid.Resolve(cfg.ClientID, idPaths)
if err != nil {
logger.Errorf("resolve peer addr: %v", err)
logger.Errorf("%v", err)
os.Exit(1)
}
if cfg.Obf.Enabled() {
logger.Infof("OBF profile=%s: peer server must use matching -obf-profile and -obf-key", cfg.Obf.Profile)
if !persisted {
logger.Warnf("client ID не сохранён ни по одному пути (%v) - будет новым при следующем запуске", idPaths)
}
cfg.ClientID = id
logger.Infof("Client ID: %s", cfg.ClientID)
if cfg.Tunnel.Enabled() {
logger.Warnf("ссылка содержит конфиг %s: CLI встроенный туннель не поднимает, запустите WireGuard/AmneziaWG отдельно", cfg.Tunnel.Mode)
cfg.Tunnel.Mode = tunnel.ModeNone
}
var connectedStreams atomic.Int32
ctx, stop := shutdown.Watch(context.Background(), logger)
defer stop()
prov, err := buildProvider(cfg, appDialer, &connectedStreams, logger)
sess, err := session.New(cfg, session.Deps{
Logger: logger,
Solver: vk.DefaultManualSolver,
})
if err != nil {
logger.Errorf("provider init: %v", err)
logger.Errorf("%v", err)
os.Exit(1)
}
logger.Infof("provider=%s", prov.Name())
getCreds := func(ctx context.Context, streamID int) (string, string, []string, error) {
c, err := prov.GetCredentials(ctx, streamID)
if err != nil {
return "", "", nil, err
}
return c.User, c.Pass, c.ServerAddrs, nil
}
// Несколько -links расширяют пул: каждый звонок даёт cfg.TURN.N стримов,
// все объединяются в общий пул (больше параллельных TURN-аллокаций).
totalStreams := cfg.TURN.N * max(len(cfg.VK.Links), 1)
if cfg.Proxy.Mode != config.ProxyModeUDP {
tcpDtlsDialer := &dtlsdial.Dialer{
HandshakeTimeout: 30 * time.Second,
HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency),
}
bondH := &bondclient.Handler{Deps: bondclient.Deps{Log: logger}}
tcpDeps := &tcpfwd.Deps{
DTLSDialer: tcpDtlsDialer,
Log: logger,
BondHandler: bondH.Handle,
}
tcpParams := &tcpfwd.Params{
Host: cfg.TURN.Host,
Port: cfg.TURN.Port,
TransportUDP: cfg.TURN.TransportUDP,
Profile: string(cfg.Obf.Profile),
ObfKey: cfg.Obf.Key,
GetCreds: tcpfwd.GetCredsFunc(getCreds),
KCPProfile: cfg.KCP.Profile,
KCPFEC: cfg.KCP.FEC,
ClientID: cfg.ClientID,
}
if err := tcpfwd.Run(ctx, tcpDeps, tcpParams, peer, cfg.Proxy.Listen, totalStreams, cfg.Proxy.Mode == config.ProxyModeTCPFwdBond); err != nil {
logger.Errorf("tcpfwd: %v", err)
os.Exit(1)
}
return
}
udpDtlsDialer := &dtlsdial.Dialer{
HandshakeTimeout: 20 * time.Second,
HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency),
}
udpParams := &udprelay.Params{
Host: cfg.TURN.Host,
Port: cfg.TURN.Port,
TransportUDP: cfg.TURN.TransportUDP,
Profile: string(cfg.Obf.Profile),
ObfKey: cfg.Obf.Key,
ObfTiming: cfg.Obf.Timing,
GetCreds: udprelay.GetCredsFunc(getCreds),
ClientID: cfg.ClientID,
}
if err := udprelay.Run(ctx, udpDtlsDialer, prov, logger, &connectedStreams, udpParams, peer, cfg.Proxy.Listen, totalStreams); err != nil {
if err := sess.Run(ctx); err != nil {
if errors.Is(err, udprelay.ErrFatal) {
logger.Errorf("udprelay: fatal: %v", err)
logger.Errorf("fatal: %v", err)
} else {
logger.Errorf("udprelay: %v", err)
logger.Errorf("%v", err)
}
os.Exit(1)
}
}
// buildProvider строит provider.Provider по cfg.Provider.Name. Одна -links даёт
// обычный vk.Provider; несколько - multi.Provider (один vk.Provider на ссылку).
func buildProvider(cfg *config.Client, dialer net.Dialer, connected *atomic.Int32, logger logx.Logger) (provider.Provider, error) {
switch cfg.Provider.Name {
case config.ProviderVK:
if len(cfg.VK.Links) == 0 {
return nil, fmt.Errorf("vk: no links configured")
}
newVK := func(link string) (provider.Provider, error) {
return vk.New(vk.Config{
Link: link,
Dialer: dialer,
ManualOnly: cfg.VK.ManualCaptcha,
Browser: string(cfg.VK.Browser),
StreamsPerCache: cfg.VK.StreamsPerCred,
StreamsAlive: connected.Load,
Log: logger,
Debug: cfg.Log.Debug,
}, vk.DefaultManualSolver)
}
if len(cfg.VK.Links) == 1 {
return newVK(cfg.VK.Links[0])
}
providers := make([]provider.Provider, 0, len(cfg.VK.Links))
for i, link := range cfg.VK.Links {
p, err := newVK(link)
if err != nil {
return nil, fmt.Errorf("vk provider [%d]: %w", i, err)
}
providers = append(providers, p)
}
logger.Infof("multi-provider: %d VK links, %d total streams", len(providers), cfg.TURN.N*len(providers))
return multi.New(providers), nil
default:
return nil, fmt.Errorf("unknown provider %q", cfg.Provider.Name)
}
}
func resolveClientID(cliID string) string {
if cliID != "" {
return cliID
}
type localCfg struct {
ClientID string `json:"client_id"`
}
paths := clientConfigPaths()
// Чтение: первый файл с непустым client_id.
for _, path := range paths {
b, err := os.ReadFile(path) //nolint:gosec // фиксированное имя из clientConfigPaths, не пользовательский ввод
if err != nil {
continue
}
var lc localCfg
if err := json.Unmarshal(b, &lc); err == nil && lc.ClientID != "" {
return lc.ClientID
}
}
// Generate 16 bytes hex ID
idBytes := make([]byte, 16)
if _, err := rand.Read(idBytes); err != nil {
log.Fatalf("failed to generate random client ID: %v", err)
}
newID := hex.EncodeToString(idBytes)
lc := localCfg{ClientID: newID}
b, _ := json.MarshalIndent(lc, "", " ")
// Запись: первый доступный для записи путь. На Android каталог рядом с
// бинарём (/data/app/.../lib/arm64) read-only - падаем на UserConfigDir,
// затем TempDir. Иначе ID не сохраняется и ротируется на каждый запуск,
// ломая allowlist (-clients-file) и статистику.
for _, path := range paths {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
continue
}
if err := os.WriteFile(path, b, 0o600); err == nil { //nolint:gosec // 0o600 для auth-токена
return newID
}
}
log.Printf("warning: failed to persist client ID to any writable path (%v); ID will rotate next launch", paths)
return newID
}
// clientConfigPaths возвращает кандидатов client_config.json в порядке
// предпочтения: рядом с бинарём (desktop, переносимость), затем per-user
// UserConfigDir и TempDir (Android, где каталог бинаря read-only).
func clientConfigPaths() []string {
const name = "client_config.json"
seen := map[string]bool{}
var dirs []string
add := func(d string) {
if d == "" || seen[d] {
return
}
seen[d] = true
dirs = append(dirs, d)
}
if exe, err := os.Executable(); err == nil {
add(filepath.Dir(exe))
}
add(filepath.Dir(os.Args[0]))
if cfgDir, err := os.UserConfigDir(); err == nil {
add(filepath.Join(cfgDir, "free-turn-proxy"))
}
add(os.TempDir())
paths := make([]string, 0, len(dirs))
for _, d := range dirs {
paths = append(paths, filepath.Join(d, name))
}
return paths
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/clientsdb"
"github.com/samosvalishe/free-turn-proxy/internal/config"
)
func TestAdmitTCPAndBond(t *testing.T) {
for _, mode := range []config.ProxyMode{config.ProxyModeTCP, config.ProxyModeUDP} {
cfg := &config.Server{Proxy: config.ProxyOpts{Mode: mode}}
for _, tag := range []byte{clientsdb.ModeUnset, clientsdb.ModeUDP, clientsdb.ModeTCP, clientsdb.ModeTCPBond, 255} {
err := admitClient(cfg, nil, "test-client", tag)
want := (mode == config.ProxyModeTCP && (tag == clientsdb.ModeTCP || tag == clientsdb.ModeTCPBond)) || (mode == config.ProxyModeUDP && (tag == clientsdb.ModeUnset || tag == clientsdb.ModeUDP))
if (err == nil) != want {
t.Fatalf("mode=%s tag=%d err=%v", mode, tag, err)
}
}
}
}
+124 -75
View File
@@ -8,18 +8,18 @@ import (
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/pion/dtls/v3"
"github.com/samosvalishe/free-turn-proxy/internal/clientsdb"
"github.com/samosvalishe/free-turn-proxy/internal/config"
"github.com/samosvalishe/free-turn-proxy/internal/logx"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bondserver"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpfwdserver"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bond"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpserver"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/udpserver"
"github.com/samosvalishe/free-turn-proxy/internal/safego"
"github.com/samosvalishe/free-turn-proxy/internal/shutdown"
"github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial"
"github.com/samosvalishe/free-turn-proxy/internal/wire"
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
@@ -36,11 +36,9 @@ func main() {
cfg, err := config.ParseServer(os.Args[1:], os.Stderr)
if err != nil {
// -help/-h: usage уже напечатан в ParseServer, выходим штатно.
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
// логгер ещё не создан - единственный fatal до его инициализации.
log.Fatalf("%v", err)
}
logger := logx.New(cfg.Log.Debug)
@@ -56,40 +54,53 @@ func main() {
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-signalChan
logger.Infof("Terminating...")
cancel()
select {
case <-signalChan:
case <-time.After(5 * time.Second):
}
logger.Warnf("Forced exit after shutdown timeout")
cancel()
os.Exit(1)
}()
ctx, stop := shutdown.Watch(context.Background(), logger)
defer stop()
addr, err := net.ResolveUDPAddr("udp", cfg.Proxy.Listen)
if err != nil {
logger.Errorf("resolve listen addr: %v", err)
os.Exit(1)
}
logger.Infof("Starting server listen=%s connect=%s mode=%s obf-profile=%s bond-autodetect=true",
cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Proxy.Mode, cfg.Obf.Profile)
logger.Infof("Starting server listen=%s connect=%s obf-profile=%s",
cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Obf.Profile)
if !cfg.Obf.Enabled() {
logger.Warnf("running with -obf-profile=none: any client reaching %s can relay to %s (no shared-key auth)", cfg.Proxy.Listen, cfg.Proxy.Connect)
}
certificate, genErr := dtlsdial.GenerateSelfSignedCert()
if genErr != nil {
logger.Errorf("self-signed cert: %v", genErr)
listener, err := listen(cfg, addr, logger)
if err != nil {
logger.Errorf("%v", err)
os.Exit(1)
}
context.AfterFunc(ctx, func() {
if err := listener.Close(); err != nil {
logger.Errorf("listener close: %v", err)
}
})
logger.Infof("Listening on %s", cfg.Proxy.Listen)
var db *clientsdb.DB
if cfg.ClientsFile != "" {
d, err := clientsdb.New(cfg.ClientsFile)
if err != nil {
logger.Errorf("Failed to open clients-file: %v", err)
os.Exit(1)
}
d.StartHotReload(ctx, 10*time.Second)
db = d
logger.Infof("Client ID authorization enabled via %s", cfg.ClientsFile)
}
serve(ctx, logger, listener, db, cfg)
}
func listen(cfg *config.Server, addr *net.UDPAddr, logger logx.Logger) (net.Listener, error) {
certificate, err := dtlsdial.GenerateSelfSignedCert()
if err != nil {
return nil, fmt.Errorf("self-signed cert: %w", err)
}
dtlsOpts := []dtls.ServerOption{
dtls.WithCertificates(certificate),
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
@@ -101,40 +112,22 @@ func main() {
logger.Infof("OBF profile=%s: listener only accepts clients with matching -obf-profile and -obf-key", cfg.Obf.Profile)
obfListener, oerr := wire.Listen(string(cfg.Obf.Profile), addr, cfg.Obf.Key, cfg.Obf.Timing)
if oerr != nil {
logger.Errorf("obf listen: %v", oerr)
os.Exit(1)
return nil, fmt.Errorf("obf listen: %w", oerr)
}
listener, err = dtls.NewListenerWithOptions(obfListener, dtlsOpts...)
} else {
listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...)
}
if err != nil {
logger.Errorf("dtls listen: %v", err)
os.Exit(1)
}
context.AfterFunc(ctx, func() {
if err := listener.Close(); err != nil {
logger.Errorf("listener close: %v", err)
}
})
logger.Infof("Listening on %s", cfg.Proxy.Listen)
registry := bondserver.NewRegistry(bondserver.Deps{Log: logger})
var db *clientsdb.DB
if cfg.ClientsFile != "" {
d, err := clientsdb.New(cfg.ClientsFile)
if err != nil {
logger.Errorf("Failed to open clients-file: %v", err)
os.Exit(1)
}
d.StartHotReload(10 * time.Second)
db = d
logger.Infof("Client ID authorization enabled via %s", cfg.ClientsFile)
return nil, fmt.Errorf("dtls listen: %w", err)
}
return listener, nil
}
func serve(ctx context.Context, logger logx.Logger, listener net.Listener, db *clientsdb.DB, cfg *config.Server) {
bonds := &bond.Server{}
var wg sync.WaitGroup
var backoff time.Duration
for {
select {
case <-ctx.Done():
@@ -148,16 +141,57 @@ func main() {
wg.Wait()
return
}
logger.Warnf("accept: %v", err)
// Отказ обычно устойчив (EMFILE): без паузы цикл сжёг бы ядро на ретраях.
backoff = nextAcceptBackoff(backoff)
logger.Warnf("accept: %v, retry in %s", err, backoff)
select {
case <-ctx.Done():
wg.Wait()
return
case <-time.After(backoff):
}
continue
}
backoff = 0
wg.Go(func() {
handleAccepted(ctx, logger, registry, db, conn, cfg)
_ = safego.Run(logger, func() { handleAccepted(ctx, logger, db, conn, cfg, bonds) })
})
}
}
func handleAccepted(ctx context.Context, logger logx.Logger, registry *bondserver.Registry, db *clientsdb.DB, conn net.Conn, cfg *config.Server) {
const (
minAcceptBackoff = 5 * time.Millisecond
maxAcceptBackoff = time.Second
)
func nextAcceptBackoff(d time.Duration) time.Duration {
if d <= 0 {
return minAcceptBackoff
}
if d *= 2; d > maxAcceptBackoff {
return maxAcceptBackoff
}
return d
}
func wireMode(m config.ProxyMode) byte {
if m == config.ProxyModeTCP {
return clientsdb.ModeTCP
}
return clientsdb.ModeUDP
}
func modeName(b byte) string {
if b == clientsdb.ModeTCPBond {
return "tcp-bond"
}
if b == clientsdb.ModeTCP {
return string(config.ProxyModeTCP)
}
return string(config.ProxyModeUDP)
}
func handleAccepted(ctx context.Context, logger logx.Logger, db *clientsdb.DB, conn net.Conn, cfg *config.Server, bonds *bond.Server) {
defer func() {
if closeErr := conn.Close(); closeErr != nil {
logger.Warnf("failed to close incoming connection: %s", closeErr)
@@ -175,35 +209,51 @@ func handleAccepted(ctx context.Context, logger logx.Logger, registry *bondserve
}
logger.Debugf("Start handshake")
if err := dtlsConn.HandshakeContext(ctx1); err != nil {
logger.Warnf("Handshake failed: %v", err)
// Адрес обязателен: пачка таймаутов с новых адресов - признак того, что клиент
// сменил relayed-адрес, а его сессия сюда не смигрировала.
logger.Warnf("Handshake failed from %s: %v", conn.RemoteAddr(), err)
return
}
logger.Debugf("Handshake done")
// Client ID читается всегда (клиент всегда шлёт его первой app-record) -
// wire-контракт симметричен. -clients-file включает только enforce по allowlist.
clientID, err := clientsdb.ReadClientID(dtlsConn)
var clientMode byte
clientID, data, err := clientsdb.AcceptClientID(dtlsConn, func(id string, mode byte) error {
clientMode = mode
return admitClient(cfg, db, id, mode)
})
if err != nil {
logger.Warnf("Read Client ID failed: %v", err)
logger.Warnf("Client ID from %s rejected: %v", conn.RemoteAddr(), err)
return
}
if db != nil {
if !db.IsAuthorized(clientID) {
logger.Warnf("Unauthorized Client ID: %s. Dropping connection.", clientID)
return
}
logger.Debugf("Client %s authorized", clientID)
} else {
logger.Debugf("Client ID received (no allowlist): %s", clientID)
}
if cfg.Proxy.Mode == config.ProxyModeTCPFwd {
tcpfwdserver.Handle(ctx, logger, registry, dtlsConn, cfg.Proxy.Connect, cfg.KCP.Profile, cfg.KCP.FEC)
} else {
udpserver.Handle(ctx, logger, conn, cfg.Proxy.Connect)
logger.Infof("Session up: client=%s from=%s", clientID, conn.RemoteAddr())
switch {
case clientMode == clientsdb.ModeTCPBond:
tcpserver.HandleBond(ctx, logger, data, cfg.Proxy.Connect, cfg.KCP.Profile, bonds, clientID)
case cfg.Proxy.Mode == config.ProxyModeTCP:
tcpserver.Handle(ctx, logger, data, cfg.Proxy.Connect, cfg.KCP.Profile)
default:
udpserver.Handle(ctx, logger, data, cfg.Proxy.Connect)
}
logger.Infof("Session down: client=%s from=%s", clientID, conn.RemoteAddr())
}
logger.Debugf("Connection closed: %s", conn.RemoteAddr())
// admitClient: режим клиента обязан совпасть с сервером, ID - быть в allowlist (если он есть).
func admitClient(cfg *config.Server, db *clientsdb.DB, id string, mode byte) error {
if mode == clientsdb.ModeTCPBond {
mode = clientsdb.ModeTCP
}
if want := wireMode(cfg.Proxy.Mode); mode != clientsdb.ModeUnset && mode != want {
return fmt.Errorf("mode mismatch: клиент %s, сервер %s - приведите -mode к одному значению",
modeName(mode), cfg.Proxy.Mode)
}
if mode == clientsdb.ModeUnset && cfg.Proxy.Mode == config.ProxyModeTCP {
return errors.New("mode mismatch: клиент без тега режима (udp), сервер tcp")
}
if db != nil && !db.IsAuthorized(id) {
return fmt.Errorf("unauthorized client ID %s", id)
}
return nil
}
func handleClientsCommand(args []string) {
@@ -212,7 +262,6 @@ func handleClientsCommand(args []string) {
os.Exit(1)
}
// Файл по умолчанию или из переменной окружения
dbPath := "clients.json"
if envPath := os.Getenv("CLIENTS_FILE"); envPath != "" {
dbPath = envPath
+3 -2
View File
@@ -10,12 +10,13 @@ services:
- path: .env
required: false
environment:
# Backend address: WireGuard → 127.0.0.1:51820 | Xray/sing-box → 127.0.0.1:443
# Backend address: WireGuard -> 127.0.0.1:51820 | Xray/sing-box -> 127.0.0.1:443
CONNECT_ADDR: "127.0.0.1:51820"
# LISTEN_ADDR: "0.0.0.0:56000"
# MODE: "tcp" # tcp = TCP-форвардер для Xray/sing-box; default udp = WireGuard
# OBF_PROFILE: "rtpopus" # none (default) | rtpopus
# OBF_TIMING: "20ms" # межпакетная задержка для RTP-мимикрии; 0=выкл; только с OBF_PROFILE и MODE=udp
# OBF_TIMING: "20ms" # межпакетная задержка для RTP-мимикрии; 0=выкл; только с OBF_PROFILE
# KCP_INTERVAL / KCP_SNDWND / KCP_RCVWND / KCP_MTU / KCP_ACKNODELAY ... # тюнинг ARQ, только с MODE=tcp
# OBF_KEY задаётся в .env (не коммитьте ключ в git)
# CLIENTS_FILE: "/app/clients.json" # раскомментируйте для авторизации по Client ID
# DEBUG: "false"
+13 -2
View File
@@ -11,10 +11,21 @@ esac
set -- -listen "$LISTEN" -connect "$CONNECT"
if [ "${MODE}" = "tcp" ]; then
set -- "$@" -mode tcp
if [ -n "${MODE}" ]; then
set -- "$@" -mode "$MODE"
fi
for kcp_var in NODELAY INTERVAL RESEND NC SNDWND RCVWND MTU ACKNODELAY; do
eval "kcp_val=\${KCP_${kcp_var}}"
if [ -n "${kcp_val}" ]; then
kcp_flag="-kcp-$(echo "$kcp_var" | tr '[:upper:]' '[:lower:]')"
case "$kcp_var" in
ACKNODELAY) set -- "$@" "${kcp_flag}=${kcp_val}" ;;
*) set -- "$@" "$kcp_flag" "$kcp_val" ;;
esac
fi
done
if [ -n "${OBF_PROFILE}" ] && [ "${OBF_PROFILE}" != "none" ]; then
OBF="${OBF_KEY:?OBF_KEY is required when OBF_PROFILE != none}"
set -- "$@" -obf-profile "$OBF_PROFILE" -obf-key "$OBF"
+88 -119
View File
@@ -1,138 +1,107 @@
# Развёртывание сервера
Для работы сервера 24/7 его необходимо настроить как службу. Мы поддерживаем установку через скрипт (интерактивно или автоматически), а также ручную настройку через **Docker** или **systemd**.
## Установка скриптом (рекомендуется)
---
## Автоматическая установка (Рекомендуется)
Проще всего использовать официальный скрипт. Он установит зависимости, сгенерирует ключи, настроит файрвол и запустит службу.
**Интерактивный режим** (задаст вопросы в терминале):
```bash
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
```
> Скрипт идемпотентен: при повторном запуске он предложит обновить, переконфигурировать или удалить сервер.
**Неинтерактивный режим** (для автоматизации):
Мастер спросит:
1. **VPN на сервере** - новый WireGuard (скрипт поднимет его сам) или уже установленный VPN (Amnezia, wg-easy, 3x-ui и т.п.: указываете его `host:port`).
2. **Запуск** - `docker` или `systemd`.
3. **Порт и обфускация.**
В конце - ссылка `freeturn://` для приложения (и WireGuard-конфиг, если VPN новый).
Управление:
```bash
# Установка через Docker (UDP, порт бэкенда 51820)
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | \
sudo bash -s -- -y --method docker --mode udp --backend-port 51820
# Обновление до конкретной версии
sudo bash install.sh -y --update --version v1.2.3
# Полное удаление
sudo bash install.sh -y --uninstall --purge
freeturn # меню: клиенты, настройки, обновление, логи, удаление
freeturn client add phone # новый клиент и его ссылка
freeturn client list
freeturn client qr phone # показать ссылку и QR ещё раз
freeturn client remove phone
```
*Все доступные флаги:* `sudo bash install.sh --help`
---
Без вопросов:
```bash
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | \
sudo bash -s -- -y --backend=new --method=docker
```
Все флаги: `freeturn --help`.
## Ручная установка
Если вы предпочитаете контролировать каждый шаг, используйте инструкции ниже.
Сначала сгенерируйте 64-символьный hex-ключ обфускации (он должен совпадать на сервере и клиенте):
```bash
openssl rand -hex 32
```
*В примерах ниже он обозначен как `<ВАШ_КЛЮЧ>`.*
Ключ обфускации (одинаковый на сервере и клиенте): `openssl rand -hex 32`.
### Способ 1: Docker Compose
### Docker Compose
1. Установите Docker:
```bash
curl -fsSL https://get.docker.com | sudo sh
```
2. Создайте директорию и `docker-compose.yml`:
```bash
mkdir -p /opt/free-turn-proxy && cd /opt/free-turn-proxy
nano docker-compose.yml
```
3. Вставьте конфигурацию:
```yaml
services:
free-turn-proxy:
image: ghcr.io/samosvalishe/free-turn-proxy:latest
container_name: free-turn-proxy
network_mode: "host" # Важно для доступа к локальному VPN (127.0.0.1)
restart: unless-stopped
environment:
- CONNECT_ADDR=127.0.0.1:51820 # Порт ВАШЕГО VPN (WG/AmneziaWG/Xray)
- LISTEN_ADDR=0.0.0.0:56000 # Внешний порт
- MODE=udp # udp (WG/Amnezia) или tcp (Xray/VLESS)
- OBF_PROFILE=rtpopus # Обязательная маскировка
- OBF_KEY=<ВАШ_КЛЮЧ> # Ваш ключ
# - CLIENTS_FILE=/opt/free-turn-proxy/clients.json # Для авторизации
# volumes:
# - /opt/free-turn-proxy/clients.json:/opt/free-turn-proxy/clients.json
```
4. Запустите: `docker compose up -d`
### Способ 2: systemd (Без Docker)
1. Скачайте бинарник:
```bash
sudo mkdir -p /opt/free-turn-proxy
sudo curl -L -o /opt/free-turn-proxy/server https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/server-linux-amd64
sudo chmod +x /opt/free-turn-proxy/server
```
*(Для ARM замените `-amd64` на `-arm64`)*
2. Создайте службу: `sudo nano /etc/systemd/system/free-turn-proxy.service`
3. Вставьте конфигурацию:
```ini
[Unit]
Description=Free TURN Proxy Server
After=network.target
[Service]
Type=simple
ExecStart=/opt/free-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ>
Restart=always
RestartSec=5
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.target
```
4. Запустите: `sudo systemctl daemon-reload && sudo systemctl enable --now free-turn-proxy.service`
---
## Настройка Файрвола
Откройте внешний порт (сервер слушает по **UDP**, даже если `MODE=tcp`):
```bash
sudo ufw allow 56000/udp
# Или iptables: sudo iptables -I INPUT -p udp --dport 56000 -j ACCEPT
```yaml
services:
free-turn-proxy:
image: ghcr.io/samosvalishe/free-turn-proxy:latest
network_mode: host
restart: unless-stopped
environment:
- CONNECT_ADDR=127.0.0.1:51820 # ваш VPN
- LISTEN_ADDR=0.0.0.0:56000
- OBF_PROFILE=rtpopus3
- OBF_KEY=<ВАШ_КЛЮЧ>
```
---
## Авторизация по Client ID (Опционально)
По умолчанию доступ открыт всем, кто знает `-obf-key`. Чтобы ограничить доступ:
1. Создайте пустой список: `echo "[]" | sudo tee /opt/free-turn-proxy/clients.json`
2. Включите авторизацию:
- **Docker:** раскомментируйте `CLIENTS_FILE` и `volumes` в `docker-compose.yml`, затем `docker compose up -d`.
- **systemd:** добавьте флаг `-clients-file /opt/free-turn-proxy/clients.json` в `ExecStart` и перезапустите службу.
3. Добавляйте ID в `clients.json` (см. [flags.md](flags.md#управление-client-id-команды-сервера)). На клиенте используйте флаг `-client-id <id>`.
---
## Переменные окружения Docker
| Переменная | По умолчанию | Описание |
| Переменная | По умолчанию | |
| --- | --- | --- |
| `CONNECT_ADDR` | **обязательна** | IP и порт вашего VPN (бэкенда) |
| `LISTEN_ADDR` | `0.0.0.0:56000` | Внешний адрес прослушивания |
| `MODE` | `udp` | Режим туннеля: `udp` \| `tcp` |
| `OBF_PROFILE` | `none` | Маскировка: `none` \| `rtpopus` \| `rtpopus2` |
| `OBF_KEY` | пусто | Ключ маскировки |
| `CLIENTS_FILE`| пусто | Путь к JSON-файлу авторизации |
| `DEBUG` | `false` | Включить debug-логи |
| `CONNECT_ADDR` | обязательна | адрес вашего VPN |
| `LISTEN_ADDR` | `0.0.0.0:56000` | внешний адрес |
| `MODE` | `udp` | `udp` (WireGuard) \| `tcp` (Xray/VLESS), как на клиенте |
| `OBF_PROFILE` | `none` | `none` \| `rtpopus` \| `rtpopus2` \| `rtpopus3` |
| `OBF_KEY` | - | ключ обфускации |
| `CLIENTS_FILE` | - | список разрешённых Client ID |
| `KCP_*` | - | тюнинг `MODE=tcp`, см. `docs/flags.md` |
| `DEBUG` | `false` | подробные логи |
> **Внимание при Bridge Mode:** Если вы уберете `network_mode: "host"` и пробросите порты (`-p 56000:56000/udp`), `CONNECT_ADDR=127.0.0.1:51820` будет указывать внутрь контейнера. В этом случае прокси не найдет VPN. Используйте IP хоста или оставляйте `network_mode: "host"`.
### systemd
```bash
sudo mkdir -p /opt/free-turn-proxy
sudo curl -L -o /opt/free-turn-proxy/server \
https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/server-linux-amd64
sudo chmod +x /opt/free-turn-proxy/server
```
`/etc/systemd/system/free-turn-proxy.service`:
```ini
[Unit]
Description=Free Turn Proxy
After=network-online.target
[Service]
ExecStart=/opt/free-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ>
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now free-turn-proxy
```
### Доступ по Client ID
Без списка подключиться может любой, у кого есть ключ обфускации. Список:
```bash
sudo CLIENTS_FILE=/opt/free-turn-proxy/clients.json \
/opt/free-turn-proxy/server clients add $(openssl rand -hex 16) phone
```
Запуск с `-clients-file /opt/free-turn-proxy/clients.json` (Docker: `CLIENTS_FILE` + volume). На клиенте - `-client-id` или ссылка `freeturn://`.
## Порт
Откройте внешний порт по **UDP** (при `MODE=tcp` тоже): `sudo ufw allow 56000/udp`. Скрипт делает это сам.
+43 -26
View File
@@ -2,31 +2,48 @@
## Клиент
| Флаг | По умолчанию | Описание |
|---------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `-listen` | `127.0.0.1:9000` | локальный адрес `ip:port`, куда подключается WireGuard (UDP) или Xray/sing-box (TCP) клиент |
| `-peer` | **обязательный** | адрес сервера на VPS, `host:port` |
| `-provider` | `vk` | источник потоков: `vk` (TURN-реле сервиса звонков) \| `direct` (напрямую на `-peer`), см. `docs/providers.md` |
| `-link` | пусто | (устарел) одна ссылка на звонок; используйте `-links`. Игнорируется, если задан `-links` |
| `-links` | **обязательный для `-provider vk`** (или `-link`) | ссылки на звонки через запятую; каждая даёт свой пул из `-n` стримов |
| `-n` | `12` (`direct`: `1`) | параллельных потоков; в `-mode tcp` это число сессий пула, по которым round-robin раскладываются локальные TCP-соединения |
| `-transport` | `tcp` | транспорт до TURN-реле: `tcp` (TCP/TLS) \| `udp` |
| `-mode` | `udp` | режим туннеля: `udp` (UDP-релей для WireGuard) \| `tcp` (TCP-форвардер для Xray/sing-box) |
| `-bond` | `false` | объединять сессии для одного TCP-соединения; только `-mode tcp`, до 256 сессий суммарно; нужен обновлённый сервер |
| `-turn` | из creds | переопределить IP TURN-сервера |
| `-port` | из creds | переопределить порт TURN-сервера |
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) \| `rtpopus2` (+ RTP header extension, ближе к WebRTC) \| `rtpopus3` (+ abs-send-time, VAD, имитация потерь, вариативный timestamp); должен совпадать с сервером |
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex (64 символа) |
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `20ms`); только с `-obf-profile != none`; `0` = выкл. В `-mode tcp` особенно уместна: KCP гонит бёрсты полноразмерных пакетов, без выравнивания профиль на Opus не похож |
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
| `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) |
| `-streams-per-cred` | `12` | потоков на один кеш учёток (только `-provider vk`) |
| `-platform` | `desktop` | класс устройства персоны авторизации (мобильность UA/device/client hints): `desktop` \| `mobile` (только `-provider vk`) |
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
| `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) |
| `-sub` | пусто | URL подписки (sub.md) для получения списка серверов |
| `-debug` | `false` | debug-логи |
## KCP (только `-mode tcp`, клиент и сервер)
Флаги настраивают ARQ-слой, который держит TCP-поток поверх датаграмм TURN. Задаются на каждой стороне отдельно; в `-mode udp` любое отличие от дефолта - ошибка запуска. В share-ссылке профиль едет полем `kcp`, так что гость получает тот же ARQ, что настроил владелец.
| Флаг | По умолчанию | Описание |
| --- | --- | --- |
| `-listen` | `127.0.0.1:9000` | локальный адрес `ip:port`, куда подключается WireGuard или Xray клиент |
| `-peer` | **обязательный** | адрес сервера на VPS, `host:port` |
| `-provider` | `vk` | источник TURN-creds: `vk` (см. `docs/providers.md`) |
| `-link` | пусто | (устарел) одна ссылка VK Calls `https://vk.ru/call/join/...`; используйте `-links`. Игнорируется, если задан `-links` |
| `-links` | **обязательный для `-provider vk`** (или `-link`) | ссылки VK Calls через запятую `https://vk.ru/call/join/A,https://vk.ru/call/join/B`; каждая даёт свой пул из `-n` стримов |
| `-n` | `10` | параллельных TURN-потоков |
| `-transport` | `tcp` | транспорт до TURN-реле: `tcp` (TCP/TLS) \| `udp` |
| `-mode` | `udp` | режим туннеля: `udp` (UDP-релей для WireGuard) \| `tcp` (TCP-форвардер для Xray/sing-box) |
| `-bond` | `false` | распределять одно TCP-соединение по всем активным smux-сессиям (только с `-mode tcp`) |
| `-turn` | из creds | переопределить IP TURN-сервера |
| `-port` | из creds | переопределить порт TURN-сервера |
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) \| `rtpopus2` (+ RTP header extension, ближе к WebRTC) \| `rtpopus3` (+ abs-send-time, VAD, имитация потерь, вариативный timestamp); должен совпадать с сервером |
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex (64 символа) |
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `20ms`); только с `-obf-profile != none` и `-mode udp`; `0` = выкл |
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
| `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) |
| `-streams-per-cred` | `10` | потоков на один кеш VK-учёток (только `-provider vk`) |
| `-browser` | `firefox` | браузерный профиль VK-auth (UA + TLS JA3 + client hints): `chrome` \| `firefox` \| `safari` (только `-provider vk`) |
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
| `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) |
| `-sub` | пусто | URL подписки (sub.md) для получения списка серверов |
| `-debug` | `false` | debug-логи |
| `-kcp-nodelay` | `1` | `0` (обычный) \| `1` (быстрый режим) |
| `-kcp-interval` | `20` | интервал внутреннего цикла KCP, мс; больше = меньше служебного трафика |
| `-kcp-resend` | `2` | быстрая повторная отправка после N дубликатов ACK; `0` = выкл |
| `-kcp-nc` | `1` | congestion control: `0` (вкл) \| `1` (выкл) |
| `-kcp-sndwnd` | `512` | окно отправки в пакетах |
| `-kcp-rcvwnd` | `512` | окно приёма в пакетах |
| `-kcp-mtu` | `1200` | MTU сегмента KCP, байты; допустимо `300..1350` |
| `-kcp-acknodelay` | `true` | отправлять ACK сразу: ниже задержка, выше служебный трафик |
На мобильной сети дефолт заметно шумит; щадящий вариант - `-kcp-interval 40 -kcp-sndwnd 256 -kcp-rcvwnd 256 -kcp-acknodelay=false`.
## Сервер
@@ -34,9 +51,9 @@
| --- | --- | --- |
| `-listen` | `0.0.0.0:56000` | адрес прослушивания `ip:port` |
| `-connect` | **обязательный** | локальный backend `host:port` (WG `127.0.0.1:51820` / Xray `127.0.0.1:443`) |
| `-mode` | `udp` | режим туннеля: `udp` \| `tcp` (bond автоопределяется) |
| `-mode` | `udp` | режим туннеля: `udp` \| `tcp`; должен совпадать с клиентом |
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` \| `rtpopus2` \| `rtpopus3`; должен совпадать с клиентом (описание профилей - в таблице клиента) |
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `10ms`); только с `-obf-profile != none` и `-mode udp`; `0` = выкл |
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `10ms`); только с `-obf-profile != none`; `0` = выкл |
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex |
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
| `-clients-file` | пусто | путь к JSON-файлу (`clients.json`) для включения авторизации по Client ID |
@@ -45,7 +62,7 @@
## Управление Client ID (Команды Сервера)
> [!NOTE]
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает — wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает - wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
Сервер содержит встроенные команды для управления файлом `clients.json` (горячая перезагрузка поддерживается автоматически, перезапускать сервер после изменений не нужно).
+25 -25
View File
@@ -1,22 +1,38 @@
# Мобильные устройства
## Android (Termux / Приложения)
## Android-приложение - рекомендуемый способ использования
- Добавляйте подключения по ссылке, QR-коду или вручную.
- Устанавливайте и настраивайте сервер на VPS из приложения.
- Переключайте серверы, настраивайте раздельное туннелирование и делитесь доступом.
- Сохраняйте настройки в зашифрованную резервную копию.
[Скачать APK](https://github.com/samosvalishe/turn-proxy-android/releases/latest)
Требования: Android 7.0+ и устройство ARM (arm64-v8a или armeabi-v7a). Для собственного подключения нужен VPS; в режиме Relay также нужна ссылка на звонок.
> [!WARNING]
> При переходе Android-приложения на версию 5 потребуется настройка с нуля. Перед обновлением сохраните данные доступа к VPS и параметры подключения. Затем заново добавьте сервер или импортируйте новую ссылку и проверьте параметры VPN и раздельного туннелирования.
## Android (Termux, ручная настройка)
Альтернативный вариант для работы с отдельным VPN-клиентом.
При работе на мобильных устройствах возникают две основные проблемы: перехват DNS оператором и зацикливание маршрутов VPN.
1. Установите Termux или используйте приложение (например, Free Turn).
2. В клиенте WireGuard / AmneziaWG: `Endpoint = 127.0.0.1:9000`, `MTU = 1280` (если связь нестабильна, MTU можно опускать вплоть до 1120).
3. **Критично:** Добавьте приложение, в котором запущен `free-turn-proxy` клиент (Termux, Free Turn), в **Исключения WireGuard** (разрешенные приложения, не пускать через VPN). Если этого не сделать, туннель завернется сам в себя, и соединения не будет.
4. **Критично:** В большинстве случаев мобильные операторы связи перехватывают/блокируют сторонние DNS, включая DoH. Вам необходимо передавать IP-адрес DNS вашего оператора связи через флаг `-dns-servers`.
1. Установите Termux.
2. В клиенте WireGuard / AmneziaWG: `Endpoint = 127.0.0.1:9000`, `MTU = 1280` (если связь нестабильна, MTU можно снижать вплоть до 1120).
3. **Критично:** Добавьте Termux в **Исключения WireGuard** (разрешенные приложения, не пускать через VPN). Если этого не сделать, туннель завернется сам в себя, и соединения не будет.
4. **Критично:** В большинстве случаев мобильные операторы блокируют сторонние DNS, включая DoH. Передавайте IP-адрес DNS вашего оператора связи через флаг `-dns-servers`.
Пример для Termux:
Пример запуска в Termux:
```bash
termux-wake-lock
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-android-arm64
chmod +x client
# Обязательно замените <ip_dns_оператора> на DNS вашего провайдера
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -dns-servers <ip_dns_оператора>
# Замените <ip_dns_оператора> на DNS вашего провайдера
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>" -dns-servers <ip_dns_оператора>
```
Снять wake lock: `termux-wake-unlock`.
@@ -30,7 +46,7 @@ apk update
apk add curl
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-linux-386
chmod +x client
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>"
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>"
```
Дольше в фоне:
@@ -38,19 +54,3 @@ GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vp
```bash
cat /dev/location > /dev/null &
```
## Нативная сборка библиотек (gomobile)
Для интеграции прокси-движка непосредственно в нативные мобильные приложения (iOS / Android) проект предоставляет пакет `mobile`, адаптированный для сборки через `gomobile bind`.
### iOS
Сборка универсального XCFramework (`dist/Mobile.xcframework`) с поддержкой iOS и симулятора:
```bash
task build:ios
```
### Android
Сборка Android Archive (`dist/mobile.aar`) с готовыми JNI-обертками для Java/Kotlin:
```bash
task build:android
```
+46 -22
View File
@@ -1,41 +1,65 @@
# Режимы
# Режимы
## UDP-Релей (WireGuard / AmneziaWG / Hysteria)
Дефолтный режим (`-mode udp`). Клиент слушает локальный UDP-порт, сервер прозрачно форвардит UDP-пакеты в backend (например, WG `127.0.0.1:51820`).
Отлично подходит для VPN-протоколов, работающих поверх UDP: **WireGuard, AmneziaWG, Hysteria**.
## Прямой режим (`-provider direct`)
Клиент шлёт тот же DTLS с OBF-обёрткой прямо на `-peer`, без реле:
- **Сервер не меняется**: тот же `-listen` порт, те же `-obf-profile`/`-obf-key`.
- **Без звонка**: ни ссылок, ни captcha; `-links`, `-turn`, `-port`, `-transport` игнорируются.
- **Потоки**: по умолчанию `-n 1` - лимитов реле нет, лишние потоки только делят очередь.
- **Ограничение**: при белых списках мобильного оператора IP VPS недоступен - там только `vk`.
- Переключение TURN <-> direct только вручную, автоматического fallback нет.
```bash
./client -provider direct -peer <vps>:56000 -obf-profile rtpopus3 -obf-key <key>
```
## TCP-Форвардер (Xray / sing-box / VLESS)
Включается флагом `-mode tcp` на обеих сторонах. Сервер подключается к локальному TCP-бэкенду (например, Xray inbound `127.0.0.1:443`). Клиент слушает локальный TCP-порт, на который вы натравливаете свой Xray/v2rayN/sing-box.
Подходит для TCP-прокси: **VLESS, VMess, Trojan, Shadowsocks**.
Поверх DTLS трафик инкапсулируется через KCP + smux.
Relayed-данные TURN - всегда датаграммы, даже при `-transport tcp`, поэтому поверх DTLS трафик инкапсулируется через KCP (ARQ) + smux. Тюнинг ARQ - флаги `-kcp-*` (см. `docs/flags.md`).
```bash
./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -mode tcp
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>" -mode tcp
```
**Bonding** — распределение одного TCP-соединения по всем активным smux-сессиям, флаг только клиентский:
`-n` в этом режиме задаёт число сессий пула: каждое локальное TCP-соединение уходит целиком в одну из них (round-robin).
### Bond для одного TCP-соединения
Добавьте клиенту `-bond`: данные каждого TCP-соединения будут распределяться между доступными сессиями. На сервере достаточно `-mode tcp`, он одновременно принимает обычные и bond-клиенты. По умолчанию bond выключен.
```bash
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp -bond -n 4
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -links "<call-link>" -mode tcp -bond -n 4
```
Серверного `-bond` нет — сервер автоопределяет bond по magic-префиксу в стриме.
Состав каналов фиксируется при открытии TCP-соединения; с одной доступной сессией bond тоже работает. Поддерживается до 256 сессий суммарно по всем ссылкам. Новые сессии используются следующими соединениями. При отказе участвующей сессии текущее соединение закрывается, а Xray может открыть новое через оставшиеся. Бесшовного переноса уже переданных данных между каналами нет. Медленный канал задерживает сборку потока; прирост скорости зависит от сети.
Для bond обновите и клиент, и сервер: сервер без его поддержки отклонит такой клиент при проверке режима. Обычный TCP сохраняет прежний протокол. В JSON клиента настройка называется `proxy.bond`, в `freeturn://` - `bond`.
Встроенный WireGuard (`-tunnel`) с `-mode tcp` не сочетается: он гонит датаграммы.
Режим должен совпадать на обеих сторонах: клиент помечает им первую запись, и сервер с другим `-mode` закрывает соединение с явной ошибкой вместо молчаливого зависания.
## OBF (wire-профили обфускации)
> [!WARNING]
> **Обфускация обязательна для хорошей скорости.** VK агрессивно шейпит (режет) любой трафик, который не похож на медиаданные звонка (даже если он зашифрован в DTLS). Обфускация маскирует ваши пакеты под голосовой RTP-трафик, позволяя обходить шейпинг.
> **Обфускация обязательна для хорошей скорости.** Сервис звонков шейпит (режет) любой трафик, который не похож на медиаданные звонка (даже если он зашифрован в DTLS). Обфускация маскирует ваши пакеты под голосовой RTP-трафик, позволяя обходить шейпинг.
`-obf-profile` выбирает wire-профиль маскировки TURN-payload. Профиль и ключ должны совпадать на клиенте и сервере.
Доступные профили:
- **`none`** (default) - обфускация выключена (скорость может быть сильно урезана).
- **`rtpopus`** - RTP/opus-заголовок + ChaCha20-Poly1305 AEAD на теле. Идеально для обхода шейпов.
- **`rtpopus2`** - rtpopus + RTP header extension (RFC 8285): X=1, ssrc-audio-level + transport-cc. Ближе к современному WebRTC. Wire несовместим с rtpopus - профиль должен совпадать на обеих сторонах.
- **`rtpopus3`** (рекомендуется) - RTP/opus + RFC 8285 extension (`ssrc-audio-level` + `transport-cc`) + ChaCha20-Poly1305. Полная эмуляция современного WebRTC-аудиопотока звонка.
- **`rtpopus2`** - RTP/opus + базовый RFC 8285 header extension.
- **`rtpopus`** - базовый RTP/opus-заголовок + ChaCha20-Poly1305 AEAD.
- **`none`** - обфускация выключена (скорость может быть сильно урезана шейпингом).
Сгенерировать ключ:
@@ -46,8 +70,8 @@
Запуск:
```bash
./server ... -obf-profile rtpopus -obf-key <64-hex>
./client ... -obf-profile rtpopus -obf-key <64-hex>
./server ... -obf-profile rtpopus3 -obf-key <64-hex>
./client ... -obf-profile rtpopus3 -obf-key <64-hex>
```
## TURN Транспорт
@@ -59,30 +83,30 @@
```
> [!WARNING]
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь — используйте стандартный TCP/TLS (просто не указывайте этот флаг).
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь - используйте стандартный TCP/TLS (просто не указывайте этот флаг).
## Потоки и Лимиты (-n)
Параметр `-n` задает количество параллельных потоков к TURN-серверу (по умолчанию 10).
Увеличение потоков может помочь поднять скорость или улучшить стабильность, **но не стоит этим злоупотреблять**. Слишком высокое значение (например, больше 15-20) повышает риск получения бана со стороны VK. Практика показывает, что значений от `6` до `12` зачастую достаточно для комфортной работы.
То же самое касается параметра `-streams-per-cred` — используйте разумные значения.
Параметр `-n` задает количество параллельных потоков к TURN-серверу (по умолчанию 12, в `-provider direct` - 1).
Увеличение потоков может помочь поднять скорость или улучшить стабильность, **но не стоит этим злоупотреблять**. Слишком высокое значение (например, больше 15-20) повышает риск бана со стороны сервиса звонков. Практика показывает, что значений от `6` до `12` зачастую достаточно для комфортной работы.
То же самое касается параметра `-streams-per-cred` - используйте разумные значения.
## Captcha
Клиент сам проходит VK captcha. Если автоматика не справилась — открывается локальный браузер. Принудительно ручной:
Клиент сам проходит captcha. Если автоматика не справилась - открывается локальный браузер. Принудительно ручной:
```bash
./client -manual-captcha ...
```
Профиль браузера: `vk_profile.json` рядом с бинарником.
Персона auth-запросов (UA + TLS JA3 + client hints + порядок заголовков + device) - всегда Chrome; флаг `-platform` выбирает класс устройства (desktop/mobile). Значения самосогласованы, без внешнего состояния. Ручная captcha идёт через ту же персону.
## DNS
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (VK API, токены, captcha) — не DNS-маршрутизацией пользовательского трафика.
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (API звонков, токены, captcha) - не DNS-маршрутизацией пользовательского трафика.
- `plain` — только UDP/53;
- `doh` — только DNS-over-HTTPS (Yandex DoH);
- `auto` (дефолт) — сначала UDP/53, sticky-fallback на DoH при полном отказе.
- `plain` - только UDP/53;
- `doh` - только DNS-over-HTTPS (Yandex DoH);
- `auto` (дефолт) - сначала UDP/53, sticky-fallback на DoH при полном отказе.
Свои UDP/53 резолверы: `-dns-servers ip[:port][,ip[:port]...]`.
+15 -10
View File
@@ -1,16 +1,21 @@
# Providers
# Провайдеры
Источник TURN-реквизитов выбирается флагом `-provider` (default `vk`). Реализации удовлетворяют интерфейс `internal/provider.Provider` и подключаются в `cmd/client/main.go` через `buildProvider`.
Флаг `-provider` выбирает, как клиент доходит до сервера: через TURN-реле сервиса звонков (`vk`, по умолчанию) или напрямую (`direct`).
## Доступные провайдеры
## `vk` (по умолчанию)
### `vk` (default)
Трафик идёт через TURN-серверы VK Calls. Нужна активная ссылка на звонок:
VK Calls API. Перебирает встроенные `app_id/app_secret`, получает короткоживущие (≈10 мин) TURN-creds через 4-шаговый token chain. Solver captcha auto+manual.
- `-link` - ссылка вида `https://vk.ru/call/join/<code>`.
**Обязательные флаги:**
- `-link` — VK callroom URL вида `https://vk.ru/call/join/<code>` (нормализуется до join-кода).
Необязательные:
**Опциональные:**
- `-streams-per-cred` (default 10) — сколько TURN-стримов делят один кеш креденшалов.
- `-manual-captcha` — пропустить auto-solver, сразу открыть браузер.
- `-streams-per-cred` (по умолчанию 12) - сколько потоков делят одни учётные данные.
- `-manual-captcha` - сразу открывать captcha в браузере.
- `-platform` (`desktop` \| `mobile`, по умолчанию `desktop`) - каким устройством представляться при авторизации.
## `direct`
Без реле: трафик идёт прямо на `-peer`, сервер тот же. Флаги звонков и TURN (`-links`, `-turn`, `-port`, `-transport`) игнорируются, `-n` по умолчанию 1.
Не работает, если IP сервера недоступен (белые списки операторов) - там только `vk`. Подробнее - [modes.md](modes.md).
+23 -13
View File
@@ -1,49 +1,58 @@
# Быстрый Старт
## Требования
- **VPS с публичным IP**, на котором установлен VPN-сервер (например, WireGuard). VPN должен слушать локальный порт (например, `127.0.0.1:51820/udp`).
- **Активная ссылка VK Calls**: `https://vk.ru/call/join/...` (создайте сами, звонок не завершайте).
- **VPS с публичным IP** (скрипт установки сам развернёт FreeTurn и, по желанию, WireGuard).
- **Активная ссылка на звонок** (создайте сами, звонок не завершайте).
---
## Шаг 1: Запуск Сервера (VPS)
Интерактивный скрипт установит Docker или systemd, настроит файрвол и запустит сервер. Запускать от root:
Интерактивный скрипт поднимет FreeTurn (Docker или systemd) и WireGuard `ft-wg0` либо подключит FreeTurn к вашему VPN, сгенерирует ключи, создаст клиента `owner` и выдаст **ссылку `freeturn://` и ссылку на QR-код**:
```bash
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
```
> Скопируйте параметры клиента, которые скрипт выдаст в конце. Для ручной установки или запуска скрипта без вопросов (non-interactive) см. [Развёртывание (deploy.md)](deploy.md).
> [!TIP]
> Отсканируйте QR ссылки `freeturn://` в приложении FreeTurn: в ней уже есть ключ, Client ID и WireGuard-конфиг.
> Ссылка равносильна ключам - делитесь осторожно. Показать снова: `freeturn client qr <имя>`.
>
> **Новый клиент:** `freeturn client add myphone`. Подробнее - [deploy.md](deploy.md).
---
## Шаг 2: Запуск Клиента (ПК)
## Шаг 2: Запуск Клиента Free Turn Proxy (ПК)
Туннель замыкается сам на себя, если VPN перехватывает весь трафик. Чтобы прокси мог общаться с TURN-серверами напрямую, скрипт добавит маршруты-исключения.
Если прямой UDP до вашего VPS блокируется провайдером, запустите клиент `free-turn-proxy`. Он перенаправит трафик через TURN-релей провайдера под видом аудиозвонка. Клиент автоматически создаёт маршруты-исключения для IP TURN-серверов (`-routes`).
**Linux:**
```bash
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-linux-amd64
chmod +x client
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | ./scripts/routes.sh
sudo ./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -client-id <ВАШ_CLIENT_ID> -routes
```
**Windows (PowerShell от администратора):**
```powershell
Invoke-WebRequest -Uri https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe
.\client.exe -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | .\scripts\routes.ps1
.\client.exe -peer <vps_ip>:56000 -provider vk -link "<call-link>" -listen 127.0.0.1:9000 -n 12 -streams-per-cred 12 -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -dns-servers 192.168.31.1 -dns-mode doh -client-id <ВАШ_CLIENT_ID> -routes
```
**macOS:**
```bash
# Apple Silicon (M1/M2): client-darwin-arm64 | Intel: client-darwin-amd64
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | ./scripts/routes-macos.sh
# Apple Silicon (M1/M2/M3): client-darwin-arm64 | Intel: client-darwin-amd64
sudo ./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -client-id <ВАШ_CLIENT_ID> -routes
```
> **Важно:** В настройках вашего VPN-клиента (например, WireGuard) укажите `Endpoint = 127.0.0.1:9000` и `MTU = 1280`. Включайте VPN *только после того*, как скрипт выведет `Ensuring route to ...`.
> **Важно:** В настройках вашего VPN-клиента (AmneziaWG или WireGuard) при работе через прокси используйте конфиг клиента (`<имя>.conf` из `freeturn client qr`, `Endpoint = 127.0.0.1:9000`, `MTU = 1280`). Включайте VPN *только после того*, как клиент выведет `Ensuring route to ...`.
> [!TIP]
> **Упрощение:** Вместо длинных флагов можно использовать ссылку `freeturn://` или подписку (`-sub`). Подробнее в [uri.md](uri.md) и [sub.md](sub.md).
> **Упрощение:** Вместо длинных флагов можно передать ссылку `freeturn://` (генерируется установщиком) или подписку (`-sub`):
> ```bash
> sudo ./client "freeturn://eyJ2Ijox..." -link "<call-link>" -routes
> ```
> Подробнее в [uri.md](uri.md) и [sub.md](sub.md).
---
@@ -56,7 +65,8 @@ termux-wake-lock
# Скачивание: curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-android-arm64 && chmod +x client
# Обязательно укажите ваш ключ и DNS оператора (можно узнать в настройках APN)
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -dns-servers <ip_dns_оператора>
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -dns-servers <ip_dns_оператора> -client-id <ВАШ_CLIENT_ID>
```
> Обязательно добавьте приложение Termux в исключения вашего VPN-клиента. Подробнее в [mobile.md](mobile.md).
+1 -1
View File
@@ -6,7 +6,7 @@
## Использование в клиенте
```bash
./client -sub https://example.com/sub.md -link "https://vk.ru/call/join/..."
./client -sub https://example.com/sub.md -link "<call-link>"
```
Клиент скачает подписку, найдет первый доступный сервер и автоматически применит его конфигурацию. Уникальная ссылка `-link` по-прежнему требуется.
+4 -4
View File
@@ -2,13 +2,13 @@
- **Сначала запускайте client, потом включайте WireGuard / Xray.**
- **Трафик не идет (нет интернета):** Проверьте серверный флаг `-connect`. В нем должен быть указан точный локальный порт, на котором слушает ваш VPN (например, `51820` для WG или `443` для VLESS). Если порт не совпадает, прокси будет стучаться в пустоту.
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются → убедитесь, что в команде есть пайп перед скриптом.
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются -> убедитесь, что в команде есть пайп перед скриптом.
- **DPI блокирует UDP:** Если используете `-transport udp` и ничего не работает, попробуйте убрать флаг (использовать TCP по умолчанию).
- **Обход шейпинга (кастомный TURN):** Если вы знаете "чистый" IP-адрес TURN-сервера VK, где нет агрессивного шейпинга, вы можете передать его вручную: `-turn <ip_address> -port <port>`. Это перезапишет данные, полученные из ссылки.
- **Обход шейпинга (кастомный TURN):** Если вы знаете "чистый" IP-адрес TURN-сервера, где нет агрессивного шейпинга, вы можете передать его вручную: `-turn <ip_address> -port <port>`. Это перезапишет данные, полученные из ссылки.
- **Проблемы с DNS на мобильном интернете:** Резолв ломается. Передайте DNS вашего провайдера явно: `-dns-servers <ip_dns>`.
- **Проблемы с MTU:** Если соединение обрывается или не грузятся тяжелые сайты, попробуйте снизить MTU в вашем VPN-клиенте (WireGuard/Amnezia) до `1280` или даже до `1120`. Из-за накладных расходов DTLS + TURN пакеты стандартного размера (1420) часто фрагментируются и отбрасываются.
- **Соединение нестабильное:** попробуйте уменьшить потоки `-n` (например, `-n 6`). Слишком большое число потоков может привести к бану.
- **VK часто требует captcha:** запустите с `-manual-captcha`, решите капчу в браузере, затем возвращайтесь к обычному запуску.
- **Клиент висит на получении TURN-данных:** ссылка VK Calls умерла / звонок завершён для всех.
- **Часто требуется captcha:** запустите с `-manual-captcha`, решите капчу в браузере, затем возвращайтесь к обычному запуску.
- **Клиент висит на получении TURN-данных:** ссылка на звонок умерла / звонок завершён для всех.
- **Docker bridge mode:** `CONNECT_ADDR=127.0.0.1:...` указывает внутрь контейнера. Используйте `--network host` или IP хоста.
- **`-obf-profile != none`:** профиль и ключ должны совпадать на клиенте и сервере.
+15 -12
View File
@@ -5,7 +5,7 @@
одноимённые флаги клиента, чтобы не вводить IP, режим, ключ и т.д. вручную.
> [!NOTE]
> Ссылка на звонок VK (`-link`) **не входит** в URI - она уникальна для каждого клиента.
> Ссылка на звонок (`-link`) **не входит** в URI - она уникальна для каждого клиента.
## Формат
@@ -22,39 +22,42 @@ Payload - JSON-объект, закодированный `base64url` (без pa
| Ключ | Флаг | Описание |
|------|------|----------|
| `v` | - | версия формата (сейчас `1`). |
| `provider` | `-provider` | источник TURN-creds (например `vk`). |
| `provider` | `-provider` | источник потоков: `vk` \| `direct`. |
| `peer` | `-peer` | адрес сервера на VPS (`ip:port`). |
| `transport` | `-transport` | транспорт до TURN-реле: `tcp` \| `udp`. |
| `mode` | `-mode` | режим туннеля: `udp` \| `tcp`. |
| `bond` | `-bond` | bonding TCP (`true`), только с `mode=tcp`. |
| `obf` | `-obf-profile` | профиль обфускации (`rtpopus` \| `rtpopus2`); `none` опускается. |
| `mode` | `-mode` | режим туннеля: `udp` \| `tcp`; отсутствует - `udp`. |
| `bond` | `-bond` | объединять сессии для одного TCP-соединения (`true`); только `mode: tcp`, по умолчанию выключено. |
| `obf` | `-obf-profile` | профиль обфускации (`rtpopus` \| `rtpopus2` \| `rtpopus3`); `none` опускается. |
| `key` | `-obf-key` | ключ обфускации (hex), только при заданном `obf`. |
| `n` | `-n` | число TURN-потоков. |
| `spc` | `-streams-per-cred` | потоков на один кеш VK-учёток. |
| `n` | `-n` | число TURN-потоков; отсутствует - дефолт клиента. |
| `spc` | `-streams-per-cred` | потоков на один кеш учёток; отсутствует - дефолт клиента. |
| `cid` | `-client-id` | Client ID гостя; owner добавляет его в allowlist (`clients.json`). |
| `listen` | `-listen` | локальный `ip:port` для WireGuard/Xray. |
| `dns` | `-dns-mode` | резолвер клиента: `plain` \| `doh` \| `auto`. |
| `dnss` | `-dns-servers` | свои DNS через запятую. |
| `mcap` | `-manual-captcha` | ручная VK captcha (`true`). |
| `mcap` | `-manual-captcha` | ручная captcha (`true`). |
| `kcp` | `-kcp-*` | профиль ARQ для `mode: tcp` (`nodelay`, `interval`, `resend`, `nc`, `sndwnd`, `rcvwnd`, `mtu`, `acknodelay`); отсутствует - дефолт. |
| `name` | - | имя клиента / комментарий. |
> [!NOTE]
> **Про `cid`:** на каждую ссылку генерируется **свежий** Client ID, который owner
> добавляет в `clients.json` (комментарий = `name`). Без `cid` в allowlist клиент не
> авторизуется. В ссылку входят все параметры подключения - не входит только `-link`
> (звонок VK, уникален для каждого клиента).
> (звонок уникален для каждого клиента).
### Пример (декодированный payload)
```json
{"v":1,"provider":"vk","peer":"1.2.3.4:56000","transport":"tcp","mode":"udp",
"obf":"rtpopus","key":"d823fa...","n":10,"cid":"a1b2c3...","name":"RU-Server"}
"obf":"rtpopus3","key":"d823fa...","n":10,"cid":"a1b2c3...","name":"RU-Server"}
```
## Пример использования
```bash
./client "freeturn://eyJ2IjoxLCJwcm92aWRlciI6InZrIiwicGVlciI6..." -link "https://vk.ru/call/join/..."
./client "freeturn://eyJ2IjoxLCJwcm92aWRlciI6InZrIiwicGVlciI6..." -link "<call-link>"
```
Параметры из URI переопределяют базовые флаги (`-peer`, `-mode`, `-obf-key`, `-n` ...).
Параметры из URI переопределяют базовые флаги (`-peer`, `-transport`, `-mode`, `-obf-key`, `-n` ...).
Версия ссылки не меняется: `mode` и `kcp` - опциональные поля, старый клиент их игнорирует и остаётся на `udp` с дефолтным ARQ.
+21 -19
View File
@@ -1,49 +1,51 @@
module github.com/samosvalishe/free-turn-proxy
go 1.26.5
go 1.26.7
require (
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.15.1
github.com/cbeuw/connutil v1.0.1
github.com/google/uuid v1.6.0
github.com/miekg/dns v1.1.72
github.com/pion/dtls/v3 v3.1.2
github.com/miekg/dns v1.1.73
github.com/pion/dtls/v3 v3.1.5
github.com/pion/logging v0.2.4
github.com/pion/transport/v4 v4.0.1
github.com/pion/turn/v5 v5.0.4
github.com/pion/stun/v3 v3.1.7
github.com/pion/transport/v4 v4.1.0
github.com/pion/turn/v5 v5.0.13
github.com/xtaci/kcp-go/v5 v5.6.72
github.com/xtaci/smux v1.5.57
golang.org/x/crypto v0.53.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96
golang.org/x/net v0.56.0
golang.org/x/crypto v0.55.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38
golang.org/x/net v0.58.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.14.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/reedsolomon v1.14.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
)
tool golang.org/x/mobile/cmd/gobind
+48 -42
View File
@@ -1,7 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
@@ -21,8 +23,6 @@ github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgL
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -37,6 +37,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -45,35 +47,33 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v5 v5.0.4 h1:xKAnP1b5eCnjFPd55OgxkqIVoyzbHKZa06SxZ3fopXQ=
github.com/pion/turn/v5 v5.0.4/go.mod h1:zbPsMp+fIVhKt5uWu2jcjk88FcoRbGMBSzVsaTuhcmM=
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4=
github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
@@ -88,23 +88,25 @@ github.com/xtaci/smux v1.5.57 h1:N72VbGoSYxgcm6mPOYX0QzEZNVD3UI/JlVvAtXF+WrY=
github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 h1:O1tFPDuiV6lk+c24ew9UqcKgmhgDF6aPXwSvYGxVR2A=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38 h1:KOTVVE9EBLJYMOieQRB5IdYazO/s7TMqVqRNQdO7CGk=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I=
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -112,28 +114,28 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -141,13 +143,15 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -164,5 +168,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+2 -5
View File
@@ -17,15 +17,12 @@ func sharedDohResolver() *DohResolver {
return dohResolverInstance
}
// AppDialer возвращает net.Dialer для tls-client и других HTTP-вызывающих.
// DNS-транспорт выбирается по mode (udp | doh | auto).
// AppDialer возвращает net.Dialer с DNS-транспортом по mode (udp | doh | auto).
func AppDialer(mode string) net.Dialer {
return buildDialer(mode, sharedDohResolver())
}
// InstallGlobalResolver выставляет net.DefaultResolver на тот же DNS-транспорт,
// что и AppDialer - чтобы сторонние библиотеки, собирающие свой http.Client
// без нашего Dialer, тоже шли через DoH / auto-fallback, а не OS resolver.
// InstallGlobalResolver устанавливает net.DefaultResolver для библиотек со стандартным http.Client.
func InstallGlobalResolver(mode string) {
d := AppDialer(mode)
if d.Resolver != nil {
+70 -99
View File
@@ -1,6 +1,4 @@
// Package dnsdial владеет DNS-резолвингом и net.Dialer'ом, прокинутым во все
// outbound HTTP/TLS клиенты. По Mode выбирает UDP/53, DNS-over-HTTPS или auto
// (UDP-probe → sticky DoH fallback).
// Package dnsdial управляет DNS-резолвингом и dialer для outbound HTTP/TLS (UDP/53, DoH, auto fallback).
package dnsdial
import (
@@ -23,20 +21,19 @@ import (
"github.com/samosvalishe/free-turn-proxy/internal/logx"
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
// встроенные Mozilla CA roots для CGO_ENABLED=0 сборок (Android).
// Mozilla CA roots для сборок без CGO.
_ "golang.org/x/crypto/x509roots/fallback"
)
// Log - пакетный логгер. По умолчанию no-op; main устанавливает через SetLogger.
var Log logx.Logger = logx.Nop()
var logHolder logx.Holder
// SetLogger ставит логгер пакета.
func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
func SetLogger(l logx.Logger) { logHolder.Set(l) }
func Log() logx.Logger { return logHolder.Get() }
const (
dohQueryTimeout = 6 * time.Second
// общий бюджет для всех попыток endpoint'ов в forwardRaw. Кратно
// dohQueryTimeout - чтобы каждому fallback хватило времени.
// dohForwardBudget кратен dohQueryTimeout для прохода по всем fallback-эндпоинтам.
dohForwardBudget = 25 * time.Second
dohMaxResponseBytes = 64 * 1024
dohContentType = "application/dns-message"
@@ -52,31 +49,26 @@ const (
autoUDPBudget = 1500 * time.Millisecond
)
// DohEndpoint - один DNS-over-HTTPS сервер с bootstrap-IP, чтобы резолв самого
// hostname не требовал DNS.
// DohEndpoint задаёт DNS-over-HTTPS сервер со статическими bootstrap-IP.
type DohEndpoint struct {
URL string
Hostname string
BootstrapIPs []string
}
// Yandex - первый, т.к. остаётся доступен у RU мобильных операторов даже когда
// международные резолверы блокируются; Google и Cloudflare - fallback.
// Yandex в начале списка из-за доступности у RU-операторов; остальные - fallback.
var defaultDohEndpoints = []DohEndpoint{
{"https://common.dot.dns.yandex.net/dns-query", "common.dot.dns.yandex.net", []string{"77.88.8.8", "77.88.8.1"}},
{"https://secure.dot.dns.yandex.net/dns-query", "secure.dot.dns.yandex.net", []string{"77.88.8.88", "77.88.8.2"}},
{"https://family.dot.dns.yandex.net/dns-query", "family.dot.dns.yandex.net", []string{"77.88.8.7", "77.88.8.3"}},
}
// DohResolver делает POST с DNS-wire запросом к одному из DoH endpoint'ов.
type DohResolver struct {
endpoints []DohEndpoint
client *http.Client
}
// NewDohResolver конструирует резолвер; если endpoints=nil, берёт
// defaultDohEndpoints. Имена endpoint'ов диалятся по BootstrapIPs - DoH-транспорт
// не зависит от системного резолвера.
// NewDohResolver создаёт DoH резолвер с прямым bootstrap IP dialer без системного DNS.
func NewDohResolver(endpoints []DohEndpoint) *DohResolver {
if len(endpoints) == 0 {
endpoints = defaultDohEndpoints
@@ -87,13 +79,10 @@ func NewDohResolver(endpoints []DohEndpoint) *DohResolver {
}
}
// newDohResolverWithClient - тестовый hook, минующий bootstrap-транспорт.
func newDohResolverWithClient(endpoints []DohEndpoint, client *http.Client) *DohResolver {
return &DohResolver{endpoints: endpoints, client: client}
}
// newBootstrapTransport возвращает http.Transport, чей DialContext знает только
// заданные hostname'ы DoH endpoint'ов и резолвит их в BootstrapIPs.
func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
bootstrap := make(map[string][]string, len(endpoints))
for _, ep := range endpoints {
@@ -126,9 +115,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
}
return nil, lastErr
},
// явный DialTLSContext гарантирует SNI = hostname, даже если TCP-dial
// идёт на bootstrap-IP. Без этого некоторые HTTP/2 пути сливают
// литерал IP как ServerName и TLS падает.
// Явный SNI гарантирует передачу hostname при TCP-dial по bootstrap-IP в HTTP/2.
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
@@ -162,14 +149,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
}
}
// forwardRaw делает POST opaque DNS-wire запроса к настроенным DoH endpoint'ам
// по порядку и возвращает первый успешный raw-ответ вместе с endpoint'ом.
// Без парсинга - удобно для локального форвардера, который пропускает что бы
// upstream ни ответил (RESINFO/HTTPS/SVCB/EDNS options/…).
//
// Каждому endpoint'у - свой per-attempt deadline (dohQueryTimeout), чтобы
// медленный первый не сожрал весь бюджет и не зарезал fallback'и. Parent ctx
// всё ещё ограничивает общее ожидание через cancel chain.
// forwardRaw отправляет DNS-wire запрос к эндпоинтам по очереди с таймаутом на попытку.
func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, error) {
if len(r.endpoints) == 0 {
return nil, errors.New("doh: no endpoints configured")
@@ -183,7 +163,7 @@ func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, err
body, err := r.postWire(epCtx, ep, query)
cancel()
if err != nil {
Log.Warnf("[DoH] %s: %v", ep.Hostname, err)
Log().Warnf("[DoH] %s: %v", ep.Hostname, err)
lastErr = err
continue
}
@@ -192,7 +172,6 @@ func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, err
return nil, lastErr
}
// postWire делает один application/dns-message POST к одному endpoint'у.
func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(query))
if err != nil {
@@ -218,11 +197,7 @@ func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte
return body, nil
}
// net.Resolver Go дозванивается до этого stub'а как до обычного nameserver'а -
// обходит уйму edge-case'ов fake-net.Conn (RESINFO probes, EDNS handshakes,
// truncation, …). Что бы он ни прочитал на UDP/TCP - уходит дословно в DoH
// endpoint, ответ отдаётся клиенту.
// dohForwarder - локальный stub DNS сервер, проксирующий raw DNS-пакеты в DoH.
type dohForwarder struct {
udpAddr string
tcpAddr string
@@ -232,19 +207,21 @@ var (
dohForwarderOnce sync.Once
dohForwarderInst *dohForwarder
dohForwarderErr error
// Резолвер подменяется на лету: ядро перезапускается в том же процессе (mobile), а Once держит листенеры.
dohForwarderRes atomic.Pointer[DohResolver]
)
// sharedDohForwarder лениво запускает process-wide форвардер, привязанный к
// заданному resolver. Побеждает первый caller; следующие переиспользуют тот же
// форвардер независимо от того, что передали.
func sharedDohForwarder(r *DohResolver) (*dohForwarder, error) {
dohForwarderRes.Store(r)
dohForwarderOnce.Do(func() {
dohForwarderInst, dohForwarderErr = startDohForwarder(r)
dohForwarderInst, dohForwarderErr = startDohForwarder()
})
return dohForwarderInst, dohForwarderErr
}
func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) {
func currentDohResolver() *DohResolver { return dohForwarderRes.Load() }
func startDohForwarder() (_ *dohForwarder, err error) {
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
return nil, fmt.Errorf("doh forwarder: listen UDP: %w", err)
@@ -268,51 +245,51 @@ func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) {
udpAddr: udpConn.LocalAddr().String(),
tcpAddr: tcpLn.Addr().String(),
}
Log.Infof("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr)
Log().Infof("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr)
go fwd.serveUDP(udpConn, r)
go fwd.serveTCP(tcpLn, r)
go fwd.serveUDP(udpConn)
go fwd.serveTCP(tcpLn)
return fwd, nil
}
func (*dohForwarder) serveUDP(conn *net.UDPConn, r *DohResolver) {
func (*dohForwarder) serveUDP(conn *net.UDPConn) {
defer func() { _ = conn.Close() }()
buf := make([]byte, forwarderUDPBufSize)
for {
n, client, err := conn.ReadFromUDP(buf)
if err != nil {
Log.Errorf("[DoH] udp read: %v", err)
Log().Errorf("[DoH] udp read: %v", err)
return
}
query := append([]byte(nil), buf[:n]...)
go func(q []byte, c *net.UDPAddr) {
ctx, cancel := context.WithTimeout(context.Background(), dohForwardBudget)
defer cancel()
resp, err := r.forwardRaw(ctx, q)
resp, err := currentDohResolver().forwardRaw(ctx, q)
if err != nil {
Log.Warnf("[DoH] udp forward failed: %v", err)
Log().Warnf("[DoH] udp forward failed: %v", err)
return
}
if _, err := conn.WriteToUDP(resp, c); err != nil {
Log.Warnf("[DoH] udp write: %v", err)
Log().Warnf("[DoH] udp write: %v", err)
}
}(query, client)
}
}
func (*dohForwarder) serveTCP(ln *net.TCPListener, r *DohResolver) {
func (*dohForwarder) serveTCP(ln *net.TCPListener) {
defer func() { _ = ln.Close() }()
for {
conn, err := ln.Accept()
if err != nil {
Log.Errorf("[DoH] tcp accept: %v", err)
Log().Errorf("[DoH] tcp accept: %v", err)
return
}
go handleDohForwarderTCP(conn, r)
go handleDohForwarderTCP(conn)
}
}
func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
func handleDohForwarderTCP(conn net.Conn) {
defer func() { _ = conn.Close() }()
for {
_ = conn.SetReadDeadline(time.Now().Add(forwarderTCPReadDL)) //nolint:errcheck
@@ -329,15 +306,15 @@ func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout)
resp, err := r.forwardRaw(ctx, query)
ctx, cancel := context.WithTimeout(context.Background(), dohForwardBudget)
resp, err := currentDohResolver().forwardRaw(ctx, query)
cancel()
if err != nil {
Log.Warnf("[DoH] tcp forward failed: %v", err)
Log().Warnf("[DoH] tcp forward failed: %v", err)
return
}
if len(resp) > 0xFFFF {
Log.Warnf("[DoH] response too large for TCP framing: %d", len(resp))
Log().Warnf("[DoH] response too large for TCP framing: %d", len(resp))
return
}
out := make([]byte, 2+len(resp))
@@ -351,8 +328,6 @@ func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
}
}
// dohForwarderDial возвращает Resolver.Dial, подключающийся к локальному DoH
// форвардеру по UDP или TCP (что запросил резолвер).
func dohForwarderDial(r *DohResolver) dialFunc {
return func(ctx context.Context, network, _ string) (net.Conn, error) {
fwd, err := sharedDohForwarder(r) //nolint:contextcheck
@@ -377,7 +352,9 @@ const (
var udpDNSServersPtr atomic.Pointer[[]string]
func init() {
func init() { ResetUDPDNSServers() }
func ResetUDPDNSServers() {
def := []string{
"77.88.8.8:53", "77.88.8.1:53",
"8.8.8.8:53", "8.8.4.4:53",
@@ -388,13 +365,7 @@ func init() {
func udpDNSServers() []string { return *udpDNSServersPtr.Load() }
// SetUDPDNSServers заменяет дефолтный список UDP/53 серверов. Каждый элемент -
// "ip" или "ip:port"; голые IP получают :53. Пустой список оставляет дефолт.
// На Android используется для подсовывания резолверов оператора из
// LinkProperties.dnsServers - часто работают там, где публичный DoH/DoT нет.
//
// Безопасно вызывать конкурентно с использованием резолвера - указатель списка
// меняется атомарно. Уже идущие dial видят свой захваченный снимок.
// SetUDPDNSServers атомарно обновляет список UDP/53 серверов (IP или IP:Port).
func SetUDPDNSServers(servers []string) {
if len(servers) == 0 {
return
@@ -418,9 +389,6 @@ func SetUDPDNSServers(servers []string) {
type dialFunc = func(context.Context, string, string) (net.Conn, error)
// buildDialer возвращает net.Dialer, чей внутренний Go-резолвер использует
// выбранный DNS-транспорт. В режиме "auto" первый полный отказ UDP/53
// залипает процесс на DoH до конца его жизни.
func buildDialer(mode string, r *DohResolver) net.Dialer {
switch mode {
case DNSModePlain:
@@ -434,8 +402,6 @@ func buildDialer(mode string, r *DohResolver) net.Dialer {
}
}
// newAppDialer оборачивает Resolver.Dial таймаутами, используемыми везде в
// приложении для outbound TCP/HTTP.
func newAppDialer(dial dialFunc) net.Dialer {
return net.Dialer{
Timeout: appDialerTimeout,
@@ -445,10 +411,9 @@ func newAppDialer(dial dialFunc) net.Dialer {
}
}
// udpDNSDial берёт первый достижимый UDP/53 резолвер из udpDNSServers.
func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
var (
d net.Dialer
d = net.Dialer{Control: netctl.Apply}
lastErr error
)
for _, s := range udpDNSServers() {
@@ -464,37 +429,34 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
return nil, lastErr
}
// autoDial возвращает Dial, который один раз пробит UDP/53 реальным DNS
// round-trip'ом; при провале залипает на DoH до конца процесса. Сделано под
// Android, где сеть скачет между Wi-Fi (UDP/53 работает) и мобилкой (блок).
//
// Просто dial-timeout не годится: UDP "dial" безсоединительный и всегда успешен
// мгновенно. Единственный способ узнать - реально отправить запрос и ждать ответ.
func autoDial(r *DohResolver) dialFunc {
var (
probed sync.Once
useDoH atomic.Bool
mu sync.Mutex
probed *[]string
useDoH bool
doh = dohForwarderDial(r)
)
return func(ctx context.Context, network, addr string) (net.Conn, error) {
probed.Do(func() {
if udpProbe(autoUDPBudget) {
Log.Infof("[DNS] UDP/53 probe OK, using UDP")
mu.Lock()
if servers := udpDNSServersPtr.Load(); servers != probed {
probed = servers
useDoH = !udpProbe(autoUDPBudget)
if useDoH {
Log().Warnf("[DNS] UDP/53 unreachable; switching to DoH until network change")
} else {
Log.Warnf("[DNS] UDP/53 unreachable; sticky-switching to DoH")
useDoH.Store(true)
Log().Infof("[DNS] UDP/53 probe OK, using UDP")
}
})
if useDoH.Load() {
}
viaDoH := useDoH
mu.Unlock()
if viaDoH {
return doh(ctx, network, addr)
}
return udpDNSDial(ctx, network, addr)
}
}
// udpProbe шлёт реальный DNS A-запрос к known-домену по UDP и проверяет, придёт
// ли ответ до дедлайна. Пробуем первые два сервера из udpDNSServers под общим
// deadline - если ни один не ответил, UDP/53 заблокирован.
// udpProbe проверяет доступность UDP/53 реальным DNS-запросом к первым серверам.
func udpProbe(timeout time.Duration) bool {
m := new(dns.Msg)
m.SetQuestion("dns.google.", dns.TypeA)
@@ -508,22 +470,31 @@ func udpProbe(timeout time.Duration) bool {
buf := make([]byte, 512)
servers := udpDNSServers()
limit := min(len(servers), 2)
for _, server := range servers[:limit] {
for i, server := range servers[:limit] {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
conn, err := net.DialTimeout("udp", server, remaining) //nolint:noctx
// Остаток бюджета делится поровну: молчащий сервер не съедает попытку следующего.
attempt := remaining / time.Duration(limit-i)
// netctl.Apply обязателен для исключения сокета пробы из VPN-туннеля.
d := net.Dialer{Timeout: attempt, Control: netctl.Apply}
conn, err := d.Dial("udp", server) //nolint:noctx
if err != nil {
continue
}
_ = conn.SetDeadline(deadline) //nolint:errcheck
_ = conn.SetDeadline(time.Now().Add(attempt)) //nolint:errcheck
_, _ = conn.Write(wire)
n, err := conn.Read(buf)
_ = conn.Close()
if err == nil && n > 12 {
if err == nil && isReply(m, buf[:n]) {
return true
}
}
return false
}
func isReply(query *dns.Msg, raw []byte) bool {
var resp dns.Msg
return resp.Unpack(raw) == nil && resp.Response && resp.Id == query.Id
}
+94 -65
View File
@@ -12,35 +12,22 @@ import (
"github.com/miekg/dns"
)
// dohAnswer builds a wire-format DNS reply for a single question with one
// answer of the matching type (A or AAAA). TTL is returned as-is.
func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
func dnsAnswer(t *testing.T, query []byte) []byte {
t.Helper()
req := new(dns.Msg)
if err := req.Unpack(query); err != nil {
t.Fatalf("unpack query: %v", err)
}
reply := new(dns.Msg)
reply.SetReply(req)
if len(req.Question) != 1 {
t.Fatalf("expected 1 question, got %d", len(req.Question))
}
q := req.Question[0]
switch q.Qtype {
case dns.TypeA:
if v4 := ip.To4(); v4 != nil {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: v4,
})
}
case dns.TypeAAAA:
if ip.To4() == nil {
reply.Answer = append(reply.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
AAAA: ip,
})
}
reply := new(dns.Msg)
reply.SetReply(req)
if q := req.Question[0]; q.Qtype == dns.TypeA {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.IPv4(9, 9, 9, 9),
})
}
out, err := reply.Pack()
if err != nil {
@@ -49,55 +36,97 @@ func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
return out
}
func readWire(t *testing.T, r io.Reader) []byte {
func mockDoH(t *testing.T) *DohResolver {
t.Helper()
b, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read body: %v", err)
}
return b
}
func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
// DoH backend: always responds with a valid wire-format reply.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readWire(t, r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
return
}
w.Header().Set("Content-Type", "application/dns-message")
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) //nolint:errcheck
_, _ = w.Write(dnsAnswer(t, body))
}))
defer srv.Close()
resolver := newDohResolverWithClient(
t.Cleanup(srv.Close)
return newDohResolverWithClient(
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
srv.Client(),
)
dial := autoDial(resolver)
// Poison udpDNSServers so that udpProbe (real DNS round-trip) fails
// immediately — net.DialTimeout rejects the malformed address.
old := udpDNSServersPtr.Load()
bad := []string{"not-a-valid-host-port"}
udpDNSServersPtr.Store(&bad)
defer func() { udpDNSServersPtr.Store(old) }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn1, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("first dial: %v", err)
}
_ = conn1.Close()
// Second call must skip UDP entirely. We assert this by poisoning
// udpDNSServers with a value that would fail parsing — if the dialer
// touches UDP again the call errors loudly.
bad2 := []string{"still-not-a-valid-host-port"}
udpDNSServersPtr.Store(&bad2)
conn2, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("second dial: %v", err)
}
_ = conn2.Close()
}
// udpDNSServer поднимает UDP/53-стенд; reply == nil - сервер молчит.
func udpDNSServer(t *testing.T, reply func(query []byte) []byte) string {
t.Helper()
pc, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pc.Close() })
go func() {
buf := make([]byte, 512)
for {
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return
}
if reply != nil {
_, _ = pc.WriteTo(reply(buf[:n]), addr)
}
}
}()
return pc.LocalAddr().String()
}
func useUDPServers(t *testing.T, servers []string) {
t.Helper()
old := udpDNSServersPtr.Load()
udpDNSServersPtr.Store(&servers)
t.Cleanup(func() { udpDNSServersPtr.Store(old) })
}
func TestAutoDial_DoHStickyUntilServersChange(t *testing.T) {
dial := autoDial(mockDoH(t))
// Невалидный адрес валит UDP-пробу сразу.
servers := []string{"not-a-valid-host-port"}
useUDPServers(t, servers)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
dialTo := func() string {
t.Helper()
conn, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
return conn.RemoteAddr().String()
}
dialTo()
good := udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })
servers[0] = good
if dialTo() == good {
t.Fatal("re-probed UDP without servers change")
}
SetUDPDNSServers([]string{good})
if got := dialTo(); got != good {
t.Fatalf("after servers change dial went to %s, want UDP %s", got, good)
}
}
// Молчащий первый сервер не должен съедать бюджет второго.
func TestUDPProbe_SilentFirstServer(t *testing.T) {
useUDPServers(t, []string{udpDNSServer(t, nil), udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })})
if !udpProbe(300 * time.Millisecond) {
t.Fatal("udpProbe = false, want true via second server")
}
}
// Эхо запроса - не DNS-ответ, UDP/53 такой сервер не обслуживает.
func TestUDPProbe_RejectsNonResponse(t *testing.T) {
useUDPServers(t, []string{udpDNSServer(t, func(q []byte) []byte { return append([]byte(nil), q...) })})
if udpProbe(300 * time.Millisecond) {
t.Fatal("udpProbe = true on echoed query, want false")
}
}
+5 -13
View File
@@ -17,8 +17,7 @@ type listener struct {
fd int
}
// WrapListener подменяет стандартный net.Listener на legacy-syscall listener,
// заточенный под iSH-симулятор на iOS, где нет современного `accept4`.
// WrapListener подменяет net.Listener на legacy socketcall для работы в iSH-эмуляторе (нет accept4).
func WrapListener(ln net.Listener) (net.Listener, error) {
tl, ok := ln.(*net.TCPListener)
if !ok {
@@ -29,14 +28,12 @@ func WrapListener(ln net.Listener) (net.Listener, error) {
return nil, err
}
// держим ссылку на *os.File, чтобы GC не закрыл FD.
// Ссылка на *os.File предотвращает закрытие FD сборщиком мусора.
return &listener{Listener: ln, f: f, fd: int(f.Fd())}, nil
}
func (l *listener) Accept() (net.Conn, error) {
// ставим listener-сокет в blocking. Go по умолчанию делает его non-blocking.
// Это избавляет от time.Sleep в spin-loop, который триггерит futex_time64 SIGSYS
// в современном Go на iSH.
// Блокирующий сокет предотвращает spin-loop со sleep, триггерящий futex_time64 SIGSYS в iSH.
if err := syscall.SetNonblock(l.fd, false); err != nil {
return nil, err
}
@@ -45,11 +42,9 @@ func (l *listener) Accept() (net.Conn, error) {
addr := make([]byte, 128)
addrlen := uintptr(128)
// i386 сетевые syscall'ы мультиплексируются через socketcall (102).
// SYS_ACCEPT - subcall 5.
// i386: socketcall (102), SYS_ACCEPT (5).
args := [3]uintptr{uintptr(l.fd), uintptr(unsafe.Pointer(&addr[0])), uintptr(unsafe.Pointer(&addrlen))}
// Syscall6 - чтобы хватило регистров аргументов на этой платформе.
r1, _, errno := syscall.Syscall6(102, 5, uintptr(unsafe.Pointer(&args)), 0, 0, 0, 0)
if errno != 0 {
if errno == syscall.EINTR {
@@ -63,15 +58,13 @@ func (l *listener) Accept() (net.Conn, error) {
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 256*1024)
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 256*1024)
// избегаем net.FileConn - она регистрирует fd в Go epoll poller'е, что в
// iSH стабильно падает с EEXIST. Возвращаем кастомный blocking net.Conn.
// net.FileConn падает с EEXIST в epoll poller iSH; используется блокирующий raw FD conn.
conn := &ishConn{fd: nfd}
return conn, nil
}
}
func (l *listener) Close() error {
// закрываем и дублированный FD, и оригинальный listener.
err1 := l.f.Close()
err2 := l.Listener.Close()
if err1 != nil {
@@ -80,7 +73,6 @@ func (l *listener) Close() error {
return err2
}
// ishConn обходит сетевой poller Go, чтобы не словить EEXIST в iSH.
type ishConn struct {
fd int
}
+1 -5
View File
@@ -1,14 +1,10 @@
//go:build !(linux && 386)
// Package ish - TCP listener shim для iSH (https://ish.app), Linux user-mode
// эмулятор на iOS. iSH таргетит linux/386 и не имеет современного accept4 /
// epoll-poller Go, поэтому там нужен sandbox-aware accept loop. На любом
// другом GOOS/GOARCH WrapListener - no-op passthrough.
// Package ish предоставляет адаптер TCP listener для эмулятора iSH на iOS.
package ish
import "net"
// WrapListener возвращает ln без изменений на не-iSH платформах.
func WrapListener(ln net.Listener) (net.Listener, error) {
return ln, nil
}
+42
View File
@@ -0,0 +1,42 @@
// Package clientid управляет персистентным ID клиента (allowlist сервера, seed отпечатка персоны).
package clientid
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/samosvalishe/free-turn-proxy/internal/statedir"
)
const fileName = "client_config.json"
type fileFormat struct {
ClientID string `json:"client_id"`
}
// Resolve возвращает существующий или сгенерированный ID. persisted=false при ошибке записи на диск.
func Resolve(id string, paths []string) (resolved string, persisted bool, err error) {
if id != "" {
return id, true, nil
}
for _, b := range statedir.ReadEach(paths) {
var f fileFormat
if json.Unmarshal(b, &f) == nil && f.ClientID != "" {
return f.ClientID, true, nil
}
}
buf := make([]byte, 16)
if _, rerr := rand.Read(buf); rerr != nil {
return "", false, fmt.Errorf("generate client id: %w", rerr)
}
newID := hex.EncodeToString(buf)
b, _ := json.MarshalIndent(fileFormat{ClientID: newID}, "", " ")
return newID, statedir.WriteFirst(paths, b), nil
}
func DefaultPaths() []string { return statedir.Paths(fileName) }
+116
View File
@@ -0,0 +1,116 @@
package clientid
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestResolveKeepsExplicitID(t *testing.T) {
id, persisted, err := Resolve("explicit", nil)
if err != nil || id != "explicit" || !persisted {
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
}
}
func TestResolveReadsFirstValidFile(t *testing.T) {
dir := t.TempDir()
broken := filepath.Join(dir, "broken.json")
good := filepath.Join(dir, fileName)
if err := os.WriteFile(broken, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(good, []byte(`{"client_id":"stored"}`), 0o600); err != nil {
t.Fatal(err)
}
id, persisted, err := Resolve("", []string{broken, good})
if err != nil || id != "stored" || !persisted {
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
}
}
func TestResolveOverwritesFileWithEmptyID(t *testing.T) {
empty := filepath.Join(t.TempDir(), fileName)
if err := os.WriteFile(empty, []byte(`{"client_id":""}`), 0o600); err != nil {
t.Fatal(err)
}
id, persisted, err := Resolve("", []string{empty})
if err != nil || !persisted {
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
}
if len(id) != 32 {
t.Fatalf("generated id = %q, want 32 hex chars", id)
}
assertStoredID(t, empty, id)
}
func TestResolveCreatesMissingDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "nested", "dir", fileName)
id, persisted, err := Resolve("", []string{target})
if err != nil || !persisted {
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
}
assertStoredID(t, target, id)
}
func assertStoredID(t *testing.T, path, want string) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("id not persisted: %v", err)
}
var f fileFormat
if json.Unmarshal(b, &f) != nil || f.ClientID != want {
t.Fatalf("persisted file = %s, want client_id %q", b, want)
}
}
func TestResolveGeneratedIDIsStableAcrossCalls(t *testing.T) {
paths := []string{filepath.Join(t.TempDir(), fileName)}
first, _, err := Resolve("", paths)
if err != nil {
t.Fatal(err)
}
second, _, err := Resolve("", paths)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("id rotated between calls: %q != %q", first, second)
}
}
func TestResolveReportsUnpersistedID(t *testing.T) {
id, persisted, err := Resolve("", nil)
if err != nil {
t.Fatal(err)
}
if persisted {
t.Fatal("persisted = true without any writable path")
}
if len(id) != 32 {
t.Fatalf("generated id = %q, want 32 hex chars", id)
}
}
func TestDefaultPathsAreUniqueAndNamed(t *testing.T) {
paths := DefaultPaths()
if len(paths) == 0 {
t.Fatal("DefaultPaths() is empty")
}
seen := map[string]bool{}
for _, p := range paths {
if filepath.Base(p) != fileName {
t.Fatalf("path %q does not end with %q", p, fileName)
}
if seen[p] {
t.Fatalf("duplicate path %q", p)
}
seen[p] = true
}
}
+159 -38
View File
@@ -1,26 +1,28 @@
package clientsdb
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net"
"os"
"path/filepath"
"sync"
"time"
)
// ClientInfo содержит метаданные о клиенте
type ClientInfo struct {
Comment string `json:"comment,omitempty"`
}
// Data структура JSON-файла
type Data struct {
Clients map[string]ClientInfo `json:"clients"`
}
// DB - база данных клиентов с поддержкой hot-reload
type DB struct {
mu sync.RWMutex
path string
@@ -28,7 +30,6 @@ type DB struct {
lastModified time.Time
}
// New создает новую базу данных и загружает ее из файла
func New(path string) (*DB, error) {
db := &DB{
path: path,
@@ -36,7 +37,6 @@ func New(path string) (*DB, error) {
}
if err := db.load(); err != nil {
// Если файла нет, это не ошибка, просто пустая база
if !os.IsNotExist(err) {
return nil, err
}
@@ -45,18 +45,21 @@ func New(path string) (*DB, error) {
return db, nil
}
// StartHotReload запускает горутину, которая периодически проверяет файл на изменения
func (db *DB) StartHotReload(interval time.Duration) {
func (db *DB) StartHotReload(ctx context.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
db.loadIfModified()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
db.loadIfModified()
}
}
}()
}
// IsAuthorized проверяет, разрешен ли клиент
func (db *DB) IsAuthorized(clientID string) bool {
db.mu.RLock()
defer db.mu.RUnlock()
@@ -64,25 +67,24 @@ func (db *DB) IsAuthorized(clientID string) bool {
return ok
}
// Add добавляет или обновляет клиента (и сразу сохраняет на диск)
func (db *DB) Add(clientID, comment string) error {
db.mu.Lock()
defer db.mu.Unlock()
db.data.Clients[clientID] = ClientInfo{Comment: comment}
return db.save()
next := Data{Clients: maps.Clone(db.data.Clients)}
next.Clients[clientID] = ClientInfo{Comment: comment}
return db.save(next)
}
// Remove удаляет клиента
func (db *DB) Remove(clientID string) error {
db.mu.Lock()
defer db.mu.Unlock()
delete(db.data.Clients, clientID)
return db.save()
next := Data{Clients: maps.Clone(db.data.Clients)}
delete(next.Clients, clientID)
return db.save(next)
}
// List возвращает всех клиентов
func (db *DB) List() map[string]ClientInfo {
db.mu.RLock()
defer db.mu.RUnlock()
@@ -136,18 +138,23 @@ func (db *DB) loadIfModified() {
}
}
func (db *DB) save() error {
b, err := json.MarshalIndent(db.data, "", " ")
func (db *DB) save(next Data) error {
b, err := json.MarshalIndent(next, "", " ")
if err != nil {
return err
}
tmpFile := db.path + ".tmp"
err = os.WriteFile(tmpFile, b, 0o600) // содержит Client ID (auth-токены) - не world-readable
err = writeSync(tmpFile, b)
if err == nil {
err = os.Rename(tmpFile, db.path)
}
if err == nil {
syncDir(filepath.Dir(db.path))
}
if err == nil {
// Авторизация меняется только после успешной замены файла.
db.data = next
stat, _ := os.Stat(db.path)
if stat != nil {
db.lastModified = stat.ModTime()
@@ -156,36 +163,150 @@ func (db *DB) save() error {
return err
}
// WriteClientID отправляет Client ID (1 байт длины + строка) в соединение
func WriteClientID(conn net.Conn, clientID string) error {
b := []byte(clientID)
if len(b) > 255 {
b = b[:255] // Усекаем до 255 байт
func writeSync(path string, b []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // 0o600: файл содержит Client ID токены авторизации
if err != nil {
return err
}
if _, err = f.Write(b); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(path)
}
buf := make([]byte, 1+len(b))
buf[0] = byte(len(b)) //nolint:gosec // len(b) усечён до ≤255 выше
copy(buf[1:], b)
_, err := conn.Write(buf)
return err
}
// ReadClientID читает Client ID из соединения. Учитывает DTLS record-orientated поведение.
func ReadClientID(conn net.Conn) (string, error) {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
func syncDir(dir string) {
d, err := os.Open(dir)
if err != nil {
return
}
_ = d.Sync()
_ = d.Close()
}
const (
ModeUnset byte = 0
ModeUDP byte = 1
ModeTCP byte = 2
ModeTCPBond byte = 3
// idVersionAck - клиент ждёт подтверждения ID.
idVersionAck byte = 2
idAck byte = 0x06
idReadTimeout = 5 * time.Second
)
// ErrNoIDAck - сервер не подтвердил Client ID: он старше протокола подтверждения или
// канал теряет всё подряд.
var ErrNoIDAck = errors.New("clientsdb: server did not acknowledge client ID (server outdated?)")
// idRetransmit - таймеры повтора ID (RFC 6347 §4.2.4: старт 1 с, удвоение).
var idRetransmit = []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}
func idRecord(clientID string, mode byte) []byte {
b := []byte(clientID)
if len(b) > 255 {
b = b[:255]
}
buf := make([]byte, 0, len(b)+3)
buf = append(buf, byte(len(b))) //nolint:gosec // len(b) усечён до ≤255 выше
buf = append(buf, b...)
return append(buf, mode, idVersionAck)
}
func WriteClientID(ctx context.Context, conn net.Conn, clientID string, mode byte) error {
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
rec := idRecord(clientID, mode)
var buf [16]byte
for _, wait := range idRetransmit {
if _, err := conn.Write(rec); err != nil {
return fmt.Errorf("send client ID: %w", err)
}
if err := conn.SetReadDeadline(time.Now().Add(wait)); err != nil {
return fmt.Errorf("client ID deadline: %w", err)
}
for {
n, err := conn.Read(buf[:])
if err == nil && n == 1 && buf[0] == idAck {
return nil
}
if err != nil {
var ne net.Error
if !errors.As(err, &ne) || !ne.Timeout() {
return fmt.Errorf("await client ID ack: %w", err)
}
break
}
}
if ctx.Err() != nil {
return ctx.Err()
}
}
return ErrNoIDAck
}
func readClientID(conn net.Conn) (string, byte, byte, error) {
_ = conn.SetReadDeadline(time.Now().Add(idReadTimeout))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
buf := make([]byte, 256)
buf := make([]byte, 258)
n, err := conn.Read(buf)
if err != nil {
return "", err
return "", ModeUnset, 0, err
}
if n == 0 {
return "", nil
return "", ModeUnset, 0, io.ErrUnexpectedEOF
}
l := int(buf[0])
if n < 1+l {
return "", io.ErrUnexpectedEOF
return "", ModeUnset, 0, io.ErrUnexpectedEOF
}
var mode, ver byte
if n > 1+l {
mode = buf[1+l]
}
if n > 2+l {
ver = buf[2+l]
}
return string(buf[1 : 1+l]), mode, ver, nil
}
func AcceptClientID(conn net.Conn, authorize func(id string, mode byte) error) (string, net.Conn, error) {
id, mode, ver, err := readClientID(conn)
if err != nil {
return "", nil, err
}
if err := authorize(id, mode); err != nil {
return id, nil, err
}
if ver < idVersionAck {
return id, conn, nil
}
if _, err := conn.Write([]byte{idAck}); err != nil {
return id, nil, fmt.Errorf("ack client ID: %w", err)
}
return id, &ackedConn{Conn: conn, rec: idRecord(id, mode)}, nil
}
type ackedConn struct {
net.Conn
rec []byte
}
func (c *ackedConn) Read(b []byte) (int, error) {
for {
n, err := c.Conn.Read(b)
if err != nil || !bytes.Equal(b[:n], c.rec) {
return n, err
}
if _, err := c.Write([]byte{idAck}); err != nil {
return 0, err
}
}
return string(buf[1 : 1+l]), nil
}
+182 -35
View File
@@ -1,12 +1,59 @@
package clientsdb
import (
"errors"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/cbeuw/connutil"
)
func TestFailedSavePreservesAuthorization(t *testing.T) {
path := filepath.Join(t.TempDir(), "clients.json")
db, err := New(path)
if err != nil {
t.Fatal(err)
}
if err = db.Add("existing", "original"); err != nil {
t.Fatal(err)
}
// Каталог вместо временного файла воспроизводит отказ записи без прав root.
if err = os.Mkdir(path+".tmp", 0o700); err != nil {
t.Fatal(err)
}
operations := []struct {
name string
run func() error
}{
{"add", func() error { return db.Add("new", "new") }},
{"update", func() error { return db.Add("existing", "changed") }},
{"remove", func() error { return db.Remove("existing") }},
}
for _, op := range operations {
t.Run(op.name, func(t *testing.T) {
if runErr := op.run(); runErr == nil {
t.Fatal("save unexpectedly succeeded")
}
if !db.IsAuthorized("existing") || db.IsAuthorized("new") {
t.Fatal("failed save changed authorization")
}
if got := db.List(); len(got) != 1 || got["existing"].Comment != "original" {
t.Fatalf("failed save changed clients: %+v", got)
}
})
}
loaded, err := New(path)
if err != nil {
t.Fatal(err)
}
if got := loaded.List(); len(got) != 1 || got["existing"].Comment != "original" {
t.Fatalf("disk changed: %+v", got)
}
}
func TestClientsDB(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "clients.json")
@@ -36,7 +83,6 @@ func TestClientsDB(t *testing.T) {
t.Errorf("Expected client-123 to be removed")
}
// Test persistence
_ = db.Add("client-789", "Test Persistence")
db2, err := New(dbPath)
@@ -48,7 +94,6 @@ func TestClientsDB(t *testing.T) {
t.Errorf("Expected client-789 to be persisted")
}
// Test hot reload manually
db2.mu.Lock()
db2.lastModified = db2.lastModified.Add(-1 * time.Second)
db2.mu.Unlock()
@@ -61,40 +106,142 @@ func TestClientsDB(t *testing.T) {
}
}
// dropWrites теряет первые n записей - модель потерь датаграмм.
type dropWrites struct {
net.Conn
n int
}
func (d *dropWrites) Write(b []byte) (int, error) {
if d.n > 0 {
d.n--
return len(b), nil
}
return d.Conn.Write(b)
}
const testID = "e8030ba252344e536664d96f4544d64d"
func accept(t *testing.T, conn net.Conn) (string, byte, net.Conn, error) {
t.Helper()
var gotMode byte
id, data, err := AcceptClientID(conn, func(_ string, mode byte) error {
gotMode = mode
return nil
})
return id, gotMode, data, err
}
func TestClientIDRoundTrip(t *testing.T) {
addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}
serverConn, err := net.ListenUDP("udp", addr)
if err != nil {
t.Fatalf("ListenUDP: %v", err)
client, server := pipe()
errc := make(chan error, 1)
go func() { errc <- WriteClientID(t.Context(), client, testID, ModeTCP) }()
id, mode, _, err := accept(t, server)
if err != nil || id != testID || mode != ModeTCP {
t.Fatalf("accept = %q/%d/%v, want %q/%d", id, mode, err, testID, ModeTCP)
}
defer func() { _ = serverConn.Close() }()
udpAddr, ok := serverConn.LocalAddr().(*net.UDPAddr)
if !ok {
t.Fatalf("LocalAddr is not *net.UDPAddr")
}
clientConn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
t.Fatalf("DialUDP: %v", err)
}
defer func() { _ = clientConn.Close() }()
expectedID := "client-test-uuid-123"
// Client writes
go func() {
if werr := WriteClientID(clientConn, expectedID); werr != nil {
t.Errorf("WriteClientID failed: %v", werr)
}
}()
// Server reads
readID, err := ReadClientID(serverConn)
if err != nil {
t.Fatalf("ReadClientID: %v", err)
}
if readID != expectedID {
t.Errorf("expected %q, got %q", expectedID, readID)
if err := <-errc; err != nil {
t.Fatalf("WriteClientID: %v", err)
}
}
// Потеря первой ID-записи: клиент повторяет по таймеру, сессия поднимается.
func TestClientIDSurvivesLostRecord(t *testing.T) {
client, server := pipe()
errc := make(chan error, 1)
go func() { errc <- WriteClientID(t.Context(), &dropWrites{Conn: client, n: 1}, testID, ModeUDP) }()
if id, _, _, err := accept(t, server); err != nil || id != testID {
t.Fatalf("accept = %q/%v", id, err)
}
if err := <-errc; err != nil {
t.Fatalf("WriteClientID: %v", err)
}
}
func TestClientIDSurvivesLostAck(t *testing.T) {
client, server := pipe()
errc := make(chan error, 1)
go func() {
if err := WriteClientID(t.Context(), client, testID, ModeUDP); err != nil {
errc <- err
return
}
_, err := client.Write([]byte("payload"))
errc <- err
}()
_, _, data, err := accept(t, &dropWrites{Conn: server, n: 1})
if err != nil {
t.Fatalf("accept: %v", err)
}
buf := make([]byte, 64)
n, err := data.Read(buf)
if err != nil || string(buf[:n]) != "payload" {
t.Fatalf("data = %q/%v, want payload", buf[:n], err)
}
if err := <-errc; err != nil {
t.Fatalf("client: %v", err)
}
}
// Клиенты старше подтверждения: без режима ([len][id]) и с режимом ([len][id][mode]).
// Им сервер не отвечает и отдаёт соединение как есть - поведение до подтверждения.
func TestAcceptLegacyRecordNoAck(t *testing.T) {
head := append([]byte{byte(len(testID))}, testID...)
for _, tc := range []struct {
name string
rec []byte
mode byte
}{
{"no mode", head, ModeUnset},
{"with mode", append(append([]byte{}, head...), ModeUDP), ModeUDP},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := pipe()
if _, err := client.Write(tc.rec); err != nil {
t.Fatal(err)
}
id, mode, data, err := accept(t, server)
if err != nil || id != testID || mode != tc.mode || data != server {
t.Fatalf("accept = %q/%d/%v, conn untouched=%v", id, mode, err, data == server)
}
_ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if n, err := client.Read(make([]byte, 4)); err == nil {
t.Fatalf("legacy client got %d bytes of ack", n)
}
})
}
}
func TestAcceptRejectsWithoutAck(t *testing.T) {
client, server := pipe()
if _, err := client.Write(idRecord(testID, ModeUDP)); err != nil {
t.Fatal(err)
}
denied := errors.New("denied")
if _, _, err := AcceptClientID(server, func(string, byte) error { return denied }); !errors.Is(err, denied) {
t.Fatalf("err = %v, want denied", err)
}
_ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if n, err := client.Read(make([]byte, 4)); err == nil {
t.Fatalf("rejected client got %d bytes of ack", n)
}
}
// pipe - датаграммная пара; таймаут отдаётся как у DTLS (net.Error), а не своей ошибкой connutil.
func pipe() (net.Conn, net.Conn) {
a, b := connutil.AsyncPacketPipe()
return &netTimeout{a}, &netTimeout{b}
}
type netTimeout struct{ net.Conn }
func (c *netTimeout) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
if errors.Is(err, connutil.ErrTimeout) {
err = os.ErrDeadlineExceeded
}
return n, err
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"encoding/hex"
"strconv"
"strings"
)
// ClientArgs восстанавливает срез CLI-аргументов из Client структуры.
func ClientArgs(c *Client) []string {
if c == nil {
return nil
}
def := Defaults()
var args []string
addIf := func(cond bool, flag string, value ...string) {
if cond {
args = append(args, flag)
args = append(args, value...)
}
}
args = append(args, "-peer", c.Proxy.Peer)
addIf(len(c.VK.Links) > 0, "-links", strings.Join(c.VK.Links, ","))
addIf(c.Provider.Name != def.Provider.Name, "-provider", c.Provider.Name)
addIf(c.Proxy.Listen != def.Proxy.Listen, "-listen", c.Proxy.Listen)
addIf(c.TURN.Host != "", "-turn", c.TURN.Host)
addIf(c.TURN.Port != "", "-port", c.TURN.Port)
addIf(c.TURN.N != def.TURN.N, "-n", strconv.Itoa(c.TURN.N))
addIf(c.VK.StreamsPerCred != def.VK.StreamsPerCred, "-streams-per-cred", strconv.Itoa(c.VK.StreamsPerCred))
addIf(c.TURN.TransportUDP != def.TURN.TransportUDP, "-transport", TransportUDP)
addIf(c.Proxy.Mode != def.Proxy.Mode, "-mode", string(c.Proxy.Mode))
addIf(c.Proxy.Bond, "-bond")
args = append(args, kcpArgs(c.KCP.Profile, def.KCP.Profile)...)
addIf(c.Obf.Enabled(), "-obf-profile", string(c.Obf.Profile))
addIf(c.Obf.Enabled(), "-obf-key", hex.EncodeToString(c.Obf.Key))
addIf(c.Obf.Timing > 0, "-obf-timing", c.Obf.Timing.String())
addIf(c.VK.ManualCaptcha, "-manual-captcha")
addIf(c.VK.Platform != def.VK.Platform, "-platform", string(c.VK.Platform))
addIf(c.DNS.Mode != def.DNS.Mode, "-dns-mode", c.DNS.Mode)
addIf(len(c.DNS.Servers) > 0, "-dns-servers", strings.Join(c.DNS.Servers, ","))
addIf(c.ClientID != "", "-client-id", c.ClientID)
addIf(c.SubURL != "", "-sub", c.SubURL)
addIf(c.Log.Debug, "-debug")
return args
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"io"
"reflect"
"strings"
"testing"
)
// Главное свойство ClientArgs: строка, показанная пользователю, разбирается
// обратно в тот же конфиг. Иначе экран "какая команда работает" начинает врать.
func TestClientArgsRoundTrip(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"minimal", validClientArgs()},
{"udp transport", append(validClientArgs(), "-transport", "udp")},
{"tcp mode", append(validClientArgs(), "-mode", "tcp")},
{"tcp mode kcp", append(validClientArgs(), "-mode", "tcp", "-kcp-mtu", "900", "-kcp-interval", "40", "-kcp-acknodelay=false")},
{"obf", append(validClientArgs(), "-obf-profile", "rtpopus3", "-obf-key", testObfKey)},
{"obf timing", append(validClientArgs(), "-obf-profile", "rtpopus", "-obf-key", testObfKey, "-obf-timing", "20ms")},
{"dns", append(validClientArgs(), "-dns-mode", "doh", "-dns-servers", "1.1.1.1,8.8.8.8:53")},
{"turn override", append(validClientArgs(), "-turn", "5.5.5.5", "-port", "3478")},
{"tuning", append(validClientArgs(), "-n", "3", "-streams-per-cred", "4")},
{"vk extras", append(validClientArgs(), "-manual-captcha", "-platform", "mobile")},
{"multi links", []string{"-peer", "1.2.3.4:5000", "-links", "https://vk.ru/call/join/A,https://vk.ru/call/join/B"}},
{"misc", append(validClientArgs(), "-client-id", "deadbeef", "-listen", "127.0.0.1:1080", "-debug")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
want, err := ParseClient(tc.args, io.Discard)
if err != nil {
t.Fatalf("ParseClient(%v) error = %v", tc.args, err)
}
rebuilt := ClientArgs(want)
got, err := ParseClient(rebuilt, io.Discard)
if err != nil {
t.Fatalf("ParseClient(%v) error = %v", rebuilt, err)
}
if !reflect.DeepEqual(*got, *want) {
t.Errorf("round-trip changed config\nargs %v\n got %+v\nwant %+v", rebuilt, *got, *want)
}
})
}
}
// Дефолтные значения в строку не попадают: она должна читаться человеком.
func TestClientArgsOmitsDefaults(t *testing.T) {
c, err := ParseClient(validClientArgs(), io.Discard)
if err != nil {
t.Fatal(err)
}
got := strings.Join(ClientArgs(c), " ")
want := "-peer 1.2.3.4:5000 -links abcdef"
if got != want {
t.Errorf("ClientArgs() = %q, want %q", got, want)
}
}
func TestClientArgsNil(t *testing.T) {
if got := ClientArgs(nil); got != nil {
t.Errorf("ClientArgs(nil) = %v", got)
}
}
+54
View File
@@ -0,0 +1,54 @@
package config
import (
"errors"
"io"
"strings"
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
)
func TestBondConfigRoundTrip(t *testing.T) {
args := []string{"-provider", "direct", "-peer", "127.0.0.1:56000", "-mode", "tcp", "-bond", "-n", "3"}
c, err := ParseClient(args, io.Discard)
if err != nil {
t.Fatal(err)
}
round, err := ParseClient(ClientArgs(c), io.Discard)
if err != nil || !round.Proxy.Bond {
t.Fatalf("args round trip: %v", err)
}
link := (&uri.Config{Provider: "direct", Peer: "127.0.0.1:56000", Mode: "tcp", Bond: true, N: 3}).String()
parsed, err := uri.Parse(link)
if err != nil || !parsed.Bond {
t.Fatalf("URI round trip: %v", err)
}
c, err = ParseClient([]string{link}, io.Discard)
if err != nil || !c.Proxy.Bond {
t.Fatalf("URI to CLI: %v", err)
}
c, err = ParseClientJSON([]byte(`{"provider":"direct","peer":"127.0.0.1:56000","proxy":{"mode":"tcp","bond":true}}`), "")
if err != nil || !c.Proxy.Bond {
t.Fatalf("JSON: %v", err)
}
c, err = ParseClientJSON([]byte(`{}`), link)
if err != nil || !c.Proxy.Bond {
t.Fatalf("URI overlay: %v", err)
}
if strings.Contains(DefaultClientJSON(), `"bond"`) {
t.Fatal("default JSON changed")
}
}
func TestBondValidation(t *testing.T) {
for _, extra := range [][]string{{}, {"-mode", "tcp", "-n", "257"}} {
args := append([]string{"-provider", "direct", "-peer", "127.0.0.1:56000", "-bond"}, extra...)
if _, err := ParseClient(args, io.Discard); !errors.Is(err, ErrBondConfig) {
t.Fatalf("accepted %v: %v", args, err)
}
}
if _, err := ParseClientJSON([]byte(`{"provider":"direct","peer":"127.0.0.1:1","proxy":{"mode":"tcp","bonds":true}}`), ""); err == nil {
t.Fatal("unknown JSON field accepted")
}
}
+150
View File
@@ -0,0 +1,150 @@
package config
import (
"flag"
"fmt"
"io"
"strings"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
)
const uriScheme = "freeturn://"
// PeekSubURL извлекает значение флага -sub из сырых аргументов без полного парсинга.
func PeekSubURL(args []string) string {
for i := range args {
a := args[i]
if v, ok := strings.CutPrefix(a, "-sub="); ok {
return v
}
if v, ok := strings.CutPrefix(a, "--sub="); ok {
return v
}
if (a == "-sub" || a == "--sub") && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
// ParseClient парсит аргументы командной строки в Client.
func ParseClient(args []string, errOut io.Writer) (*Client, error) {
r := defaultRaw()
fs := flag.NewFlagSet("client", flag.ContinueOnError)
if errOut != nil {
fs.SetOutput(errOut)
}
fs.StringVar(&r.Turn, "turn", r.Turn, "IP TURN-сервера; override creds провайдера")
fs.StringVar(&r.Port, "port", r.Port, "порт TURN-сервера; override creds провайдера")
fs.StringVar(&r.Listen, "listen", r.Listen, "локальный ip:port для WireGuard/Xray клиента")
fs.StringVar(&r.Provider, "provider", r.Provider, "источник потоков: vk (TURN-реле VK Calls) | direct (напрямую на -peer)")
fs.StringVar(&r.Link, "link", r.Link, "(устарел) одна ссылка VK Calls, используйте -links")
fs.StringVar(&r.Links, "links", r.Links, "ссылки VK Calls через запятую: https://vk.ru/call/join/...,https://vk.ru/call/join/...")
fs.StringVar(&r.Peer, "peer", r.Peer, "адрес сервера на VPS, host:port; обязательно")
fs.IntVar(&r.N, "n", r.N, "число параллельных потоков; 0 - по провайдеру (vk 12, direct 1)")
fs.StringVar(&r.Transport, "transport", r.Transport, "транспорт до TURN-реле: tcp | udp")
fs.StringVar(&r.Mode, "mode", r.Mode, "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
fs.BoolVar(&r.Bond, "bond", r.Bond, "объединять сессии для одного TCP-соединения; только -mode tcp")
fs.StringVar(&r.ObfProfile, "obf-profile", r.ObfProfile, "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с сервером")
fs.StringVar(&r.ObfKey, "obf-key", r.ObfKey, "ключ для -obf-profile != none: 32 байта hex (64 символа)")
fs.BoolVar(&r.GenObfKey, "gen-obf-key", r.GenObfKey, "напечатать новый -obf-key и выйти")
fs.DurationVar(&r.ObfTiming, "obf-timing", r.ObfTiming, "межпакетная задержка для RTP-мимикрии (напр. 20ms); 0=выкл")
fs.IntVar(&r.StreamsPerCred, "streams-per-cred", r.StreamsPerCred, "TURN-потоков на один кеш VK-creds; только -provider vk")
fs.BoolVar(&r.Debug, "debug", r.Debug, "подробные debug-логи")
fs.BoolVar(&r.ManualCaptcha, "manual-captcha", r.ManualCaptcha, "ручная VK captcha в браузере вместо авто; только -provider vk")
fs.StringVar(&r.Platform, "platform", r.Platform, "класс устройства персоны VK-auth: desktop | mobile; только -provider vk")
fs.StringVar(&r.DNSMode, "dns-mode", r.DNSMode, "резолвер клиента: plain | doh | auto")
fs.StringVar(&r.DNSServers, "dns-servers", r.DNSServers, "свои UDP/53 DNS через запятую: ip[:port][,ip[:port]...]")
fs.StringVar(&r.ClientID, "client-id", r.ClientID, "уникальный ID клиента (автогенерация если не задан)")
fs.StringVar(&r.SubURL, "sub", r.SubURL, "URL подписки (sub.md) для получения списка серверов")
fs.BoolVar(&r.Routes, "routes", r.Routes, "автоматическое управление маршрутами к TURN-серверам; требует прав администратора")
kcp := registerKCPFlags(fs, r.KCP)
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() > 0 && strings.HasPrefix(fs.Arg(0), uriScheme) {
u, err := uri.Parse(fs.Arg(0))
if err != nil {
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
}
r.applyURI(u)
}
r.KCP = kcp.profile()
c, err := assemble(r)
if err != nil {
return nil, err
}
if c.Obf.GenKey {
return c, nil
}
if err := Validate(c); err != nil {
return nil, err
}
return c, nil
}
// ParseServer парсит аргументы командной строки в Server.
func ParseServer(args []string, errOut io.Writer) (*Server, error) {
def := ServerDefaults()
fs := flag.NewFlagSet("server", flag.ContinueOnError)
if errOut != nil {
fs.SetOutput(errOut)
}
listen := fs.String("listen", def.Proxy.Listen, "локальный адрес прослушивания ip:port")
connect := fs.String("connect", "", "локальный бэкенд host:port; обязательно: WG 127.0.0.1:51820 | Xray 127.0.0.1:443")
mode := fs.String("mode", string(def.Proxy.Mode), "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
obfProfile := fs.String("obf-profile", string(def.Obf.Profile), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с клиентом")
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 10ms); 0=выкл")
debug := fs.Bool("debug", false, "подробные debug-логи")
clientsFile := fs.String("clients-file", "", "путь к файлу clients.json для авторизации по Client ID")
kcp := registerKCPFlags(fs, def.KCP.Profile)
if err := fs.Parse(args); err != nil {
return nil, err
}
switch *mode {
case ModeUDP, ModeTCP:
default:
return nil, fmt.Errorf("invalid -mode value %q: must be %s | %s", *mode, ModeUDP, ModeTCP)
}
s := &Server{
Obf: ObfOpts{
Profile: ObfProfile(*obfProfile),
GenKey: *genObfKey,
Timing: *obfTiming,
},
Proxy: ProxyOpts{
Mode: ProxyMode(*mode),
Listen: *listen,
Connect: *connect,
},
Log: LogOpts{Debug: *debug},
KCP: KCPOpts{Profile: kcp.profile()},
ClientsFile: *clientsFile,
}
if s.Obf.GenKey {
return s, nil
}
if err := ValidateServer(s); err != nil {
return nil, err
}
key, err := rtpopus.DecodeKey(s.Obf.Enabled(), *obfKey)
if err != nil {
return nil, err
}
s.Obf.Key = key
return s, nil
}
+49 -431
View File
@@ -1,128 +1,95 @@
// Package config парсит CLI-флаги клиента и сервера.
//
// Функции Parse* без побочных эффектов: валидируют ввод и декодируют wrap-ключ,
// но не трогают сеть, DNS и состояние процесса. Подключение этих эффектов -
// ответственность main() после возврата Parse*.
//
// Опции сгруппированы по доменам (TURN, Obf, Proxy, VK, DNS, Log) - структура
// зеркалит концептуальные слои прокси.
// Package config описывает структуры конфигурации клиента и сервера.
package config
import (
"errors"
"flag"
"fmt"
"io"
"strings"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcptun"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
)
const (
dnsModePlain = "plain"
dnsModeDoH = "doh"
dnsModeAuto = "auto"
defaultStreamsPerCache = 10
)
// ProxyMode выбирает payload прикладного уровня, который идёт через TURN-туннель.
// На клиенте доступны все три; на сервере только UDP / TCPFwd
// (bond определяется автоматически per-stream по magic-префиксу).
type ProxyMode string
const (
ProxyModeUDP ProxyMode = "udp" // -mode udp (default): UDP-релей пакетов (WireGuard)
ProxyModeTCPFwd ProxyMode = "tcpfwd" // -mode tcp: TCP-форвардер через smux
ProxyModeTCPFwdBond ProxyMode = "tcpfwd-bond" // -mode tcp -bond: bond TCP по N smux-сессиям
)
// TURNOpts - опции TURN-сервера (куда и как подключаться).
type TURNOpts struct {
Host string // -turn: переопределить IP/host TURN-сервера
Port string // -port: переопределить порт TURN
TransportUDP bool // -transport udp: подключение к TURN по UDP (по умолчанию TCP/TLS)
N int // -n: число TURN-потоков (только клиент)
Host string
Port string
TransportUDP bool
N int
}
// ObfProfile выбирает wire-профиль обфускации TURN-payload.
// Профили живут в internal/wire/<profile>/ - сейчас только rtpopus,
// под добавление новых (rtph264, vp8 и т.д.).
// ObfProfile - wire-профиль обфускации TURN-payload (internal/wire/<profile>/).
type ObfProfile string
const (
ObfProfileNone ObfProfile = "none" // обфускация отключена
ObfProfileNone ObfProfile = "none"
ObfProfileRTPOpus ObfProfile = "rtpopus" // RTP/opus + ChaCha20-Poly1305 AEAD
ObfProfileRTPOpus2 ObfProfile = "rtpopus2" // rtpopus + RTP header extension (мимикрия под современный WebRTC)
ObfProfileRTPOpus2 ObfProfile = "rtpopus2" // rtpopus + RTP header extension (мимикрия под WebRTC)
ObfProfileRTPOpus3 ObfProfile = "rtpopus3" // rtpopus2 + abs-send-time + VAD + loss simulation + variable ts
)
// ObfOpts - опции обфускации TURN-payload.
type ObfOpts struct {
Profile ObfProfile // -obf-profile: none (default) | rtpopus | rtpopus2
Key []byte // -obf-key (декодированный): 32-байтовый общий ключ; nil если Profile=none
GenKey bool // -gen-obf-key: напечатать новый ключ и выйти
Timing time.Duration // -obf-timing: межпакетная задержка (RTP-мимикрия); 0=выкл
Profile ObfProfile
Key []byte
GenKey bool
Timing time.Duration
}
// Enabled возвращает true когда выбран реальный профиль обфускации.
func (o ObfOpts) Enabled() bool { return o.Profile != ObfProfileNone }
// ProxyOpts - опции прокси прикладного уровня.
// ProxyMode задаёт тип прикладного трафика в TURN-туннеле.
type ProxyMode string
const (
ProxyModeUDP ProxyMode = "udp" // UDP-релей датаграмм (WireGuard)
ProxyModeTCP ProxyMode = "tcp" // TCP-форвардер через KCP+smux (Xray/sing-box)
)
type ProxyOpts struct {
Mode ProxyMode // udp | tcpfwd | tcpfwd-bond (сервер: udp | tcpfwd)
Listen string // -listen: локальный bind (клиент: WG/TCP entry; сервер: TURN entry)
Connect string // -connect: backend (только сервер)
Peer string // -peer: адрес серверного прокси, куда дозванивается клиент (только клиент)
Bond bool
Mode ProxyMode
Listen string
Connect string
Peer string
}
// Browser выбирает браузерный профиль для control-plane запросов VK-провайдера.
type Browser string
type Platform string
const (
BrowserChrome Browser = "chrome"
BrowserFirefox Browser = "firefox"
BrowserSafari Browser = "safari"
PlatformDesktop Platform = "desktop"
PlatformMobile Platform = "mobile"
)
// VKOpts - опции VK-учёток и captcha (только клиент, провайдер "vk").
type VKOpts struct {
Links []string // -links (нормализованные join-коды); несколько = больше стримов
StreamsPerCred int // -streams-per-cred
ManualCaptcha bool // -manual-captcha
Browser Browser // -browser: chrome | firefox | safari
Links []string
StreamsPerCred int
ManualCaptcha bool
Platform Platform
}
// ProviderOpts выбирает реализацию provider.Provider.
type ProviderOpts struct {
Name string // -provider: vk (default)
Name string
}
// Известные имена провайдеров.
const (
ProviderVK = "vk"
ProviderVK = "vk"
ProviderDirect = "direct"
)
// DNSOpts - опции DNS-резолвинга (только клиент).
type DNSOpts struct {
Mode string // -dns-mode: plain | doh | auto
Servers []string // -dns-servers (через запятую); nil если флаг пуст
Mode string
Servers []string
}
// LogOpts - опции логирования.
type LogOpts struct {
Debug bool // -debug
Debug bool
}
// KCPOpts - параметры KCP-туннеля, хардкодятся из DefaultProfile/FEC{}.
type KCPOpts struct {
Profile kcptun.Profile
FEC kcptun.FEC
// TunnelOpts - параметры встроенного WireGuard/AmneziaWG userspace-туннеля.
type TunnelOpts struct {
Mode tunnel.Mode
Config string
MTU int
}
// Client - разобранные и провалидированные CLI-опции клиента.
func (t TunnelOpts) Enabled() bool { return t.Mode != "" && t.Mode != tunnel.ModeNone }
type Client struct {
TURN TURNOpts
Obf ObfOpts
@@ -132,365 +99,16 @@ type Client struct {
DNS DNSOpts
Log LogOpts
KCP KCPOpts
Tunnel TunnelOpts
ClientID string
SubURL string
Routes bool
}
// Server - разобранные и провалидированные CLI-опции сервера.
type Server struct {
Obf ObfOpts
Proxy ProxyOpts
Log LogOpts
KCP KCPOpts
ClientsFile string // -clients-file
}
// PeekSubURL вытаскивает значение -sub из сырых args без полного парсинга.
// Нужно до ParseClient: подписка отдаёт peer, без которого ParseClient падает
// на валидации. Вызывающий тянет подписку и подсовывает URI ноды позиционным
// аргументом - дальше применение идёт общим путём в ParseClient.
func PeekSubURL(args []string) string {
for i := range args {
a := args[i]
if v, ok := strings.CutPrefix(a, "-sub="); ok {
return v
}
if v, ok := strings.CutPrefix(a, "--sub="); ok {
return v
}
if (a == "-sub" || a == "--sub") && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
// ParseClient разбирает args (без имени программы) в Client.
// При flag.ErrHelp возвращает (nil, flag.ErrHelp) - вызывающий выходит штатно.
func ParseClient(args []string, errOut io.Writer) (*Client, error) {
fs := flag.NewFlagSet("client", flag.ContinueOnError)
if errOut != nil {
fs.SetOutput(errOut)
}
turn := fs.String("turn", "", "IP TURN-сервера; override creds провайдера")
port := fs.String("port", "", "порт TURN-сервера; override creds провайдера")
listen := fs.String("listen", "127.0.0.1:9000", "локальный ip:port для WireGuard/Xray клиента")
provider := fs.String("provider", ProviderVK, "источник TURN-creds: vk")
link := fs.String("link", "", "(устарел) одна ссылка VK Calls, используйте -links")
links := fs.String("links", "", "ссылки VK Calls через запятую: https://vk.ru/call/join/...,https://vk.ru/call/join/...")
peer := fs.String("peer", "", "адрес сервера на VPS, host:port; обязательно")
n := fs.Int("n", 10, "число параллельных TURN-потоков")
transport := fs.String("transport", "tcp", "транспорт до TURN-реле: tcp | udp")
mode := fs.String("mode", "udp", "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
bond := fs.Bool("bond", false, "страйпинг TCP по smux-сессиям; только с -mode tcp")
obfProfile := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с сервером")
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 20ms); 0=выкл")
streamsPerCred := fs.Int("streams-per-cred", defaultStreamsPerCache, "TURN-потоков на один кеш VK-creds; только -provider vk")
debug := fs.Bool("debug", false, "подробные debug-логи")
manualCaptcha := fs.Bool("manual-captcha", false, "ручная VK captcha в браузере вместо авто; только -provider vk")
browser := fs.String("browser", string(BrowserFirefox), "браузерный профиль VK-auth: chrome | firefox | safari; только -provider vk")
dnsMode := fs.String("dns-mode", dnsModeAuto, "резолвер клиента: plain | doh | auto")
dnsServers := fs.String("dns-servers", "", "свои UDP/53 DNS через запятую: ip[:port][,ip[:port]...]")
clientID := fs.String("client-id", "", "уникальный ID клиента (автогенерация если не задан)")
subURL := fs.String("sub", "", "URL подписки (sub.md) для получения списка серверов")
if err := fs.Parse(args); err != nil {
return nil, err
}
c := &Client{
TURN: TURNOpts{
Host: *turn,
Port: *port,
TransportUDP: *transport == "udp",
N: *n,
},
Obf: ObfOpts{
Profile: ObfProfile(*obfProfile),
GenKey: *genObfKey,
Timing: *obfTiming,
},
Proxy: ProxyOpts{
Mode: ClientProxyMode(*mode, *bond),
Listen: *listen,
Peer: *peer,
},
Provider: ProviderOpts{
Name: *provider,
},
VK: VKOpts{
StreamsPerCred: *streamsPerCred,
ManualCaptcha: *manualCaptcha,
Browser: Browser(*browser),
},
DNS: DNSOpts{
Mode: *dnsMode,
},
Log: LogOpts{
Debug: *debug,
},
KCP: KCPOpts{
Profile: kcptun.DefaultProfile(),
FEC: kcptun.FEC{},
},
ClientID: *clientID,
SubURL: *subURL,
}
// Обработка позиционного аргумента URI
if fs.NArg() > 0 {
arg := fs.Arg(0)
if strings.HasPrefix(arg, "freeturn://") {
ucfg, err := uri.Parse(arg)
if err != nil {
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
}
if ucfg.Provider != "" {
c.Provider.Name = ucfg.Provider
}
if ucfg.Transport != "" {
*transport = ucfg.Transport
}
if ucfg.Mode != "" {
*mode = ucfg.Mode
}
if ucfg.Bond {
*bond = true
}
if ucfg.N > 0 {
c.TURN.N = ucfg.N
}
if ucfg.StreamsPerCred > 0 {
c.VK.StreamsPerCred = ucfg.StreamsPerCred
}
if ucfg.ObfProfile != "" {
c.Obf.Profile = ObfProfile(ucfg.ObfProfile)
}
if ucfg.ObfKey != "" {
*obfKey = ucfg.ObfKey
}
if ucfg.Peer != "" {
c.Proxy.Peer = ucfg.Peer
}
if ucfg.ClientID != "" {
c.ClientID = ucfg.ClientID
}
if ucfg.Listen != "" {
c.Proxy.Listen = ucfg.Listen
}
if ucfg.DNSMode != "" {
c.DNS.Mode = ucfg.DNSMode
}
if ucfg.DNSServers != "" {
*dnsServers = ucfg.DNSServers
}
if ucfg.ManualCaptcha {
c.VK.ManualCaptcha = true
}
}
}
// Пересчитываем после возможного изменения из URI
c.Proxy.Mode = ClientProxyMode(*mode, *bond)
c.TURN.TransportUDP = *transport == "udp"
switch *transport {
case "tcp", "udp":
default:
return nil, fmt.Errorf("invalid -transport value %q: must be tcp | udp", *transport)
}
switch *mode {
case "udp", "tcp":
default:
return nil, fmt.Errorf("invalid -mode value %q: must be udp | tcp", *mode)
}
if *bond && *mode != "tcp" {
return nil, fmt.Errorf("-bond requires -mode tcp")
}
switch c.DNS.Mode {
case dnsModePlain, dnsModeDoH, dnsModeAuto:
default:
return nil, fmt.Errorf("invalid -dns-mode value %q: must be plain | doh | auto", c.DNS.Mode)
}
if *dnsServers != "" {
c.DNS.Servers = strings.Split(*dnsServers, ",")
}
if c.Obf.GenKey {
return c, nil
}
if c.Proxy.Peer == "" {
return nil, errors.New("need peer address")
}
switch c.Provider.Name {
case ProviderVK:
if *links == "" && *link == "" {
return nil, errors.New("need -links (или -link) (required for -provider vk)")
}
if c.VK.StreamsPerCred <= 0 {
return nil, fmt.Errorf("-streams-per-cred must be positive")
}
switch c.VK.Browser {
case BrowserChrome, BrowserFirefox, BrowserSafari:
default:
return nil, fmt.Errorf("invalid -browser value %q: must be %s | %s | %s", c.VK.Browser, BrowserChrome, BrowserFirefox, BrowserSafari)
}
rawLinks := strings.Split(*links, ",")
if len(rawLinks) == 1 && rawLinks[0] == "" {
// -links не задан, используем -link (backward compat)
rawLinks = []string{*link}
}
for _, raw := range rawLinks {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
parts := strings.Split(raw, "join/")
normalized := parts[len(parts)-1]
if idx := strings.IndexAny(normalized, "/?#"); idx != -1 {
normalized = normalized[:idx]
}
if normalized != "" {
c.VK.Links = append(c.VK.Links, normalized)
}
}
if len(c.VK.Links) == 0 {
return nil, errors.New("need at least one valid VK link")
}
default:
return nil, fmt.Errorf("invalid -provider value %q: must be %s", c.Provider.Name, ProviderVK)
}
if err := validateObfProfile(c.Obf.Profile); err != nil {
return nil, err
}
key, err := rtpopus.DecodeKey(c.Obf.Enabled(), *obfKey)
if err != nil {
return nil, err
}
c.Obf.Key = key
if err := validateObfTiming(c.Obf, c.Proxy.Mode); err != nil {
return nil, err
}
if c.TURN.N <= 0 {
c.TURN.N = 10
}
return c, nil
}
// ParseServer разбирает args (без имени программы) в Server.
func ParseServer(args []string, errOut io.Writer) (*Server, error) {
fs := flag.NewFlagSet("server", flag.ContinueOnError)
if errOut != nil {
fs.SetOutput(errOut)
}
listen := fs.String("listen", "0.0.0.0:56000", "локальный адрес прослушивания ip:port")
connect := fs.String("connect", "", "локальный бэкенд host:port; обязательно: WG 127.0.0.1:51820 | Xray 127.0.0.1:443")
mode := fs.String("mode", "udp", "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box; bond авто)")
obfProfile := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с клиентом")
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 10ms); 0=выкл")
debug := fs.Bool("debug", false, "подробные debug-логи")
clientsFile := fs.String("clients-file", "", "путь к файлу clients.json для авторизации по Client ID")
if err := fs.Parse(args); err != nil {
return nil, err
}
s := &Server{
Obf: ObfOpts{
Profile: ObfProfile(*obfProfile),
GenKey: *genObfKey,
Timing: *obfTiming,
},
Proxy: ProxyOpts{
Mode: serverProxyMode(*mode),
Listen: *listen,
Connect: *connect,
},
Log: LogOpts{
Debug: *debug,
},
KCP: KCPOpts{
Profile: kcptun.DefaultProfile(),
FEC: kcptun.FEC{},
},
ClientsFile: *clientsFile,
}
switch *mode {
case "udp", "tcp":
default:
return nil, fmt.Errorf("invalid -mode value %q: must be udp | tcp", *mode)
}
if s.Obf.GenKey {
return s, nil
}
if s.Proxy.Connect == "" {
return nil, fmt.Errorf("server address is required")
}
if err := validateObfProfile(s.Obf.Profile); err != nil {
return nil, err
}
key, err := rtpopus.DecodeKey(s.Obf.Enabled(), *obfKey)
if err != nil {
return nil, err
}
s.Obf.Key = key
if err := validateObfTiming(s.Obf, s.Proxy.Mode); err != nil {
return nil, err
}
return s, nil
}
// validateObfTiming ограничивает -obf-timing UDP-релеем с включённой обфускацией:
// без RTP-профиля паковать нечего, а в tcp-режиме pacing ломает RTT/конгешн KCP.
func validateObfTiming(o ObfOpts, mode ProxyMode) error {
if o.Timing <= 0 {
return nil
}
if !o.Enabled() {
return errors.New("-obf-timing requires -obf-profile != none")
}
if mode != ProxyModeUDP {
return errors.New("-obf-timing supported only with -mode udp")
}
return nil
}
// validateObfProfile проверяет что -obf-profile содержит известное значение.
func validateObfProfile(p ObfProfile) error {
switch p {
case ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3:
return nil
default:
return fmt.Errorf("invalid -obf-profile value %q: must be %s | %s | %s | %s", p, ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3)
}
}
func ClientProxyMode(mode string, bond bool) ProxyMode {
switch {
case mode == "tcp" && bond:
return ProxyModeTCPFwdBond
case mode == "tcp":
return ProxyModeTCPFwd
default:
return ProxyModeUDP
}
}
func serverProxyMode(mode string) ProxyMode {
if mode == "tcp" {
return ProxyModeTCPFwd
}
return ProxyModeUDP
ClientsFile string
}
+124 -70
View File
@@ -30,13 +30,13 @@ func TestParseClient_Defaults(t *testing.T) {
if c.Proxy.Listen != "127.0.0.1:9000" {
t.Errorf("Proxy.Listen default: %q", c.Proxy.Listen)
}
if c.TURN.N != 10 {
if c.TURN.N != DefaultStreams {
t.Errorf("TURN.N default: %d", c.TURN.N)
}
if c.DNS.Mode != "auto" {
t.Errorf("DNS.Mode default: %q", c.DNS.Mode)
}
if c.VK.StreamsPerCred != defaultStreamsPerCache {
if c.VK.StreamsPerCred != DefaultStreamsPerCred {
t.Errorf("VK.StreamsPerCred default: %d", c.VK.StreamsPerCred)
}
if len(c.VK.Links) != 1 || c.VK.Links[0] != "abcdef" {
@@ -51,9 +51,6 @@ func TestParseClient_Defaults(t *testing.T) {
if c.Obf.Enabled() {
t.Errorf("Obf.Enabled() should be false when profile=none")
}
if c.Proxy.Mode != ProxyModeUDP {
t.Errorf("Proxy.Mode default: %q (expected udp)", c.Proxy.Mode)
}
}
func TestParseClient_VKLinkStrip(t *testing.T) {
@@ -115,30 +112,32 @@ func TestParseClient_InvalidDNS(t *testing.T) {
}
}
func TestParseClient_BrowserSafari(t *testing.T) {
args := append(validClientArgs(), "-browser", "safari")
func TestParseClient_PlatformDefaultDesktop(t *testing.T) {
c, err := ParseClient(validClientArgs(), io.Discard)
if err != nil {
t.Fatal(err)
}
if c.VK.Platform != PlatformDesktop {
t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformDesktop)
}
}
func TestParseClient_PlatformMobile(t *testing.T) {
args := append(validClientArgs(), "-platform", "mobile")
c, err := ParseClient(args, io.Discard)
if err != nil {
t.Fatal(err)
}
if c.VK.Browser != BrowserSafari {
t.Errorf("VK.Browser = %q, want %q", c.VK.Browser, BrowserSafari)
if c.VK.Platform != PlatformMobile {
t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformMobile)
}
}
func TestParseClient_BrowserInvalid(t *testing.T) {
args := append(validClientArgs(), "-browser", "opera")
func TestParseClient_PlatformInvalid(t *testing.T) {
args := append(validClientArgs(), "-platform", "tablet")
_, err := ParseClient(args, io.Discard)
if err == nil || !strings.Contains(err.Error(), "invalid -browser") {
t.Errorf("expected browser error, got %v", err)
}
}
func TestParseClient_BondWithoutTCPMode(t *testing.T) {
args := append(validClientArgs(), "-bond")
_, err := ParseClient(args, io.Discard)
if err == nil || !strings.Contains(err.Error(), "-bond requires -mode tcp") {
t.Errorf("expected bond error, got %v", err)
if err == nil || !strings.Contains(err.Error(), "invalid -platform") {
t.Errorf("expected platform error, got %v", err)
}
}
@@ -183,14 +182,6 @@ func TestParseClient_ObfTimingRequiresObf(t *testing.T) {
}
}
func TestParseClient_ObfTimingRejectsTCP(t *testing.T) {
args := append(validClientArgs(), "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
_, err := ParseClient(args, io.Discard)
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
t.Errorf("expected obf-timing/mode error, got %v", err)
}
}
func TestParseClient_ObfTimingValid(t *testing.T) {
args := append(validClientArgs(), "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
c, err := ParseClient(args, io.Discard)
@@ -202,14 +193,6 @@ func TestParseClient_ObfTimingValid(t *testing.T) {
}
}
func TestParseServer_ObfTimingRejectsTCP(t *testing.T) {
args := []string{"-connect", "127.0.0.1:51820", "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "10ms"}
_, err := ParseServer(args, io.Discard)
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
t.Errorf("expected obf-timing/mode error, got %v", err)
}
}
func TestParseClient_StreamsPerCredNonPositive(t *testing.T) {
args := append(validClientArgs(), "-streams-per-cred", "0")
_, err := ParseClient(args, io.Discard)
@@ -218,14 +201,14 @@ func TestParseClient_StreamsPerCredNonPositive(t *testing.T) {
}
}
func TestParseClient_NClampedToTen(t *testing.T) {
func TestParseClient_NClampedToDefault(t *testing.T) {
args := append(validClientArgs(), "-n", "-5")
c, err := ParseClient(args, io.Discard)
if err != nil {
t.Fatal(err)
}
if c.TURN.N != 10 {
t.Errorf("TURN.N: %d (expected 10)", c.TURN.N)
if c.TURN.N != DefaultStreams {
t.Errorf("TURN.N: %d (expected %d)", c.TURN.N, DefaultStreams)
}
}
@@ -266,31 +249,6 @@ func TestParseClient_HelpReturnsErrHelp(t *testing.T) {
}
}
func TestParseClient_ProxyMode(t *testing.T) {
cases := []struct {
name string
args []string
want ProxyMode
}{
{"default-udp", nil, ProxyModeUDP},
{"tcp", []string{"-mode", "tcp"}, ProxyModeTCPFwd},
{"tcp-bond", []string{"-mode", "tcp", "-bond"}, ProxyModeTCPFwdBond},
{"default-udp-no-bond", nil, ProxyModeUDP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := append(validClientArgs(), tc.args...)
c, err := ParseClient(args, io.Discard)
if err != nil {
t.Fatal(err)
}
if c.Proxy.Mode != tc.want {
t.Errorf("Proxy.Mode = %q, want %q", c.Proxy.Mode, tc.want)
}
})
}
}
func TestParseServer_Defaults(t *testing.T) {
s, err := ParseServer([]string{"-connect", "backend:1234"}, io.Discard)
if err != nil {
@@ -302,9 +260,6 @@ func TestParseServer_Defaults(t *testing.T) {
if s.Proxy.Connect != "backend:1234" {
t.Errorf("Proxy.Connect: %q", s.Proxy.Connect)
}
if s.Proxy.Mode != ProxyModeUDP {
t.Errorf("Proxy.Mode default: %q", s.Proxy.Mode)
}
}
func TestParseServer_MissingConnect(t *testing.T) {
@@ -363,7 +318,106 @@ func TestParseServer_ProxyMode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if s.Proxy.Mode != ProxyModeTCPFwd {
t.Errorf("Proxy.Mode = %q, want tcpfwd", s.Proxy.Mode)
if s.Proxy.Mode != ProxyModeTCP {
t.Errorf("Proxy.Mode = %q, want %q", s.Proxy.Mode, ProxyModeTCP)
}
}
func TestParseServer_InvalidMode(t *testing.T) {
_, err := ParseServer([]string{"-connect", "x:1", "-mode", "quic"}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "invalid -mode") {
t.Errorf("expected mode error, got %v", err)
}
}
func TestParseServer_KCPFlagsRequireTCPMode(t *testing.T) {
_, err := ParseServer([]string{"-connect", "x:1", "-kcp-mtu", "900"}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "-kcp-*") {
t.Errorf("expected kcp/mode error, got %v", err)
}
}
func TestParseServer_KCPProfileApplied(t *testing.T) {
s, err := ParseServer([]string{"-connect", "x:1", "-mode", "tcp", "-kcp-mtu", "900", "-kcp-interval", "40"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if s.KCP.Profile.MTU != 900 || s.KCP.Profile.Interval != 40 {
t.Errorf("KCP profile = %+v", s.KCP.Profile)
}
}
func TestParseServer_KCPMTUOutOfRange(t *testing.T) {
_, err := ParseServer([]string{"-connect", "x:1", "-mode", "tcp", "-kcp-mtu", "2000"}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "-kcp-mtu") {
t.Errorf("expected kcp-mtu error, got %v", err)
}
}
func TestParseClient_ProxyMode(t *testing.T) {
cases := []struct {
name string
args []string
want ProxyMode
}{
{"default-udp", nil, ProxyModeUDP},
{"tcp", []string{"-mode", "tcp"}, ProxyModeTCP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := ParseClient(append(validClientArgs(), tc.args...), io.Discard)
if err != nil {
t.Fatal(err)
}
if c.Proxy.Mode != tc.want {
t.Errorf("Proxy.Mode = %q, want %q", c.Proxy.Mode, tc.want)
}
})
}
}
func TestParseClient_InvalidMode(t *testing.T) {
_, err := ParseClient(append(validClientArgs(), "-mode", "quic"), io.Discard)
if err == nil || !strings.Contains(err.Error(), "invalid -mode") {
t.Errorf("expected mode error, got %v", err)
}
}
func TestParseClient_ObfTimingAllowedInTCP(t *testing.T) {
args := append(validClientArgs(), "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
c, err := ParseClient(args, io.Discard)
if err != nil {
t.Fatalf("ParseClient() error = %v", err)
}
if c.Obf.Timing != 20*time.Millisecond {
t.Errorf("Obf.Timing = %v, want 20ms", c.Obf.Timing)
}
}
func TestParseClient_ObfTimingRequiresProfile(t *testing.T) {
_, err := ParseClient(append(validClientArgs(), "-obf-timing", "20ms"), io.Discard)
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
t.Errorf("expected obf-timing/profile error, got %v", err)
}
}
func TestParseClient_KCPFlagsRequireTCPMode(t *testing.T) {
_, err := ParseClient(append(validClientArgs(), "-kcp-sndwnd", "128"), io.Discard)
if err == nil || !strings.Contains(err.Error(), "-kcp-*") {
t.Errorf("expected kcp/mode error, got %v", err)
}
}
func TestParseClient_ProviderDirect(t *testing.T) {
c, err := ParseClient([]string{"-peer", "1.2.3.4:5000", "-provider", "direct"}, io.Discard)
if err != nil {
t.Fatalf("direct without links: %v", err)
}
if c.TURN.N != DefaultDirectStreams {
t.Errorf("TURN.N = %d, want %d", c.TURN.N, DefaultDirectStreams)
}
c, err = ParseClient([]string{"-peer", "1.2.3.4:5000", "-provider", "direct", "-n", "5"}, io.Discard)
if err != nil || c.TURN.N != 5 {
t.Fatalf("explicit -n: %v, n=%d", err, c.TURN.N)
}
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
)
const (
TransportTCP = "tcp"
TransportUDP = "udp"
ModeUDP = "udp"
ModeTCP = "tcp"
)
const (
DNSModePlain = dnsdial.DNSModePlain
DNSModeDoH = dnsdial.DNSModeDoH
DNSModeAuto = dnsdial.DNSModeAuto
)
const (
DefaultListen = "127.0.0.1:9000"
DefaultStreams = 12
DefaultDirectStreams = 1
DefaultStreamsPerCred = 12
DefaultTransport = TransportTCP
DefaultMode = ModeUDP
DefaultDNSMode = DNSModeAuto
DefaultProvider = ProviderVK
DefaultPlatform = PlatformDesktop
DefaultObfProfile = ObfProfileNone
DefaultServerListen = "0.0.0.0:56000"
)
func defaultRaw() raw {
return raw{
Listen: DefaultListen,
Provider: DefaultProvider,
StreamsPerCred: DefaultStreamsPerCred,
Transport: DefaultTransport,
Mode: DefaultMode,
ObfProfile: string(DefaultObfProfile),
Platform: string(DefaultPlatform),
DNSMode: DefaultDNSMode,
Routes: false,
TunnelMode: string(tunnel.ModeNone),
TunnelMTU: tunnel.DefaultMTU,
KCP: kcpmux.DefaultProfile(),
}
}
// Defaults возвращает конфигурацию клиента с дефолтными значениями (без peer/links).
func Defaults() Client {
c, err := assemble(defaultRaw())
if err != nil {
panic("config: defaults must assemble: " + err.Error())
}
return *c
}
// ServerDefaults возвращает конфигурацию сервера с дефолтными значениями.
func ServerDefaults() Server {
return Server{
Obf: ObfOpts{Profile: DefaultObfProfile},
Proxy: ProxyOpts{Mode: ProxyModeUDP, Listen: DefaultServerListen},
KCP: KCPOpts{Profile: kcpmux.DefaultProfile()},
}
}
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"io"
"reflect"
"testing"
)
func TestDefaultsAssemble(t *testing.T) {
d := Defaults()
if d.Proxy.Listen != DefaultListen {
t.Errorf("Listen = %q, want %q", d.Proxy.Listen, DefaultListen)
}
if d.TURN.N != DefaultStreams || d.VK.StreamsPerCred != DefaultStreamsPerCred {
t.Errorf("N = %d, StreamsPerCred = %d", d.TURN.N, d.VK.StreamsPerCred)
}
if d.TURN.TransportUDP {
t.Error("TransportUDP must be false for default -transport tcp")
}
if d.DNS.Mode != DefaultDNSMode || d.VK.Platform != DefaultPlatform {
t.Errorf("DNS.Mode = %q, Platform = %q", d.DNS.Mode, d.VK.Platform)
}
if d.Obf.Enabled() || d.Obf.Key != nil {
t.Errorf("Obf must be off by default: %+v", d.Obf)
}
}
// Проверка совпадения дефолтов флагов и структуры Defaults().
func TestFlagDefaultsMatchDefaults(t *testing.T) {
parsed, err := ParseClient(validClientArgs(), io.Discard)
if err != nil {
t.Fatal(err)
}
want := Defaults()
want.Proxy.Peer = parsed.Proxy.Peer
want.VK.Links = parsed.VK.Links
if !reflect.DeepEqual(*parsed, want) {
t.Errorf("ParseClient defaults differ from Defaults():\n got %+v\nwant %+v", *parsed, want)
}
}
func TestServerDefaults(t *testing.T) {
d := ServerDefaults()
if d.Proxy.Listen != DefaultServerListen {
t.Errorf("Listen = %q, want %q", d.Proxy.Listen, DefaultServerListen)
}
if d.Obf.Enabled() {
t.Errorf("unexpected server defaults: %+v", d)
}
}
func TestParseServerDefaultsMatchServerDefaults(t *testing.T) {
parsed, err := ParseServer([]string{"-connect", "127.0.0.1:51820"}, io.Discard)
if err != nil {
t.Fatal(err)
}
want := ServerDefaults()
want.Proxy.Connect = parsed.Proxy.Connect
if !reflect.DeepEqual(*parsed, want) {
t.Errorf("ParseServer defaults differ from ServerDefaults():\n got %+v\nwant %+v", *parsed, want)
}
}
+194
View File
@@ -0,0 +1,194 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
)
// ClientJSON - JSON-схема конфигурации клиента (gomobile).
type ClientJSON struct {
Peer string `json:"peer"`
ClientID string `json:"clientId"`
SubURL string `json:"subUrl"`
Provider string `json:"provider"`
Routes bool `json:"routes"`
TURN turnJSON `json:"turn"`
Proxy proxyJSON `json:"proxy"`
VK vkJSON `json:"vk"`
Obf obfJSON `json:"obf"`
DNS dnsJSON `json:"dns"`
Log logJSON `json:"log"`
KCP kcpJSON `json:"kcp"`
Tunnel tunnelJSON `json:"tunnel"`
}
// tunnelJSON - параметры встроенного wg-quick туннеля.
type tunnelJSON struct {
Mode string `json:"mode"`
Config string `json:"config"`
MTU int `json:"mtu"`
}
type turnJSON struct {
N int `json:"n"`
Transport string `json:"transport"`
Host string `json:"host"`
Port string `json:"port"`
}
type proxyJSON struct {
Bond bool `json:"bond,omitempty"`
Mode string `json:"mode"`
Listen string `json:"listen"`
}
type vkJSON struct {
Links []string `json:"links"`
StreamsPerCred int `json:"streamsPerCred"`
ManualCaptcha bool `json:"manualCaptcha"`
Platform string `json:"platform"`
}
type obfJSON struct {
Profile string `json:"profile"`
Key string `json:"key"`
TimingMs int `json:"timingMs"`
}
type dnsJSON struct {
Mode string `json:"mode"`
Servers []string `json:"servers"`
}
type logJSON struct {
Debug bool `json:"debug"`
}
// DefaultClientJSON возвращает дефолтную конфигурацию клиента в формате JSON.
func DefaultClientJSON() string {
b, err := json.MarshalIndent(defaultClientJSON(), "", " ")
if err != nil {
panic("config: default JSON must marshal: " + err.Error())
}
return string(b)
}
// PeekSubURLJSON извлекает subUrl из сырого JSON без полной валидации.
func PeekSubURLJSON(data []byte) string {
var peek struct {
SubURL string `json:"subUrl"`
}
if json.Unmarshal(data, &peek) != nil {
return ""
}
return peek.SubURL
}
// ParseClientJSON парсит JSON-конфигурацию клиента с опциональным overlayURI подписки.
func ParseClientJSON(data []byte, overlayURI string) (*Client, error) {
dto := defaultClientJSON()
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&dto); err != nil {
return nil, fmt.Errorf("config json: %w", err)
}
r := dto.toRaw()
if overlayURI != "" {
u, err := uri.Parse(overlayURI)
if err != nil {
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
}
r.applyURI(u)
}
c, err := assemble(r)
if err != nil {
return nil, err
}
if err := Validate(c); err != nil {
return nil, err
}
return c, nil
}
func defaultClientJSON() ClientJSON {
r := defaultRaw()
return ClientJSON{
Peer: r.Peer,
ClientID: r.ClientID,
SubURL: r.SubURL,
Provider: r.Provider,
Routes: r.Routes,
TURN: turnJSON{
N: r.N,
Transport: r.Transport,
Host: r.Turn,
Port: r.Port,
},
Proxy: proxyJSON{Mode: r.Mode, Listen: r.Listen},
KCP: kcpJSONFrom(r.KCP),
VK: vkJSON{
StreamsPerCred: r.StreamsPerCred,
ManualCaptcha: r.ManualCaptcha,
Platform: r.Platform,
},
Obf: obfJSON{
Profile: r.ObfProfile,
Key: r.ObfKey,
TimingMs: int(r.ObfTiming / time.Millisecond),
},
DNS: dnsJSON{Mode: r.DNSMode},
Log: logJSON{Debug: r.Debug},
Tunnel: tunnelJSON{
Mode: r.TunnelMode,
Config: r.TunnelConfig,
MTU: r.TunnelMTU,
},
}
}
func (j ClientJSON) toRaw() raw {
return raw{
Turn: j.TURN.Host,
Port: j.TURN.Port,
Listen: j.Proxy.Listen,
Peer: j.Peer,
Provider: j.Provider,
Links: strings.Join(j.VK.Links, ","),
N: j.TURN.N,
StreamsPerCred: j.VK.StreamsPerCred,
Transport: j.TURN.Transport,
Mode: j.Proxy.Mode,
Bond: j.Proxy.Bond,
ObfProfile: j.Obf.Profile,
ObfKey: j.Obf.Key,
ObfTiming: time.Duration(j.Obf.TimingMs) * time.Millisecond,
ManualCaptcha: j.VK.ManualCaptcha,
Platform: j.VK.Platform,
DNSMode: j.DNS.Mode,
DNSServers: strings.Join(j.DNS.Servers, ","),
Debug: j.Log.Debug,
ClientID: j.ClientID,
SubURL: j.SubURL,
Routes: j.Routes,
TunnelMode: j.Tunnel.Mode,
TunnelConfig: j.Tunnel.Config,
TunnelMTU: j.Tunnel.MTU,
KCP: j.KCP.profile(),
}
}
+183
View File
@@ -0,0 +1,183 @@
package config
import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
)
const minimalJSON = `{"peer":"1.2.3.4:5000","vk":{"links":["https://vk.ru/call/join/CODE"]}}`
func TestParseClientJSONFillsDefaults(t *testing.T) {
c, err := ParseClientJSON([]byte(minimalJSON), "")
if err != nil {
t.Fatalf("ParseClientJSON() error = %v", err)
}
want := Defaults()
want.Proxy.Peer = "1.2.3.4:5000"
want.VK.Links = []string{"CODE"}
if !reflect.DeepEqual(*c, want) {
t.Errorf("parsed config differs from defaults:\n got %+v\nwant %+v", *c, want)
}
}
func TestParseClientJSONAppliesFields(t *testing.T) {
in := `{
"peer": "5.6.7.8:56000",
"clientId": "abc",
"turn": {"n": 3, "transport": "udp"},
"proxy": {"listen": "127.0.0.1:1080"},
"vk": {"links": ["A", "B"], "streamsPerCred": 4, "manualCaptcha": true, "platform": "mobile"},
"obf": {"profile": "rtpopus3", "key": "` + testObfKey + `"},
"dns": {"mode": "doh", "servers": ["1.1.1.1", "8.8.8.8"]},
"log": {"debug": true}
}`
c, err := ParseClientJSON([]byte(in), "")
if err != nil {
t.Fatalf("ParseClientJSON() error = %v", err)
}
if c.TURN.N != 3 || !c.TURN.TransportUDP {
t.Errorf("TURN = %+v", c.TURN)
}
if c.Proxy.Listen != "127.0.0.1:1080" {
t.Errorf("Proxy = %+v", c.Proxy)
}
if len(c.VK.Links) != 2 || c.VK.Platform != PlatformMobile || !c.VK.ManualCaptcha {
t.Errorf("VK = %+v", c.VK)
}
if c.Obf.Profile != ObfProfileRTPOpus3 || len(c.Obf.Key) != 32 {
t.Errorf("Obf = %+v", c.Obf)
}
if c.DNS.Mode != DNSModeDoH || len(c.DNS.Servers) != 2 {
t.Errorf("DNS = %+v", c.DNS)
}
if !c.Log.Debug || c.ClientID != "abc" {
t.Errorf("Log/ClientID = %+v / %q", c.Log, c.ClientID)
}
}
func TestParseClientJSONRunsValidate(t *testing.T) {
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},` +
`"obf":{"profile":"none","timingMs":20}}`
_, err := ParseClientJSON([]byte(in), "")
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
t.Fatalf("ParseClientJSON() error = %v, want obf-timing rule", err)
}
}
func TestParseClientJSONRejectsUnknownField(t *testing.T) {
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},"treads":12}`
_, err := ParseClientJSON([]byte(in), "")
if err == nil || !strings.Contains(err.Error(), "treads") {
t.Fatalf("ParseClientJSON() error = %v, want unknown field error", err)
}
}
func TestParseClientJSONRejectsMalformed(t *testing.T) {
if _, err := ParseClientJSON([]byte("{"), ""); err == nil {
t.Fatal("ParseClientJSON() error = nil for malformed input")
}
}
func TestParseClientJSONOverlayURI(t *testing.T) {
node := &uri.Config{Version: 1, Provider: ProviderVK, Peer: "9.9.9.9:56000", N: 5}
c, err := ParseClientJSON([]byte(minimalJSON), node.String())
if err != nil {
t.Fatalf("ParseClientJSON() error = %v", err)
}
if c.Proxy.Peer != "9.9.9.9:56000" || c.TURN.N != 5 {
t.Errorf("overlay not applied: peer %q, n %d", c.Proxy.Peer, c.TURN.N)
}
if len(c.VK.Links) != 1 || c.VK.Links[0] != "CODE" {
t.Errorf("links overwritten by overlay: %v", c.VK.Links)
}
}
func TestParseClientJSONRejectsBadOverlay(t *testing.T) {
if _, err := ParseClientJSON([]byte(minimalJSON), "freeturn://not-base64!!"); err == nil {
t.Fatal("ParseClientJSON() error = nil for broken URI")
}
}
func TestPeekSubURLJSON(t *testing.T) {
if got := PeekSubURLJSON([]byte(`{"subUrl":"https://example.org/sub"}`)); got != "https://example.org/sub" {
t.Errorf("PeekSubURLJSON() = %q", got)
}
if got := PeekSubURLJSON([]byte(minimalJSON)); got != "" {
t.Errorf("PeekSubURLJSON() = %q, want empty", got)
}
if got := PeekSubURLJSON([]byte("{")); got != "" {
t.Errorf("PeekSubURLJSON() on malformed = %q, want empty", got)
}
}
func TestDefaultClientJSONRoundTrip(t *testing.T) {
var dto ClientJSON
if err := json.Unmarshal([]byte(DefaultClientJSON()), &dto); err != nil {
t.Fatalf("DefaultClientJSON() is not valid JSON: %v", err)
}
if !reflect.DeepEqual(dto, defaultClientJSON()) {
t.Errorf("round-trip changed defaults:\n got %+v\nwant %+v", dto, defaultClientJSON())
}
dto.Peer = "1.2.3.4:5000"
dto.VK.Links = []string{"CODE"}
b, err := json.Marshal(dto)
if err != nil {
t.Fatal(err)
}
c, err := ParseClientJSON(b, "")
if err != nil {
t.Fatalf("ParseClientJSON(DefaultClientJSON + required) error = %v", err)
}
if c.Obf.Timing != 0 || c.TURN.N != DefaultStreams {
t.Errorf("unexpected config from defaults: %+v", c)
}
}
func TestClientJSONTimingMsMapsToDuration(t *testing.T) {
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},` +
`"obf":{"profile":"rtpopus","key":"` + testObfKey + `","timingMs":25}}`
c, err := ParseClientJSON([]byte(in), "")
if err != nil {
t.Fatal(err)
}
if c.Obf.Timing != 25*time.Millisecond {
t.Errorf("Obf.Timing = %v, want 25ms", c.Obf.Timing)
}
}
// Старый JSON без секций mode/kcp обязан читаться: поля optional, дефолт - udp.
func TestParseClientJSONLegacyWithoutMode(t *testing.T) {
data := `{"peer":"1.2.3.4:5000","vk":{"links":["abcdef"]}}`
c, err := ParseClientJSON([]byte(data), "")
if err != nil {
t.Fatalf("ParseClientJSON() error = %v", err)
}
if c.Proxy.Mode != ProxyModeUDP {
t.Errorf("Proxy.Mode = %q, want udp", c.Proxy.Mode)
}
if c.KCP.Profile != kcpmux.DefaultProfile() {
t.Errorf("KCP profile = %+v, want default", c.KCP.Profile)
}
}
func TestParseClientJSONTCPMode(t *testing.T) {
data := `{"peer":"1.2.3.4:5000","vk":{"links":["abcdef"]},"proxy":{"mode":"tcp"}}`
c, err := ParseClientJSON([]byte(data), "")
if err != nil {
t.Fatalf("ParseClientJSON() error = %v", err)
}
if c.Proxy.Mode != ProxyModeTCP {
t.Errorf("Proxy.Mode = %q, want tcp", c.Proxy.Mode)
}
}
+141
View File
@@ -0,0 +1,141 @@
package config
import (
"flag"
"fmt"
"strconv"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
)
// KCPOpts - параметры ARQ-слоя tcp-режима; в udp-режиме не используются.
type KCPOpts struct {
Profile kcpmux.Profile
}
// kcpFlags держит указатели на -kcp-*; флаги регистрируются одинаково у клиента и
// сервера, потому что окна и MTU настраиваются на каждой стороне отдельно.
type kcpFlags struct {
noDelay *int
interval *int
resend *int
nc *int
sndWnd *int
rcvWnd *int
mtu *int
ackNoDelay *bool
}
func registerKCPFlags(fs *flag.FlagSet, def kcpmux.Profile) *kcpFlags {
return &kcpFlags{
noDelay: fs.Int("kcp-nodelay", def.NoDelay, "KCP nodelay: 0 (обычный) | 1 (быстрый)"),
interval: fs.Int("kcp-interval", def.Interval, "интервал внутреннего цикла KCP, мс; больше = меньше служебного трафика"),
resend: fs.Int("kcp-resend", def.Resend, "быстрая повторная отправка после N дубликатов ACK; 0=выкл"),
nc: fs.Int("kcp-nc", def.NC, "KCP congestion control: 0 (вкл) | 1 (выкл)"),
sndWnd: fs.Int("kcp-sndwnd", def.SndWnd, "окно отправки KCP в пакетах"),
rcvWnd: fs.Int("kcp-rcvwnd", def.RcvWnd, "окно приёма KCP в пакетах"),
mtu: fs.Int("kcp-mtu", def.MTU, "MTU сегмента KCP в байтах"),
ackNoDelay: fs.Bool("kcp-acknodelay", def.ACKNoDelay, "отправлять ACK сразу; ниже задержка, выше служебный трафик. Все -kcp-* работают только с -mode tcp"),
}
}
func (k *kcpFlags) profile() kcpmux.Profile {
return kcpmux.Profile{
NoDelay: *k.noDelay,
Interval: *k.interval,
Resend: *k.resend,
NC: *k.nc,
SndWnd: *k.sndWnd,
RcvWnd: *k.rcvWnd,
MTU: *k.mtu,
ACKNoDelay: *k.ackNoDelay,
}
}
const (
minKCPMTU = 300
// Сегмент KCP едет внутри DTLS-записи (~37 B), obf-обёртки (до 40 B), TURN
// ChannelData/Send (до 36 B) и IP/UDP (28 B): выше 1350 пакет режется по пути.
maxKCPMTU = 1350
)
func validateKCP(p kcpmux.Profile) error {
if p.NoDelay != 0 && p.NoDelay != 1 {
return fmt.Errorf("invalid -kcp-nodelay value %d: must be 0 | 1", p.NoDelay)
}
if p.NC != 0 && p.NC != 1 {
return fmt.Errorf("invalid -kcp-nc value %d: must be 0 | 1", p.NC)
}
if p.Interval <= 0 {
return fmt.Errorf("invalid -kcp-interval value %d: must be positive", p.Interval)
}
if p.Resend < 0 {
return fmt.Errorf("invalid -kcp-resend value %d: must be non-negative", p.Resend)
}
if p.SndWnd <= 0 || p.RcvWnd <= 0 {
return fmt.Errorf("invalid KCP window %d/%d: -kcp-sndwnd and -kcp-rcvwnd must be positive", p.SndWnd, p.RcvWnd)
}
if p.MTU < minKCPMTU || p.MTU > maxKCPMTU {
return fmt.Errorf("invalid -kcp-mtu value %d: must be %d..%d", p.MTU, minKCPMTU, maxKCPMTU)
}
return nil
}
// kcpJSON - секция "kcp" клиентского JSON; в udp-режиме игнорируется.
type kcpJSON struct {
NoDelay int `json:"noDelay"`
Interval int `json:"interval"`
Resend int `json:"resend"`
NC int `json:"nc"`
SndWnd int `json:"sndWnd"`
RcvWnd int `json:"rcvWnd"`
MTU int `json:"mtu"`
ACKNoDelay bool `json:"ackNoDelay"`
}
func kcpJSONFrom(p kcpmux.Profile) kcpJSON {
return kcpJSON{
NoDelay: p.NoDelay,
Interval: p.Interval,
Resend: p.Resend,
NC: p.NC,
SndWnd: p.SndWnd,
RcvWnd: p.RcvWnd,
MTU: p.MTU,
ACKNoDelay: p.ACKNoDelay,
}
}
func (k kcpJSON) profile() kcpmux.Profile {
return kcpmux.Profile{
NoDelay: k.NoDelay,
Interval: k.Interval,
Resend: k.Resend,
NC: k.NC,
SndWnd: k.SndWnd,
RcvWnd: k.RcvWnd,
MTU: k.MTU,
ACKNoDelay: k.ACKNoDelay,
}
}
// kcpArgs выводит только отличия от дефолта: -kcp-* валидны лишь в tcp-режиме.
func kcpArgs(p, def kcpmux.Profile) []string {
var args []string
addInt := func(flag string, v, d int) {
if v != d {
args = append(args, flag, strconv.Itoa(v))
}
}
addInt("-kcp-nodelay", p.NoDelay, def.NoDelay)
addInt("-kcp-interval", p.Interval, def.Interval)
addInt("-kcp-resend", p.Resend, def.Resend)
addInt("-kcp-nc", p.NC, def.NC)
addInt("-kcp-sndwnd", p.SndWnd, def.SndWnd)
addInt("-kcp-rcvwnd", p.RcvWnd, def.RcvWnd)
addInt("-kcp-mtu", p.MTU, def.MTU)
if p.ACKNoDelay != def.ACKNoDelay {
args = append(args, "-kcp-acknodelay="+strconv.FormatBool(p.ACKNoDelay))
}
return args
}
+216
View File
@@ -0,0 +1,216 @@
package config
import (
"fmt"
"strings"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
)
// raw хранит неразобранные строковые опции до этапа assemble.
type raw struct {
Turn string
Port string
Listen string
Peer string
Provider string
Link string
Links string
N int
StreamsPerCred int
Transport string
Mode string
Bond bool
ObfProfile string
ObfKey string
ObfTiming time.Duration
GenObfKey bool
ManualCaptcha bool
Platform string
DNSMode string
DNSServers string
Debug bool
ClientID string
SubURL string
Routes bool
TunnelMode string
TunnelConfig string
TunnelMTU int
KCP kcpmux.Profile
}
// applyURI применяет параметры freeturn:// поверх raw-опций.
func (r *raw) applyURI(u *uri.Config) {
override(&r.Provider, u.Provider)
override(&r.Transport, u.Transport)
override(&r.Mode, u.Mode)
r.Bond = r.Bond || u.Bond
if u.KCP != nil {
r.KCP = kcpmux.Profile{
NoDelay: u.KCP.NoDelay,
Interval: u.KCP.Interval,
Resend: u.KCP.Resend,
NC: u.KCP.NC,
SndWnd: u.KCP.SndWnd,
RcvWnd: u.KCP.RcvWnd,
MTU: u.KCP.MTU,
ACKNoDelay: u.KCP.ACKNoDelay,
}
}
if u.N > 0 {
r.N = u.N
}
if u.StreamsPerCred > 0 {
r.StreamsPerCred = u.StreamsPerCred
}
override(&r.ObfProfile, u.ObfProfile)
override(&r.ObfKey, u.ObfKey)
if u.ObfTimingMs > 0 && u.ObfProfile != "" && u.ObfProfile != string(ObfProfileNone) {
r.ObfTiming = time.Duration(u.ObfTimingMs) * time.Millisecond
}
if u.VKLink != "" && r.Links == "" && r.Link == "" {
r.Links = u.VKLink
}
override(&r.Peer, u.Peer)
override(&r.ClientID, u.ClientID)
override(&r.Listen, u.Listen)
override(&r.DNSMode, u.DNSMode)
override(&r.DNSServers, u.DNSServers)
r.ManualCaptcha = r.ManualCaptcha || u.ManualCaptcha
if u.WGConf != "" {
r.TunnelMode = "awg"
r.TunnelConfig = u.WGConf
}
}
func override[T comparable](dst *T, v T) {
var zero T
if v != zero {
*dst = v
}
}
func assemble(r raw) (*Client, error) {
switch r.Transport {
case TransportTCP, TransportUDP:
default:
return nil, fmt.Errorf("invalid -transport value %q: must be %s | %s", r.Transport, TransportTCP, TransportUDP)
}
switch r.Mode {
case ModeUDP, ModeTCP:
default:
return nil, fmt.Errorf("invalid -mode value %q: must be %s | %s", r.Mode, ModeUDP, ModeTCP)
}
n := r.N
if n <= 0 {
n = DefaultStreams
if r.Provider == ProviderDirect {
n = DefaultDirectStreams
}
}
platform := Platform(r.Platform)
if platform == "" {
platform = DefaultPlatform
}
tunnelMTU := r.TunnelMTU
if tunnelMTU <= 0 {
tunnelMTU = tunnel.DefaultMTU
}
c := &Client{
TURN: TURNOpts{
Host: r.Turn,
Port: r.Port,
TransportUDP: r.Transport == TransportUDP,
N: n,
},
Obf: ObfOpts{
Profile: ObfProfile(r.ObfProfile),
GenKey: r.GenObfKey,
Timing: r.ObfTiming,
},
Proxy: ProxyOpts{
Bond: r.Bond,
Mode: ProxyMode(r.Mode),
Listen: r.Listen,
Peer: r.Peer,
},
Provider: ProviderOpts{Name: r.Provider},
VK: VKOpts{
Links: normalizeVKLinks(r.Links, r.Link),
StreamsPerCred: r.StreamsPerCred,
ManualCaptcha: r.ManualCaptcha,
Platform: platform,
},
DNS: DNSOpts{Mode: r.DNSMode},
Log: LogOpts{Debug: r.Debug},
KCP: KCPOpts{Profile: r.KCP},
Tunnel: TunnelOpts{
Mode: tunnel.Mode(r.TunnelMode),
Config: r.TunnelConfig,
MTU: tunnelMTU,
},
ClientID: r.ClientID,
SubURL: r.SubURL,
Routes: r.Routes,
}
if r.DNSServers != "" {
c.DNS.Servers = strings.Split(r.DNSServers, ",")
}
if c.Obf.GenKey {
return c, nil
}
if err := validateObfProfile(c.Obf.Profile); err != nil {
return nil, err
}
key, err := rtpopus.DecodeKey(c.Obf.Enabled(), r.ObfKey)
if err != nil {
return nil, err
}
c.Obf.Key = key
return c, nil
}
// normalizeVKLinks извлекает join-код звонка из URL или строки кода.
func normalizeVKLinks(links, link string) []string {
items := strings.Split(links, ",")
if len(items) == 1 && items[0] == "" {
items = []string{link}
}
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
parts := strings.Split(item, "join/")
code := parts[len(parts)-1]
if idx := strings.IndexAny(code, "/?#"); idx != -1 {
code = code[:idx]
}
if code != "" {
out = append(out, code)
}
}
if len(out) == 0 {
return nil
}
return out
}
+134
View File
@@ -0,0 +1,134 @@
package config
import (
"reflect"
"strings"
"testing"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/uri"
)
func TestNormalizeVKLinks(t *testing.T) {
cases := []struct {
name string
links string
link string
want []string
}{
{"empty", "", "", nil},
{"legacy link only", "", "https://vk.ru/call/join/CODE", []string{"CODE"}},
{"links win over legacy", "https://vk.ru/call/join/NEW", "https://vk.ru/call/join/OLD", []string{"NEW"}},
{"query stripped", "", "https://vk.ru/call/join/CODE?utm=1", []string{"CODE"}},
{"path stripped", "", "https://vk.ru/call/join/CODE/extra", []string{"CODE"}},
{"fragment stripped", "", "https://vk.ru/call/join/CODE#x", []string{"CODE"}},
{"bare code", "", "CODE", []string{"CODE"}},
{"spaces and blanks", " A , ,B ", "", []string{"A", "B"}},
{"only separators", ",,", "", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeVKLinks(tc.links, tc.link); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("normalizeVKLinks(%q, %q) = %v, want %v", tc.links, tc.link, got, tc.want)
}
})
}
}
func TestAssembleRejectsRawEnums(t *testing.T) {
cases := []struct {
name string
mutate func(*raw)
want string
}{
{"transport", func(r *raw) { r.Transport = "sctp" }, "invalid -transport"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := defaultRaw()
tc.mutate(&r)
_, err := assemble(r)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("assemble() error = %v, want mention of %q", err, tc.want)
}
})
}
}
func TestAssembleGenKeySkipsKeyDecode(t *testing.T) {
r := defaultRaw()
r.GenObfKey = true
r.ObfProfile = "bogus"
r.ObfKey = "not-hex"
c, err := assemble(r)
if err != nil {
t.Fatalf("assemble() error = %v, want nil for -gen-obf-key", err)
}
if c.Obf.Key != nil {
t.Errorf("Obf.Key = %v, want nil", c.Obf.Key)
}
}
func TestApplyURIOverridesOnlyPresentFields(t *testing.T) {
r := defaultRaw()
r.Peer = "flag-peer:1"
r.ClientID = "flag-id"
r.Links = "https://vk.ru/call/join/FROMFLAG"
r.applyURI(&uri.Config{
Peer: "uri-peer:2",
Transport: TransportUDP,
N: 7,
})
if r.Peer != "uri-peer:2" {
t.Errorf("Peer = %q, want URI value", r.Peer)
}
if r.Transport != TransportUDP || r.N != 7 {
t.Errorf("URI fields not applied: %+v", r)
}
if r.ClientID != "flag-id" || r.Links != "https://vk.ru/call/join/FROMFLAG" {
t.Errorf("URI wiped fields it does not carry: %+v", r)
}
}
func TestApplyURIVKLinkIsFallback(t *testing.T) {
r := defaultRaw()
r.applyURI(&uri.Config{VKLink: "https://vk.ru/call/join/OWNER"})
if r.Links != "https://vk.ru/call/join/OWNER" {
t.Errorf("Links = %q, want owner link", r.Links)
}
r = defaultRaw()
r.Links = "https://vk.ru/call/join/MINE"
r.applyURI(&uri.Config{VKLink: "https://vk.ru/call/join/OWNER"})
if r.Links != "https://vk.ru/call/join/MINE" {
t.Errorf("Links = %q, own link must win", r.Links)
}
}
func TestApplyURITimingNeedsObf(t *testing.T) {
r := defaultRaw()
r.applyURI(&uri.Config{ObfTimingMs: 20})
if r.ObfTiming != 0 {
t.Errorf("ObfTiming = %s without obf profile, want 0", r.ObfTiming)
}
r.applyURI(&uri.Config{ObfProfile: "rtpopus", ObfKey: "00", ObfTimingMs: 20})
if r.ObfTiming != 20*time.Millisecond {
t.Errorf("ObfTiming = %s, want 20ms", r.ObfTiming)
}
}
func TestApplyURIIgnoresZeroValues(t *testing.T) {
r := defaultRaw()
r.N = 5
r.StreamsPerCred = 3
r.applyURI(&uri.Config{})
if r.N != 5 || r.StreamsPerCred != 3 {
t.Errorf("empty URI must not reset fields: %+v", r)
}
}
+170
View File
@@ -0,0 +1,170 @@
package config
import (
"errors"
"fmt"
"strings"
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bond"
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
)
// Validate проверяет валидность конфигурации Client.
func Validate(c *Client) error {
if c == nil {
return errors.New("nil client config")
}
if c.Proxy.Peer == "" {
return errors.New("need peer address")
}
if err := validateProvider(c); err != nil {
return err
}
if err := validateDNSMode(c.DNS.Mode); err != nil {
return err
}
if err := validateObfProfile(c.Obf.Profile); err != nil {
return err
}
if err := validateProxyMode(c.Proxy.Mode); err != nil {
return err
}
if err := validateBond(c); err != nil {
return err
}
if err := validateTunnel(c.Tunnel, c.Proxy.Mode); err != nil {
return err
}
if err := validateKCPFor(c.KCP, c.Proxy.Mode); err != nil {
return err
}
return validateObfTiming(c.Obf)
}
var ErrBondConfig = errors.New("invalid bond configuration")
func validateBond(c *Client) error {
if !c.Proxy.Bond {
return nil
}
groups := 1
if c.Provider.Name == ProviderVK {
groups = max(len(c.VK.Links), 1)
}
if c.Proxy.Mode != ProxyModeTCP || c.TURN.N > bond.MaxLanes/groups {
return fmt.Errorf("%w: -bond requires -mode tcp and at most %d sessions in total", ErrBondConfig, bond.MaxLanes)
}
return nil
}
func validateProvider(c *Client) error {
switch c.Provider.Name {
case ProviderDirect:
return nil
case ProviderVK:
default:
return fmt.Errorf("invalid -provider value %q: must be %s | %s", c.Provider.Name, ProviderVK, ProviderDirect)
}
if len(c.VK.Links) == 0 {
return errors.New("vk: need at least one link (-links / -link)")
}
if c.VK.StreamsPerCred <= 0 {
return errors.New("-streams-per-cred must be positive")
}
switch c.VK.Platform {
case PlatformDesktop, PlatformMobile:
return nil
default:
return fmt.Errorf("invalid -platform value %q: must be %s | %s", c.VK.Platform, PlatformDesktop, PlatformMobile)
}
}
func validateDNSMode(m string) error {
switch m {
case DNSModePlain, DNSModeDoH, DNSModeAuto:
return nil
default:
return fmt.Errorf("invalid -dns-mode value %q: must be %s | %s | %s", m, DNSModePlain, DNSModeDoH, DNSModeAuto)
}
}
func validateProxyMode(m ProxyMode) error {
switch m {
case ProxyModeUDP, ProxyModeTCP:
return nil
default:
return fmt.Errorf("invalid -mode value %q: must be %s | %s", m, ProxyModeUDP, ProxyModeTCP)
}
}
// validateKCPFor: -kcp-* настраивают ARQ, который живёт только в tcp-режиме.
func validateKCPFor(k KCPOpts, mode ProxyMode) error {
if mode != ProxyModeTCP {
if k.Profile != kcpmux.DefaultProfile() {
return errors.New("-kcp-* supported only with -mode tcp")
}
return nil
}
return validateKCP(k.Profile)
}
// validateTunnel: встроенный WG гонит датаграммы, tcp-режим их не переносит.
func validateTunnel(t TunnelOpts, mode ProxyMode) error {
if t.Mode != "" && !t.Mode.Valid() {
return fmt.Errorf("invalid tunnel mode %q: must be %s | %s | %s",
t.Mode, tunnel.ModeNone, tunnel.ModeWG, tunnel.ModeAWG)
}
if !t.Enabled() {
return nil
}
if mode != ProxyModeUDP {
return errors.New("tunnel requires -mode udp")
}
if strings.TrimSpace(t.Config) == "" {
return errors.New("tunnel config is required")
}
if t.MTU <= 0 {
return errors.New("tunnel MTU must be positive")
}
return nil
}
func ValidateServer(s *Server) error {
if s == nil {
return errors.New("nil server config")
}
if s.Proxy.Connect == "" {
return errors.New("server address is required")
}
if err := validateObfProfile(s.Obf.Profile); err != nil {
return err
}
if err := validateProxyMode(s.Proxy.Mode); err != nil {
return err
}
if err := validateKCPFor(s.KCP, s.Proxy.Mode); err != nil {
return err
}
return validateObfTiming(s.Obf)
}
func validateObfTiming(o ObfOpts) error {
if o.Timing <= 0 {
return nil
}
if !o.Enabled() {
return errors.New("-obf-timing requires -obf-profile != none")
}
return nil
}
func validateObfProfile(p ObfProfile) error {
switch p {
case ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3:
return nil
default:
return fmt.Errorf("invalid -obf-profile value %q: must be %s | %s | %s | %s", p, ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3)
}
}
+93
View File
@@ -0,0 +1,93 @@
package config
import (
"strings"
"testing"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
)
func validClient() *Client {
c := Defaults()
c.Proxy.Peer = "1.2.3.4:5000"
c.VK.Links = []string{"abcdef"}
return &c
}
func TestValidateAcceptsDefaultsPlusRequired(t *testing.T) {
if err := Validate(validClient()); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidateRejects(t *testing.T) {
cases := []struct {
name string
mutate func(*Client)
want string
}{
{"nil peer", func(c *Client) { c.Proxy.Peer = "" }, "peer"},
{"no links", func(c *Client) { c.VK.Links = nil }, "link"},
{"streams per cred", func(c *Client) { c.VK.StreamsPerCred = 0 }, "streams-per-cred"},
{"platform", func(c *Client) { c.VK.Platform = "watch" }, "invalid -platform"},
{"provider", func(c *Client) { c.Provider.Name = "bogus" }, "invalid -provider"},
{"dns mode", func(c *Client) { c.DNS.Mode = "bogus" }, "invalid -dns-mode"},
{"obf profile", func(c *Client) { c.Obf.Profile = "bogus" }, "invalid -obf-profile"},
{"timing without obf", func(c *Client) { c.Obf.Timing = time.Second }, "-obf-timing"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := validClient()
tc.mutate(c)
err := Validate(c)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Validate() error = %v, want mention of %q", err, tc.want)
}
})
}
}
func TestValidateRejectsNil(t *testing.T) {
if err := Validate(nil); err == nil {
t.Fatal("Validate(nil) error = nil")
}
if err := ValidateServer(nil); err == nil {
t.Fatal("ValidateServer(nil) error = nil")
}
}
func TestValidateServer(t *testing.T) {
s := ServerDefaults()
if err := ValidateServer(&s); err == nil {
t.Fatal("ValidateServer() must require -connect")
}
s.Proxy.Connect = "127.0.0.1:51820"
if err := ValidateServer(&s); err != nil {
t.Fatalf("ValidateServer() error = %v", err)
}
s.Obf.Profile = "bogus"
if err := ValidateServer(&s); err == nil {
t.Fatal("ValidateServer() must reject unknown obf profile")
}
}
// Встроенный WG гонит датаграммы - в tcp-режиме такой конфиг молча не работал бы.
func TestValidateTunnelRejectsTCPMode(t *testing.T) {
c := validClient()
c.Proxy.Mode = ProxyModeTCP
c.Tunnel = TunnelOpts{Mode: tunnel.ModeWG, Config: "[Interface]", MTU: 1280}
if err := Validate(c); err == nil || !strings.Contains(err.Error(), "tunnel requires") {
t.Errorf("expected tunnel/mode error, got %v", err)
}
}
// Маршруты к TURN от режима туннеля не зависят.
func TestValidateRoutesAllowedInTCPMode(t *testing.T) {
c := validClient()
c.Proxy.Mode = ProxyModeTCP
c.Routes = true
if err := Validate(c); err != nil {
t.Errorf("routes must be allowed in tcp mode: %v", err)
}
}
+25 -12
View File
@@ -1,20 +1,18 @@
// Package logx - минимальный уровневый логгер поверх stdlib log.
// Вызывающий получает Logger и зовёт Debugf/Infof/Warnf/Errorf;
// Debugf управляется debug-флагом, остальные уровни всегда пишут.
// Package logx предоставляет уровневый логгер поверх stdlib log.
package logx
import "log"
import (
"log"
"sync/atomic"
)
// Logger - интерфейс уровневого логирования.
// Debugf гейтится debug-флагом конструктора; остальные уровни печатают всегда.
type Logger interface {
Debugf(format string, v ...any)
Infof(format string, v ...any)
Warnf(format string, v ...any)
Errorf(format string, v ...any)
// DebugEnabled сообщает, будет ли Debugf писать вывод. Hot-path
// (счётчики статистики, условные ветки) используют это, чтобы не
// делать работу, результат которой логгер всё равно отбросит.
// DebugEnabled сообщает, включен ли уровень отладки.
DebugEnabled() bool
}
@@ -22,12 +20,10 @@ type stdLogger struct {
debug bool
}
// New возвращает Logger поверх stdlib log. При debug=false Debugf - no-op.
func New(debug bool) Logger {
return &stdLogger{debug: debug}
}
// Nop возвращает Logger, отбрасывающий весь вывод. Полезно в тестах.
func Nop() Logger { return nopLogger{} }
func (l *stdLogger) Debugf(format string, v ...any) {
@@ -40,8 +36,7 @@ func (*stdLogger) Warnf(format string, v ...any) { log.Printf("[WARN] "+format,
func (*stdLogger) Errorf(format string, v ...any) { log.Printf("[ERROR] "+format, v...) }
func (l *stdLogger) DebugEnabled() bool { return l.debug }
// OrNop возвращает l, если он не nil, иначе Nop. Используется в конструкторах
// пакетов, принимающих nullable Logger.
// OrNop возвращает l или Nop, если l == nil.
func OrNop(l Logger) Logger {
if l == nil {
return Nop()
@@ -56,3 +51,21 @@ func (nopLogger) Infof(string, ...any) {}
func (nopLogger) Warnf(string, ...any) {}
func (nopLogger) Errorf(string, ...any) {}
func (nopLogger) DebugEnabled() bool { return false }
// Holder хранит пакетный логгер, подменяемый на лету: ядро перезапускается в том же
// процессе (mobile), поэтому Set гонится с пишущими горутинами.
type Holder struct {
ptr atomic.Pointer[Logger]
}
func (h *Holder) Set(l Logger) {
v := OrNop(l)
h.ptr.Store(&v)
}
func (h *Holder) Get() Logger {
if p := h.ptr.Load(); p != nil {
return *p
}
return Nop()
}
+23
View File
@@ -0,0 +1,23 @@
package logx
import "strings"
type prefixedLogger struct {
Logger
prefix string
}
// WithPrefix привязывает источник к логгеру, не к изменяемому глобальному состоянию.
func WithPrefix(logger Logger, prefix string) Logger {
return &prefixedLogger{Logger: OrNop(logger), prefix: strings.ReplaceAll(prefix, "%", "%%")}
}
func (l *prefixedLogger) Debugf(format string, v ...any) {
if l.DebugEnabled() {
l.Logger.Debugf(l.prefix+format, v...)
}
}
func (l *prefixedLogger) Infof(format string, v ...any) { l.Logger.Infof(l.prefix+format, v...) }
func (l *prefixedLogger) Warnf(format string, v ...any) { l.Logger.Warnf(l.prefix+format, v...) }
func (l *prefixedLogger) Errorf(format string, v ...any) { l.Logger.Errorf(l.prefix+format, v...) }
+37
View File
@@ -0,0 +1,37 @@
package logx
import (
"fmt"
"testing"
)
type captureLogger struct {
nopLogger
lines []string
}
func (l *captureLogger) Infof(format string, args ...any) {
l.lines = append(l.lines, fmt.Sprintf(format, args...))
}
func TestPrefixRemainsBoundToLogger(t *testing.T) {
sink := &captureLogger{}
old := WithPrefix(sink, "[old 100%] ")
next := WithPrefix(sink, "[next] ")
next.Infof("ready %d", 2)
old.Infof("closed %d", 1)
if sink.lines[0] != "[next] ready 2" || sink.lines[1] != "[old 100%] closed 1" {
t.Fatalf("messages = %q", sink.lines)
}
}
func TestPrefixNilLogger(t *testing.T) {
l := WithPrefix(nil, "test ")
l.Debugf("%d", 1)
l.Infof("%d", 1)
l.Warnf("%d", 1)
l.Errorf("%d", 1)
if l.DebugEnabled() {
t.Fatal("nil logger enabled debug")
}
}
+8 -26
View File
@@ -8,27 +8,16 @@ import (
"github.com/samosvalishe/free-turn-proxy/internal/randx"
)
// MultiSplitWriteConn оборачивает TCP net.Conn и разбивает первый Write на
// несколько сегментов так, чтобы SNI из TLS ClientHello попадал на границу.
// DPI без TCP-реассемблинга не собирает hostname и не инжектит SNI-based RST.
//
// В отличие от SplitFirstWriteConn (один рез на фиксированном offset - рвёт
// только TLS record header, SNI остаётся целым) парсит ClientHello и режет
// внутри host_name. Если первый Write не ClientHello или SNI не найден -
// фоллбэк на одиночный рез FallbackSplitAt (поведение SplitFirstWriteConn).
// MultiSplitWriteConn разбивает первый Write ClientHello на несколько TCP-сегментов, разрезая SNI.
// DPI без TCP-реассемблинга не может извлечь hostname для фильтрации.
type MultiSplitWriteConn struct {
net.Conn
// Delay - базовая пауза между сегментами.
Delay time.Duration
// Jitter - верхняя граница случайной добавки к Delay (антифингерпринт тайминга).
Jitter time.Duration
// FallbackSplitAt - offset одиночного реза, когда SNI не найден. 0 - без реза.
Delay time.Duration
Jitter time.Duration
FallbackSplitAt int
done atomic.Bool
}
// Write на первом вызове дробит ClientHello по границам внутри host_name (SNI),
// далее проксирует напрямую.
func (s *MultiSplitWriteConn) Write(b []byte) (int, error) {
if !s.done.CompareAndSwap(false, true) {
return s.Conn.Write(b)
@@ -61,8 +50,7 @@ func (s *MultiSplitWriteConn) Write(b []byte) (int, error) {
return total, nil
}
// splitOffsets возвращает отсортированные точки реза первого сегмента. Сначала
// пробует SNI (рез внутри host_name), иначе одиночный FallbackSplitAt.
// splitOffsets возвращает смещения для разбиения первого сегмента (по SNI или FallbackSplitAt).
func (s *MultiSplitWriteConn) splitOffsets(b []byte) []int {
if start, end, ok := clientHelloSNIRange(b); ok {
if offs := sniSplitOffsets(start, end); len(offs) > 0 {
@@ -75,10 +63,7 @@ func (s *MultiSplitWriteConn) splitOffsets(b []byte) []int {
return nil
}
// sniSplitOffsets делит host_name [start,end) на ~трети с джиттером. Гарантирует:
// registrable-домен (vk.ru и т.п.) пересекает хотя бы одну границу независимо от
// раскладки лейблов. Короткий hostname (len 2) - один рез посередине; len<2 -
// без реза (nil, уходит на фоллбэк).
// sniSplitOffsets вычисляет точки разреза внутри диапазона SNI host_name.
func sniSplitOffsets(start, end int) []int {
l := end - start
switch {
@@ -97,9 +82,7 @@ func sniSplitOffsets(start, end int) []int {
}
}
// clientHelloSNIRange парсит TLS ClientHello (b начинается с TLS record header)
// и возвращает байтовый диапазон [start,end) первого host_name в SNI-расширении.
// ok=false если b не ClientHello-record либо SNI/host_name отсутствует.
// clientHelloSNIRange парсит TLS ClientHello и возвращает диапазон байт [start,end) первого SNI host_name.
func clientHelloSNIRange(b []byte) (int, int, bool) {
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
@@ -149,8 +132,7 @@ func clientHelloSNIRange(b []byte) (int, int, bool) {
return 0, 0, false
}
// parseSNIExtension ищет первый host_name (name_type 0) в server_name_list
// внутри [p,end). Возвращает абсолютный диапазон host_name в b.
// parseSNIExtension извлекает границы первого host_name из server_name_list.
func parseSNIExtension(b []byte, p, end int) (int, int, bool) {
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
if p+2 > end {
+10 -16
View File
@@ -5,27 +5,21 @@ import (
"testing"
)
// u8 - узкое преобразование длины в байт для тестового билдера ClientHello.
// Длины здесь малы и ограничены, переполнение невозможно.
//
//nolint:gosec // G115: тестовые длины малы и ограничены
func u8(v int) byte { return byte(v) }
func u8(v int) byte { return byte(v & 0xFF) }
// buildClientHello собирает минимальный валидный TLS ClientHello-record с
// заданным SNI host_name. Достаточно для проверки парсера и сегментации.
func buildClientHello(host string) []byte {
hn := []byte(host)
entry := append([]byte{0x00, u8(len(hn) >> 8), u8(len(hn))}, hn...) // name_type=host_name + len + host
sni := append([]byte{u8(len(entry) >> 8), u8(len(entry))}, entry...) // server_name_list len + entry
ext := append([]byte{0x00, 0x00, u8(len(sni) >> 8), u8(len(sni))}, sni...) // ext type 0 + len + body
entry := append([]byte{0x00, u8(len(hn) >> 8), u8(len(hn))}, hn...)
sni := append([]byte{u8(len(entry) >> 8), u8(len(entry))}, entry...)
ext := append([]byte{0x00, 0x00, u8(len(sni) >> 8), u8(len(sni))}, sni...)
body := make([]byte, 0, 64)
body = append(body, 0x03, 0x03) // client_version
body = append(body, make([]byte, 32)...) // random
body = append(body, 0x00) // session_id len 0
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites len 2 + TLS_AES_128_GCM_SHA256
body = append(body, 0x01, 0x00) // compression methods len 1 + null
body = append(body, u8(len(ext)>>8), u8(len(ext))) // extensions length
body = append(body, 0x03, 0x03)
body = append(body, make([]byte, 32)...)
body = append(body, 0x00)
body = append(body, 0x00, 0x02, 0x13, 0x01)
body = append(body, 0x01, 0x00)
body = append(body, u8(len(ext)>>8), u8(len(ext)))
body = append(body, ext...)
hs := append([]byte{0x01, u8(len(body) >> 16), u8(len(body) >> 8), u8(len(body))}, body...)
+4 -18
View File
@@ -1,7 +1,4 @@
// Package netconn - мелкие net.Conn / transport.Net адаптеры для клиента
// и сервера: passthrough transport.Net (для pion turn), ConnectedUDPConn
// (WriteTo поверх dialed UDPConn) и SplitFirstWriteConn (обход DPI через
// разбиение первого сегмента).
// Package netconn содержит сетевые адаптеры для net.Conn и transport.Net.
package netconn
import (
@@ -15,10 +12,8 @@ import (
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
)
// DirectNet реализует transport.Net через стандартный net.
type DirectNet struct{}
// New возвращает transport.Net поверх стандартного net.
func New() transport.Net {
return DirectNet{}
}
@@ -35,10 +30,7 @@ type directTCPListener struct {
*net.TCPListener
}
// All socket-creating methods route through a net.Dialer / net.ListenConfig
// carrying netctl.Apply as Control so the host can protect each fd from the
// VPN tunnel. (net.DialUDP / net.ListenUDP take no Control, so we go via
// Dialer/ListenConfig and type-assert back to the concrete UDP/TCP conn.)
// Все сокеты создаются с Control: netctl.Apply для защиты от попадания в VPN-туннель.
func (DirectNet) ListenPacket(network string, address string) (net.PacketConn, error) {
return (&net.ListenConfig{Control: netctl.Apply}).ListenPacket(context.Background(), network, address)
}
@@ -169,21 +161,16 @@ func (l directTCPListener) AcceptTCP() (transport.TCPConn, error) {
return l.TCPListener.AcceptTCP()
}
// ConnectedUDPConn адаптирует dialed (connected) *net.UDPConn к семантике
// net.PacketConn: WriteTo игнорирует адрес, т.к. ядро уже знает его из connect().
// ConnectedUDPConn адаптирует dialed *net.UDPConn к net.PacketConn (WriteTo транслируется в Write).
type ConnectedUDPConn struct {
*net.UDPConn
}
// WriteTo игнорирует addr (UDP уже connected) и пишет p.
func (c *ConnectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) {
return c.Write(p)
}
// SplitFirstWriteConn оборачивает TCP net.Conn и разбивает самый первый Write
// на два сегмента (SplitAt байт + остаток) с опциональной паузой Delay между ними.
// Ломает DPI-правила, матчащие фиксированный offset в первом сегменте без
// TCP-реассемблинга (например STUN magic cookie на offset 4-7).
// SplitFirstWriteConn разбивает первый Write на два TCP-сегмента для обхода DPI без реассемблинга.
type SplitFirstWriteConn struct {
net.Conn
SplitAt int
@@ -191,7 +178,6 @@ type SplitFirstWriteConn struct {
done atomic.Bool
}
// Write делает one-shot разбиение при первом вызове, далее проксирует напрямую.
func (s *SplitFirstWriteConn) Write(b []byte) (int, error) {
if s.done.CompareAndSwap(false, true) && len(b) > s.SplitAt {
n1, err := s.Conn.Write(b[:s.SplitAt])
-1
View File
@@ -8,7 +8,6 @@ import (
"time"
)
// pipeRecorder is a net.Conn that records the slice handed to each Write.
type pipeRecorder struct {
net.Conn
mu sync.Mutex
+16 -15
View File
@@ -8,20 +8,21 @@ import (
"time"
)
// BiCopy запускает io.Copy в обе стороны между c1 и c2; возвращает
// (bytesC1FromC2, bytesC2FromC1). Как только одна копия завершилась
// (EOF/ошибка/cancel) - обоим концам выставляется deadline=time.Now(),
// чтобы разблокировать парную горутину. Ошибки уходят в errf, если он не nil.
// BiCopy закрывает оба conn, как только копирование в любую сторону завершится.
func BiCopy(ctx context.Context, c1, c2 net.Conn, errf func(format string, v ...any)) (int64, int64) {
ctx2, cancel := context.WithCancel(ctx)
context.AfterFunc(ctx2, func() {
now := time.Now()
if err := c1.SetDeadline(now); err != nil && errf != nil {
errf("BiCopy: c1 SetDeadline: %v", err)
setDeadline := func(t time.Time, what string) {
if err := c1.SetDeadline(t); err != nil && errf != nil {
errf("BiCopy: c1 %s: %v", what, err)
}
if err := c2.SetDeadline(now); err != nil && errf != nil {
errf("BiCopy: c2 SetDeadline: %v", err)
if err := c2.SetDeadline(t); err != nil && errf != nil {
errf("BiCopy: c2 %s: %v", what, err)
}
}
hookDone := make(chan struct{})
stopOnCancel := context.AfterFunc(ctx2, func() {
defer close(hookDone)
setDeadline(time.Now(), "SetDeadline")
})
var wg sync.WaitGroup
@@ -44,11 +45,11 @@ func BiCopy(ctx context.Context, c1, c2 net.Conn, errf func(format string, v ...
})
wg.Wait()
if err := c1.SetDeadline(time.Time{}); err != nil && errf != nil {
errf("BiCopy: c1 clear deadline: %v", err)
}
if err := c2.SetDeadline(time.Time{}); err != nil && errf != nil {
errf("BiCopy: c2 clear deadline: %v", err)
// Дедлайны сбрасываются только после того, как хук отменён или доработал - иначе
// он взвёлся бы следом и оставил conn'ы просроченными навсегда.
if !stopOnCancel() {
<-hookDone
}
setDeadline(time.Time{}, "clear deadline")
return c1FromC2, c2FromC1
}
+42
View File
@@ -0,0 +1,42 @@
package netconn
import (
"context"
"io"
"net"
"testing"
)
func TestBiCopy(t *testing.T) {
t.Parallel()
a1, a2 := net.Pipe()
b1, b2 := net.Pipe()
defer func() { _ = a1.Close() }()
defer func() { _ = b1.Close() }()
done := make(chan [2]int64, 1)
go func() {
n1, n2 := BiCopy(context.Background(), a2, b2, nil)
done <- [2]int64{n1, n2}
}()
go func() {
_, _ = a1.Write([]byte("ping"))
_ = a1.Close()
}()
got := make([]byte, 4)
if _, err := io.ReadFull(b1, got); err != nil {
t.Fatal(err)
}
if string(got) != "ping" {
t.Fatalf("got %q, want %q", got, "ping")
}
_ = b1.Close()
counts := <-done
if counts[1] != 4 {
t.Errorf("a2->b2 = %d, want 4", counts[1])
}
}
+200
View File
@@ -0,0 +1,200 @@
package netconn
import (
"errors"
"net"
"os"
"sync"
"time"
)
// ErrPacketTooLarge возвращается, если размер датаграммы превышает MTU пайпа.
var ErrPacketTooLarge = errors.New("netconn: packet exceeds pipe MTU")
const (
defaultPipeMTU = 2048
defaultPipeQueue = 256
)
type pipeAddr string
func (pipeAddr) Network() string { return "pipe" }
func (a pipeAddr) String() string { return string(a) }
// PacketPipe возвращает пару связанных net.PacketConn в памяти.
func PacketPipe(mtu, queue int) (net.PacketConn, net.PacketConn) {
if mtu <= 0 {
mtu = defaultPipeMTU
}
if queue <= 0 {
queue = defaultPipeQueue
}
// sync.Pool хранит *[]byte для избежания аллокаций при передаче в канал.
pool := &sync.Pool{New: func() any { b := make([]byte, mtu); return &b }}
toA := make(chan *[]byte, queue)
toB := make(chan *[]byte, queue)
a := &packetPipe{local: pipeAddr("pipe:a"), remote: pipeAddr("pipe:b"), rx: toA, tx: toB, mtu: mtu, pool: pool, done: make(chan struct{})}
b := &packetPipe{local: pipeAddr("pipe:b"), remote: pipeAddr("pipe:a"), rx: toB, tx: toA, mtu: mtu, pool: pool, done: make(chan struct{})}
a.peer, b.peer = b, a
return a, b
}
type packetPipe struct {
local net.Addr
remote net.Addr
rx <-chan *[]byte
tx chan<- *[]byte
peer *packetPipe
mtu int
pool *sync.Pool
// deadline хранит дедлайн и канал оповещения для немедленного пробуждения читателя.
deadline struct {
mu sync.Mutex
at time.Time
changed chan struct{}
}
closeOnce sync.Once
done chan struct{}
}
func (p *packetPipe) ReadFrom(b []byte) (int, net.Addr, error) {
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
}()
for {
at, changed := p.readDeadline()
if timer != nil {
timer.Stop()
timer = nil
}
var timeout <-chan time.Time
if !at.IsZero() {
timer = time.NewTimer(time.Until(at))
timeout = timer.C
}
select {
case <-p.done:
return 0, nil, net.ErrClosed
case <-p.peer.done:
if n, ok := p.drain(b); ok {
return n, p.remote, nil
}
return 0, nil, net.ErrClosed
case <-changed:
continue
case <-timeout:
return 0, nil, os.ErrDeadlineExceeded
case buf, ok := <-p.rx:
if !ok {
return 0, nil, net.ErrClosed
}
n := copy(b, *buf)
p.pool.Put(buf)
return n, p.remote, nil
}
}
}
func (p *packetPipe) drain(b []byte) (int, bool) {
select {
case buf, ok := <-p.rx:
if !ok {
return 0, false
}
n := copy(b, *buf)
p.pool.Put(buf)
return n, true
default:
return 0, false
}
}
func (p *packetPipe) readDeadline() (time.Time, <-chan struct{}) {
p.deadline.mu.Lock()
defer p.deadline.mu.Unlock()
if p.deadline.changed == nil {
p.deadline.changed = make(chan struct{})
}
return p.deadline.at, p.deadline.changed
}
func (p *packetPipe) WriteTo(b []byte, _ net.Addr) (int, error) {
if len(b) > p.mtu {
return 0, ErrPacketTooLarge
}
select {
case <-p.done:
return 0, net.ErrClosed
case <-p.peer.done:
return 0, net.ErrClosed
default:
}
buf, _ := p.pool.Get().(*[]byte)
*buf = (*buf)[:len(b)]
copy(*buf, b)
select {
case p.tx <- buf:
default:
p.pool.Put(buf)
}
return len(b), nil
}
func (p *packetPipe) Close() error {
p.closeOnce.Do(func() { close(p.done) })
return nil
}
func (p *packetPipe) LocalAddr() net.Addr { return p.local }
func (p *packetPipe) SetDeadline(t time.Time) error {
return p.SetReadDeadline(t)
}
func (p *packetPipe) SetReadDeadline(t time.Time) error {
p.deadline.mu.Lock()
defer p.deadline.mu.Unlock()
p.deadline.at = t
if p.deadline.changed != nil {
close(p.deadline.changed)
}
p.deadline.changed = make(chan struct{})
return nil
}
// SetWriteDeadline - no-op: неблокирующая запись дропает пакет при переполнении очереди.
func (*packetPipe) SetWriteDeadline(time.Time) error { return nil }
// datagramConn подаёт packetPipe как net.Conn: границы датаграмм сохраняются, поэтому
// поверх такой пары можно поднимать KCP без реального сокета.
type datagramConn struct {
net.PacketConn
remote net.Addr
}
func (c *datagramConn) Read(b []byte) (int, error) {
n, _, err := c.ReadFrom(b)
return n, err
}
func (c *datagramConn) Write(b []byte) (int, error) { return c.WriteTo(b, c.remote) }
func (c *datagramConn) RemoteAddr() net.Addr { return c.remote }
// DatagramPipe - пара связанных net.Conn в памяти с датаграммной семантикой.
func DatagramPipe(mtu, queue int) (net.Conn, net.Conn) {
a, b := PacketPipe(mtu, queue)
return &datagramConn{PacketConn: a, remote: b.LocalAddr()}, &datagramConn{PacketConn: b, remote: a.LocalAddr()}
}
+164
View File
@@ -0,0 +1,164 @@
package netconn
import (
"bytes"
"errors"
"net"
"os"
"testing"
"time"
)
func TestPacketPipeRoundTrip(t *testing.T) {
a, b := PacketPipe(0, 0)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
want := []byte("hello turn")
if _, err := a.WriteTo(want, nil); err != nil {
t.Fatalf("WriteTo() error = %v", err)
}
buf := make([]byte, 64)
n, addr, err := b.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom() error = %v", err)
}
if !bytes.Equal(buf[:n], want) {
t.Errorf("read %q, want %q", buf[:n], want)
}
if addr == nil || addr.String() != a.LocalAddr().String() {
t.Errorf("addr = %v, want %v", addr, a.LocalAddr())
}
}
func TestPacketPipeKeepsDatagramBoundaries(t *testing.T) {
a, b := PacketPipe(0, 0)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
for _, msg := range [][]byte{[]byte("one"), []byte("two")} {
if _, err := a.WriteTo(msg, nil); err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 64)
for _, want := range []string{"one", "two"} {
n, _, err := b.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if got := string(buf[:n]); got != want {
t.Fatalf("read %q, want %q", got, want)
}
}
}
func TestPacketPipeTruncatesToCallerBuffer(t *testing.T) {
a, b := PacketPipe(0, 0)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
if _, err := a.WriteTo([]byte("0123456789"), nil); err != nil {
t.Fatal(err)
}
if _, err := a.WriteTo([]byte("next"), nil); err != nil {
t.Fatal(err)
}
small := make([]byte, 4)
n, _, err := b.ReadFrom(small)
if err != nil || n != 4 || string(small) != "0123" {
t.Fatalf("ReadFrom() = %d, %q, %v", n, small[:n], err)
}
buf := make([]byte, 64)
n, _, err = b.ReadFrom(buf)
if err != nil || string(buf[:n]) != "next" {
t.Fatalf("second ReadFrom() = %q, %v", buf[:n], err)
}
}
func TestPacketPipeRejectsOversized(t *testing.T) {
a, b := PacketPipe(16, 0)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
if _, err := a.WriteTo(make([]byte, 17), nil); !errors.Is(err, ErrPacketTooLarge) {
t.Fatalf("WriteTo() error = %v, want ErrPacketTooLarge", err)
}
}
func TestPacketPipeDropsWhenQueueFull(t *testing.T) {
a, b := PacketPipe(64, 2)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
done := make(chan struct{})
go func() {
defer close(done)
for range 10 {
if _, err := a.WriteTo([]byte("x"), nil); err != nil {
t.Errorf("WriteTo() error = %v", err)
return
}
}
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WriteTo blocked on full queue")
}
buf := make([]byte, 8)
for range 2 {
if _, _, err := b.ReadFrom(buf); err != nil {
t.Fatalf("ReadFrom() error = %v", err)
}
}
if err := b.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
t.Fatal(err)
}
if _, _, err := b.ReadFrom(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("ReadFrom() error = %v, want deadline", err)
}
}
func TestPacketPipeReadDeadline(t *testing.T) {
a, b := PacketPipe(0, 0)
defer func() { _ = a.Close() }()
defer func() { _ = b.Close() }()
if err := b.SetDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
t.Fatal(err)
}
start := time.Now()
if _, _, err := b.ReadFrom(make([]byte, 8)); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("ReadFrom() error = %v, want deadline", err)
}
if time.Since(start) > time.Second {
t.Error("ReadFrom() ignored deadline")
}
}
func TestPacketPipeClose(t *testing.T) {
a, b := PacketPipe(0, 0)
if err := b.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, _, err := b.ReadFrom(make([]byte, 8)); !errors.Is(err, net.ErrClosed) {
t.Errorf("ReadFrom() after Close error = %v, want net.ErrClosed", err)
}
if _, err := a.WriteTo([]byte("x"), nil); !errors.Is(err, net.ErrClosed) {
t.Errorf("WriteTo() to closed peer error = %v, want net.ErrClosed", err)
}
if err := b.Close(); err != nil {
t.Errorf("second Close() error = %v", err)
}
_ = a.Close()
}
var _ net.PacketConn = func() net.PacketConn { a, _ := PacketPipe(0, 0); return a }()
+18 -18
View File
@@ -1,28 +1,28 @@
// Package netctl carries a process-global socket Control hook applied to every
// OUTBOUND socket the vendored free-turn client opens. The host installs it so
// those sockets are excluded from the VPN tunnel (Android VpnService.protect on
// the raw fd) — without it the client's own TURN/VK/DNS traffic loops back into
// the tunnel and hangs. This patch is local to our vendored copy: upstream has
// no such hook, and the dialers it creates (netconn.DirectNet, dnsdial,
// turndial) are routed through Apply below.
// Package netctl предоставляет глобальный Control-хук для сокетов (VpnService.protect).
package netctl
import "syscall"
import (
"sync/atomic"
"syscall"
)
var control func(network, address string, c syscall.RawConn) error
type ControlFunc func(network, address string, c syscall.RawConn) error
// SetControl installs the per-socket protector. Call before the client starts.
// nil (the default) makes Apply a no-op, so desktop builds are unaffected.
func SetControl(fn func(network, address string, c syscall.RawConn) error) {
control = fn
var control atomic.Pointer[ControlFunc]
// SetControl регистрирует функцию защиты сокетов хоста (nil - no-op).
func SetControl(fn ControlFunc) {
if fn == nil {
control.Store(nil)
return
}
control.Store(&fn)
}
// Apply is assigned as the Control func of every outbound net.Dialer /
// net.ListenConfig in the vendored client. It reads `control` at dial time so
// the host may register the protector before or after the client starts.
// Apply вызывается из net.Dialer и net.ListenConfig для защиты создаваемых сокетов.
func Apply(network, address string, c syscall.RawConn) error {
if control != nil {
return control(network, address, c)
if fn := control.Load(); fn != nil {
return (*fn)(network, address, c)
}
return nil
}
+7 -8
View File
@@ -1,6 +1,4 @@
// Package multi объединяет несколько provider.Provider в один, распределяя
// streamID по вложенным провайдерам round-robin. Применение: несколько
// VK-звонков как единый пул TURN-стримов (больше параллельных аллокаций).
// Package multi объединяет несколько provider.Provider с балансировкой round-robin.
package multi
import (
@@ -10,9 +8,7 @@ import (
"github.com/samosvalishe/free-turn-proxy/internal/provider"
)
// Provider распределяет 1-based streamID round-robin: провайдер = (streamID-1) % n,
// внутренний streamID = ((streamID-1) / n) + 1 (биекция, без коллизий).
// Без изменяемого состояния - thread-safe.
// Provider распределяет запросы учетных данных по пулу провайдеров.
type Provider struct {
providers []provider.Provider
n int
@@ -36,7 +32,6 @@ func (m *Provider) GetCredentials(ctx context.Context, streamID int) (provider.C
return p.GetCredentials(ctx, innerID)
}
// IsAuthError делегирует первому провайдеру: все вложенные однотипны (VK).
func (m *Provider) IsAuthError(err error) bool {
return m.providers[0].IsAuthError(err)
}
@@ -51,7 +46,11 @@ func (m *Provider) ResetErrors(streamID int) {
p.ResetErrors(innerID)
}
// BackoffUntilUnix - максимум среди провайдеров (один в блокировке -> пауза для всех).
func (m *Provider) DropCredentials(streamID int) {
p, innerID := m.providerFor(streamID)
p.DropCredentials(innerID)
}
func (m *Provider) BackoffUntilUnix() int64 {
var until int64
for _, p := range m.providers {
+6
View File
@@ -17,6 +17,7 @@ type fakeProvider struct {
gotCreds []int
gotHandle []int
gotReset []int
gotDrop []int
authResult bool
}
@@ -27,6 +28,7 @@ func (f *fakeProvider) GetCredentials(_ context.Context, streamID int) (provider
func (f *fakeProvider) IsAuthError(error) bool { return f.authResult }
func (f *fakeProvider) HandleAuthError(s int) bool { f.gotHandle = append(f.gotHandle, s); return true }
func (f *fakeProvider) ResetErrors(s int) { f.gotReset = append(f.gotReset, s) }
func (f *fakeProvider) DropCredentials(s int) { f.gotDrop = append(f.gotDrop, s) }
func (f *fakeProvider) BackoffUntilUnix() int64 { return f.backoff }
func (f *fakeProvider) Name() string { return fmt.Sprintf("fake%d", f.idx) }
@@ -93,6 +95,10 @@ func TestHandleAndResetRouting(t *testing.T) {
if len(fs[0].gotReset) != 1 || fs[0].gotReset[0] != 3 {
t.Fatalf("ResetErrors routed wrong: %v", fs[0].gotReset)
}
m.DropCredentials(5)
if len(fs[0].gotDrop) != 1 || fs[0].gotDrop[0] != 3 {
t.Fatalf("DropCredentials routed wrong: %v", fs[0].gotDrop)
}
if len(fs[1].gotHandle) != 0 {
t.Fatalf("provider 1 should not be touched: %v", fs[1].gotHandle)
}
+7 -46
View File
@@ -1,13 +1,4 @@
// Package provider определяет абстракцию источника TURN-реквизитов.
//
// Реализации (internal/provider/vk, ...) поставляют
// короткоживущие user/pass/addr для TURN-allocate. Pipeline (proxy/udprelay,
// proxy/tcpfwd) работает только через этот интерфейс - никаких VK-specific
// типов в общем коде.
//
// Sentinel-ошибки описывают provider-нейтральные состояния, которые pipeline
// обрабатывает однотипно: ErrBackoffActive - провайдер просит подождать,
// ErrFatalNoStreams - фатально, дальше пробовать бессмысленно.
// Package provider определяет интерфейс источника учетных данных TURN-серверов.
package provider
import (
@@ -16,56 +7,26 @@ import (
"time"
)
// Credentials - выданные провайдером TURN-реквизиты для одного стрима.
// ServerAddrs - кандидаты host:port в порядке предпочтения (первый -
// предпочтительный для этого streamID). Pipeline пробует их по очереди при
// allocate: если первый не проходит (DPI-дроп/RST), берёт следующий.
// Credentials содержит реквизиты авторизации и адреса TURN-серверов.
type Credentials struct {
User string
Pass string
ServerAddrs []string // host:port, primary-first
ExpiresAt time.Time // 0 если провайдер не сообщает дедлайн
ServerAddrs []string
ExpiresAt time.Time
}
// Provider - источник TURN-реквизитов для клиентского pipeline.
// Реализации обязаны быть thread-safe: pipeline вызывает GetCredentials и
// HandleAuthError из N разных горутин одновременно (по числу стримов).
// Provider предоставляет доступ к учетным записям TURN.
type Provider interface {
// GetCredentials блокирующе возвращает реквизиты для streamID.
// Реализация отвечает за throttling, кеширование, ретраи к upstream-API.
GetCredentials(ctx context.Context, streamID int) (Credentials, error)
// IsAuthError - провайдер сам решает, какие ошибки от TURN-allocate
// (401/403/stale-nonce/etc.) считать auth-related и приводить к
// инвалидации кеша через HandleAuthError.
IsAuthError(err error) bool
// HandleAuthError увеличивает счётчик auth-ошибок для streamID.
// Возвращает true, если порог достигнут и кеш для streamID инвалидирован.
HandleAuthError(streamID int) bool
// ResetErrors обнуляет счётчик ошибок (вызывать после успешного allocate).
ResetErrors(streamID int)
// BackoffUntilUnix - unix-секунда, до которой провайдер просит pipeline
// не звать GetCredentials. 0 - нет активного бэк-оффа.
// Используется только при возврате ошибки, обёрнутой ErrBackoffActive.
DropCredentials(streamID int)
BackoffUntilUnix() int64
// Name - короткое имя провайдера для логов ("vk", ...).
Name() string
}
// Sentinel-ошибки, которые провайдер оборачивает (через errors.Join или
// fmt.Errorf("%w: ...", ErrXxx, inner)) для сигнализации pipeline.
var (
// ErrBackoffActive - провайдер просит подождать. Pipeline вызывает
// BackoffUntilUnix() для получения точного дедлайна; если 0 - sleep по
// дефолтному backoff (60s).
ErrBackoffActive = errors.New("provider backoff active")
// ErrFatalNoStreams - провайдер не может выдать креды и ни один стрим
// ещё не поднят. Pipeline возвращает фатальную ошибку наружу
// (хост-процесс делает os.Exit без вмешательства pipeline).
ErrBackoffActive = errors.New("provider backoff active")
ErrFatalNoStreams = errors.New("provider fatal: no streams alive")
)
@@ -1,133 +1,261 @@
package browserprofile
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"os"
"strconv"
"strings"
fhttp "github.com/bogdanfinn/fhttp"
"github.com/bogdanfinn/tls-client/profiles"
)
// Kind - семейство браузера.
type Kind string
const (
Chrome Kind = "chrome"
Firefox Kind = "firefox"
Safari Kind = "safari"
chromeSecChUa = `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`
uaWindows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
uaMac = "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"
uaAndroid = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36"
timezoneMoscow = "Europe/Moscow"
touchPointsMobile = 5
)
// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное -
// Firefox (текущий дефолт продукта).
func KindFromString(s string) Kind {
switch s {
case string(Chrome):
return Chrome
case string(Safari):
return Safari
type Platform string
const (
Desktop Platform = "desktop"
Mobile Platform = "mobile"
)
// PlatformFromString преобразует строку в Platform (по умолчанию Desktop).
func PlatformFromString(s string) Platform {
if s == string(Mobile) {
return Mobile
}
return Firefox
return Desktop
}
// Profile содержит браузерные параметры и отпечатки (UA, client hints, device fingerprint).
type Profile struct {
Platform Platform
UserAgent string
SecChUa string // пусто для non-Chromium профилей (Chromium-only client hint)
SecChUa string
SecChUaMobile string
SecChUaPlatform string
AcceptLanguage string
DeviceJSON string
VisitorID string
cores int
memGB int
webdriver bool
dev device
}
// ForKind возвращает канонический профиль для браузера.
func ForKind(k Kind) Profile {
switch k {
case Safari:
return Profile{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15",
AcceptLanguage: "ru-RU,ru;q=0.9",
}
case Firefox:
return Profile{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
AcceptLanguage: "ru-RU,ru;q=0.9",
}
func (p Profile) IsMobile() bool { return p.Platform == Mobile }
func (p Profile) HardwareConcurrency() int { return p.cores }
func (p Profile) Webdriver() bool { return p.webdriver }
func (p Profile) DeviceMemory() *int {
if p.memGB == 0 {
return nil
}
return 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: `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`,
return &p.memGB
}
func (p Profile) Languages() []string { return p.dev.Languages }
func (p Profile) DevicePixelRatio() float64 { return p.dev.DevicePixelRatio }
func (Profile) Timezone() string { return timezoneMoscow }
func (p Profile) MaxTouchPoints() int {
if p.IsMobile() {
return touchPointsMobile
}
return 0
}
func (p Profile) Orientation() string {
if p.IsMobile() {
return "portrait-primary"
}
return "landscape-primary"
}
func (p Profile) PlatformName() string { return strings.Trim(p.SecChUaPlatform, `"`) }
type Brand struct {
Brand string `json:"brand"`
Version string `json:"version"`
}
func (p Profile) Brands() []Brand {
out := make([]Brand, 0, 3)
for _, part := range strings.Split(p.SecChUa, ", ") {
name, ver, ok := strings.Cut(part, ";v=")
if !ok {
continue
}
out = append(out, Brand{Brand: strings.Trim(name, `"`), Version: strings.Trim(ver, `"`)})
}
return out
}
type device struct {
ScreenWidth int `json:"screenWidth"`
ScreenHeight int `json:"screenHeight"`
ScreenAvailWidth int `json:"screenAvailWidth"`
ScreenAvailHeight int `json:"screenAvailHeight"`
InnerWidth int `json:"innerWidth"`
InnerHeight int `json:"innerHeight"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
Language string `json:"language"`
Languages []string `json:"languages"`
Webdriver bool `json:"webdriver"`
HardwareConcurrency int `json:"hardwareConcurrency"`
DeviceMemory *int `json:"deviceMemory,omitempty"`
ConnectionEffectiveType string `json:"connectionEffectiveType,omitempty"`
NotificationsPermission string `json:"notificationsPermission"`
}
var chromeHeaderOrder = []string{
"content-length",
"sec-ch-ua-platform",
"user-agent",
"sec-ch-ua",
"content-type",
"sec-ch-ua-mobile",
"upgrade-insecure-requests",
"accept",
"origin",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"referer",
"accept-encoding",
"accept-language",
"cookie",
"priority",
}
type spec struct {
userAgent string
chPlatform string
dev device
}
var desktopSpecs = []spec{
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(1920, 1080, 1032, 1147, 945, 1, 16, 8)},
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(1536, 864, 816, 1229, 738, 1.25, 8, 8)},
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(2560, 1440, 1392, 1512, 1237, 1, 12, 8)},
{userAgent: uaMac, chPlatform: `"macOS"`, dev: newDevice(1512, 982, 944, 1147, 870, 2, 10, 8)},
}
var mobileSpecs = []spec{
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(364, 793, 793, 363, 671, 3.5, 8, 8)},
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(393, 852, 852, 393, 659, 3, 8, 8)},
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(412, 915, 915, 412, 724, 2.625, 8, 4)},
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(360, 800, 800, 360, 612, 3, 8, 4)},
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(384, 832, 832, 384, 644, 2.75, 8, 8)},
}
// Identity идентифицирует браузерную личность и её поколение.
type Identity struct {
Seed string
Gen int
}
func (id Identity) String() string { return id.Seed + "|" + strconv.Itoa(id.Gen) }
// For генерирует профиль браузера для указанной платформы и идентичности.
func For(p Platform, id Identity) Profile {
specs := desktopSpecs
if p == Mobile {
specs = mobileSpecs
} else {
p = Desktop
}
sum := sha256.Sum256([]byte(id.String()))
s := specs[binary.BigEndian.Uint64(sum[:8])%uint64(len(specs))]
profile := Profile{
Platform: p,
SecChUa: chromeSecChUa,
SecChUaMobile: "?0",
SecChUaPlatform: `"Windows"`,
AcceptLanguage: "ru-RU,ru;q=0.9",
SecChUaPlatform: s.chPlatform,
AcceptLanguage: "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
UserAgent: s.userAgent,
}
if p == Mobile {
profile.SecChUaMobile = "?1"
}
profile = withDevice(profile, s.dev)
profile.VisitorID = visitorID(id, profile)
return profile
}
func newDevice(screenW, screenH, availH, innerW, innerH int, dpr float64, cores, memGB int) device {
memory := memGB
return device{
ScreenWidth: screenW, ScreenHeight: screenH,
ScreenAvailWidth: screenW, ScreenAvailHeight: availH,
InnerWidth: innerW, InnerHeight: innerH,
DevicePixelRatio: dpr,
Language: "ru-RU",
Languages: []string{"ru-RU", "en-US"},
HardwareConcurrency: cores,
DeviceMemory: &memory,
ConnectionEffectiveType: "4g",
NotificationsPermission: "denied",
}
}
func Family(p Profile) Kind {
ua := strings.ToLower(p.UserAgent)
switch {
case strings.Contains(ua, "firefox/"):
return Firefox
case strings.Contains(ua, "version/") && strings.Contains(ua, "safari/") && !strings.Contains(ua, "chrome/") && !strings.Contains(ua, "chromium/"):
return Safari
case strings.Contains(ua, "chrome/") || strings.Contains(ua, "chromium/"):
return Chrome
default:
return ""
}
func visitorID(id Identity, p Profile) string {
sum := sha256.Sum256([]byte(id.String() + "|" + p.UserAgent + "|" + p.DeviceJSON))
return hex.EncodeToString(sum[:16])
}
func IsMobile(p Profile) bool {
ua := strings.ToLower(p.UserAgent)
return strings.Contains(ua, " mobile ") ||
strings.Contains(ua, " mobile/") ||
strings.Contains(ua, "android") ||
strings.Contains(strings.ToLower(p.SecChUaMobile), "?1")
}
type Saved struct {
Profile
DeviceJSON string
BrowserFp string
}
const profileFile = "vk_profile.json"
// profilePath возвращает путь к сохранённому профилю.
func profilePath() string {
if p := strings.TrimSpace(os.Getenv("VK_PROFILE_PATH")); p != "" {
func withDevice(p Profile, d device) Profile {
data, err := json.Marshal(d)
if err != nil {
return p
}
return profileFile
p.DeviceJSON = string(data)
p.dev = d
p.cores, p.webdriver = d.HardwareConcurrency, d.Webdriver
if d.DeviceMemory != nil {
p.memGB = *d.DeviceMemory
}
return p
}
func Load() (*Saved, error) {
data, err := os.ReadFile(profilePath())
if err != nil {
return nil, err
}
var sp Saved
if err := json.Unmarshal(data, &sp); err != nil {
return nil, err
}
return &sp, nil
}
func Save(sp Saved) error {
data, err := json.MarshalIndent(sp, "", " ")
if err != nil {
return err
}
return os.WriteFile(profilePath(), data, 0600)
func (Profile) ClientProfile() profiles.ClientProfile {
return profiles.Chrome_146
}
func ApplyFhttp(req *fhttp.Request, profile Profile) {
req.Header.Set("User-Agent", profile.UserAgent)
if profile.SecChUa != "" {
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("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", profile.AcceptLanguage)
if req.Header.Get("Sec-Fetch-Dest") == "document" {
req.Header.Set("Priority", "u=0, i")
} else {
req.Header.Set("Priority", "u=1, i")
}
acceptLang := profile.AcceptLanguage
if acceptLang == "" {
acceptLang = "ru-RU,ru;q=0.9"
}
req.Header.Set("Accept-Language", acceptLang)
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
}
func ApplyProxyFhttp(req *fhttp.Request) {
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
}
@@ -1,97 +1,277 @@
package browserprofile
import (
"os"
"path/filepath"
"encoding/json"
"reflect"
"regexp"
"slices"
"strings"
"testing"
fhttp "github.com/bogdanfinn/fhttp"
"github.com/bogdanfinn/tls-client/profiles"
)
func TestKindFromString(t *testing.T) {
const testSeed = "persona-test"
// personas возвращает по персоне на каждый spec платформы: инварианты обязаны
// держаться на всех, а не только на той, что выпала дефолтной identity.
func personas(t *testing.T, plat Platform) []Profile {
t.Helper()
specs := desktopSpecs
if plat == Mobile {
specs = mobileSpecs
}
seen := make(map[string]Profile, len(specs))
for gen := 0; gen < 200 && len(seen) < len(specs); gen++ {
p := For(plat, Identity{Seed: testSeed, Gen: gen})
seen[p.UserAgent+p.DeviceJSON] = p
}
if len(seen) != len(specs) {
t.Fatalf("%s: covered %d/%d specs", plat, len(seen), len(specs))
}
out := make([]Profile, 0, len(seen))
for _, p := range seen {
out = append(out, p)
}
return out
}
func deviceOf(t *testing.T, p Profile) device {
t.Helper()
var dev device
if err := json.Unmarshal([]byte(p.DeviceJSON), &dev); err != nil {
t.Fatalf("DeviceJSON: %v", err)
}
return dev
}
func TestPlatformFromString(t *testing.T) {
cases := []struct {
in string
want Kind
want Platform
}{
{"chrome", Chrome},
{"firefox", Firefox},
{"safari", Safari},
{"", Firefox},
{"unknown", Firefox},
{"mobile", Mobile},
{"desktop", Desktop},
{"", Desktop},
{"unknown", Desktop},
}
for _, tc := range cases {
if got := KindFromString(tc.in); got != tc.want {
t.Fatalf("KindFromString(%q) = %q, want %q", tc.in, got, tc.want)
if got := PlatformFromString(tc.in); got != tc.want {
t.Fatalf("PlatformFromString(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestProfilePathHonorsEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "vk_profile_chrome_42.json")
t.Setenv("VK_PROFILE_PATH", path)
want := Saved{Profile: ForKind(Chrome), DeviceJSON: "{}", BrowserFp: "abc123"}
if err := Save(want); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("profile not written to VK_PROFILE_PATH %s: %v", path, err)
}
got, err := Load()
if err != nil {
t.Fatal(err)
}
if got.BrowserFp != want.BrowserFp || got.UserAgent != want.UserAgent {
t.Fatalf("loaded = %+v, want fp=%s ua=%s", got, want.BrowserFp, want.UserAgent)
func TestEveryPersonaIsChrome146(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
if !strings.Contains(p.UserAgent, "Chrome/146.0.0.0") {
t.Fatalf("%s UA not chrome 146: %q", plat, p.UserAgent)
}
if p.SecChUa != chromeSecChUa {
t.Fatalf("%s sec-ch-ua = %q", plat, p.SecChUa)
}
}
}
}
func TestForKindChrome146(t *testing.T) {
p := ForKind(Chrome)
if p.UserAgent != "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" {
t.Fatalf("unexpected chrome UA: %q", p.UserAgent)
}
if p.SecChUa != `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"` {
t.Fatalf("unexpected chrome sec-ch-ua: %q", p.SecChUa)
// Одна identity - одна личность: иначе visitorId прыгал бы между запросами
// одной сессии.
func TestForIsDeterministic(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
id := Identity{Seed: testSeed, Gen: 3}
first, again := For(plat, id), For(plat, id)
if !reflect.DeepEqual(first, again) {
t.Fatalf("%s: For not deterministic", plat)
}
}
}
func TestApplyFhttpOmitsClientHintsForFirefoxAndSafari(t *testing.T) {
for _, kind := range []Kind{Firefox, Safari} {
req, err := fhttp.NewRequest(fhttp.MethodGet, "https://example.com/", nil)
// Сжигание персоны обязано менять и устройство, и visitorId: иначе отвергнутый
// отпечаток переиспользуется.
func TestBurnChangesIdentity(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
prev := For(plat, Identity{Seed: testSeed})
changed := false
for gen := 1; gen <= 4; gen++ {
next := For(plat, Identity{Seed: testSeed, Gen: gen})
if next.VisitorID == prev.VisitorID {
t.Fatalf("%s gen=%d reused visitorId %q", plat, gen, next.VisitorID)
}
if next.DeviceJSON != prev.DeviceJSON {
changed = true
}
prev = next
}
if !changed {
t.Fatalf("%s: burning never changed the device", plat)
}
}
}
// Разные установки - разные личности, иначе visitorId общий на всех.
func TestSeedSeparatesInstalls(t *testing.T) {
a := For(Desktop, Identity{Seed: "install-a"})
b := For(Desktop, Identity{Seed: "install-b"})
if a.VisitorID == b.VisitorID {
t.Fatal("different seeds share visitorId")
}
}
func TestVisitorIDFormat(t *testing.T) {
re := regexp.MustCompile(`^[0-9a-f]{32}$`)
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
if !re.MatchString(p.VisitorID) {
t.Fatalf("%s visitorId = %q", plat, p.VisitorID)
}
}
}
}
func TestPlatformSet(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
p := For(plat, Identity{Seed: testSeed})
if p.Platform != plat {
t.Fatalf("For(%s).Platform = %q", plat, p.Platform)
}
if p.IsMobile() != (plat == Mobile) {
t.Fatalf("For(%s).IsMobile() = %v", plat, p.IsMobile())
}
}
}
// Окно не бывает больше экрана, а рабочая область - больше самого экрана.
func TestWindowFitsScreen(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
dev := deviceOf(t, p)
if dev.InnerWidth > dev.ScreenAvailWidth || dev.InnerHeight > dev.ScreenAvailHeight {
t.Fatalf("%s window %dx%d exceeds avail %dx%d", plat,
dev.InnerWidth, dev.InnerHeight, dev.ScreenAvailWidth, dev.ScreenAvailHeight)
}
if dev.ScreenAvailWidth > dev.ScreenWidth || dev.ScreenAvailHeight > dev.ScreenHeight {
t.Fatalf("%s avail %dx%d exceeds screen %dx%d", plat,
dev.ScreenAvailWidth, dev.ScreenAvailHeight, dev.ScreenWidth, dev.ScreenHeight)
}
}
}
}
// Chrome квантует navigator.deviceMemory по степеням двойки и режет потолок до 8:
// любое другое значение живым браузером быть не может.
func TestDeviceMemoryIsChromeQuantized(t *testing.T) {
allowed := []int{1, 2, 4, 8}
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
dev := deviceOf(t, p)
if dev.DeviceMemory == nil || !slices.Contains(allowed, *dev.DeviceMemory) {
t.Fatalf("%s deviceMemory = %v, want one of %v", plat, dev.DeviceMemory, allowed)
}
}
}
}
// Порядок заголовков обязан быть задан всегда, иначе на проводе останется
// случайный порядок Go-мапы.
func TestApplyFhttpSetsChromeHeaders(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
req, err := fhttp.NewRequest(fhttp.MethodPost, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
ApplyFhttp(req, ForKind(kind))
if req.Header.Get("sec-ch-ua") != "" {
t.Fatalf("%s sec-ch-ua = %q, want empty", kind, req.Header.Get("sec-ch-ua"))
p := For(plat, Identity{Seed: testSeed})
ApplyFhttp(req, p)
if req.Header.Get("sec-ch-ua") != p.SecChUa {
t.Fatalf("For(%s) sec-ch-ua = %q", plat, req.Header.Get("sec-ch-ua"))
}
if req.Header.Get("DNT") != "" {
t.Fatalf("%s DNT = %q, want empty", kind, req.Header.Get("DNT"))
if got := req.Header.Get("Priority"); got != "u=1, i" {
t.Fatalf("For(%s) priority = %q", plat, got)
}
if len(req.Header[fhttp.HeaderOrderKey]) == 0 {
t.Fatalf("For(%s) header order not set", plat)
}
}
}
func TestFamily(t *testing.T) {
for _, kind := range []Kind{Chrome, Firefox, Safari} {
if got := Family(ForKind(kind)); got != kind {
t.Fatalf("Family(%s profile) = %q, want %q", kind, got, kind)
// Персона обязана быть самосогласованной: мобильная платформа -> мобильный UA,
// мобильный sec-ch-ua и непустой device для captcha.
func TestPersonaSelfConsistent(t *testing.T) {
for _, p := range personas(t, Mobile) {
if !strings.Contains(strings.ToLower(p.UserAgent), "mobile") {
t.Fatalf("mobile UA not mobile: %q", p.UserAgent)
}
if p.DeviceJSON == "" {
t.Fatal("mobile persona has empty DeviceJSON")
}
if p.SecChUaMobile != "?1" {
t.Fatalf("mobile sec-ch-ua-mobile = %q, want ?1", p.SecChUaMobile)
}
}
if got := Family(Profile{UserAgent: "curl/8.0"}); got != "" {
t.Fatalf("Family(unknown) = %q, want empty", got)
for _, p := range personas(t, Desktop) {
if strings.Contains(strings.ToLower(p.UserAgent), "mobile") {
t.Fatalf("desktop UA is mobile: %q", p.UserAgent)
}
if p.SecChUaMobile != "?0" {
t.Fatalf("desktop sec-ch-ua-mobile = %q, want ?0", p.SecChUaMobile)
}
}
}
func TestIsMobile(t *testing.T) {
if IsMobile(ForKind(Chrome)) {
t.Fatal("desktop chrome detected as mobile")
// sec-ch-ua-platform и UA читает один и тот же анти-бот: заявленная в hints ОС
// обязана совпадать с ОС в User-Agent.
func TestPersonaPlatformMatchesUA(t *testing.T) {
marker := map[string]string{
`"Windows"`: "Windows NT",
`"macOS"`: "Macintosh",
`"Android"`: "Android",
}
if !IsMobile(Profile{UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36"}) {
t.Fatal("android chrome was not detected as mobile")
}
if !IsMobile(Profile{UserAgent: ForKind(Chrome).UserAgent, SecChUaMobile: "?1"}) {
t.Fatal("sec-ch-ua-mobile ?1 was not detected as mobile")
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
want, ok := marker[p.SecChUaPlatform]
if !ok {
t.Fatalf("unknown sec-ch-ua-platform %q", p.SecChUaPlatform)
}
if !strings.Contains(p.UserAgent, want) {
t.Fatalf("sec-ch-ua-platform %s but UA %q", p.SecChUaPlatform, p.UserAgent)
}
}
}
}
// Accept-Language и navigator.languages читает один и тот же анти-бот: тег,
// объявленный в JS, но отсутствующий в заголовке - несоответствие персоны.
func TestPersonaLanguagesMatchHeader(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
dev := deviceOf(t, p)
tags := strings.Split(p.AcceptLanguage, ",")
for i, tag := range tags {
tags[i], _, _ = strings.Cut(tag, ";")
}
if len(dev.Languages) == 0 || dev.Languages[0] != dev.Language {
t.Fatalf("%s languages[0]=%v, want language %q", plat, dev.Languages, dev.Language)
}
for _, want := range dev.Languages {
if !slices.Contains(tags, want) {
t.Fatalf("%s language %q missing from Accept-Language %q", plat, want, p.AcceptLanguage)
}
}
if tags[0] != dev.Language {
t.Fatalf("%s Accept-Language starts with %q, want %q", plat, tags[0], dev.Language)
}
}
}
}
// Chrome использует один TLS-стек на всех платформах.
func TestClientProfileIsChrome146(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
got := For(plat, Identity{Seed: testSeed}).ClientProfile()
if got.GetClientHelloStr() != profiles.Chrome_146.GetClientHelloStr() {
t.Fatalf("For(%s).ClientProfile() = %s", plat, got.GetClientHelloStr())
}
}
}
@@ -0,0 +1,153 @@
package captcha
import (
neturl "net/url"
"regexp"
"strings"
"sync"
"sync/atomic"
fhttp "github.com/bogdanfinn/fhttp"
)
const (
assetsMaxParallel = 6
assetsMaxCount = 40
)
var (
reAssetScript = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
reAssetLink = regexp.MustCompile(`<link[^>]+>`)
reAssetHref = regexp.MustCompile(`href="([^"]+)"`)
reAssetRel = regexp.MustCompile(`rel="([^"]+)"`)
reAssetAs = regexp.MustCompile(`as="([^"]+)"`)
reAssetImg = regexp.MustCompile(`<img[^>]+src="([^"]+)"`)
)
type asset struct {
URL string
Dest string
}
func parsePageAssets(html string) []asset {
seen := map[string]struct{}{}
out := make([]asset, 0, assetsMaxCount)
add := func(raw, dest string) {
url := absoluteAssetURL(raw)
if url == "" || len(out) >= assetsMaxCount {
return
}
if _, dup := seen[url]; dup {
return
}
seen[url] = struct{}{}
out = append(out, asset{URL: url, Dest: dest})
}
for _, m := range reAssetScript.FindAllStringSubmatch(html, -1) {
add(m[1], "script")
}
for _, tag := range reAssetLink.FindAllString(html, -1) {
href := reAssetHref.FindStringSubmatch(tag)
if len(href) < 2 {
continue
}
add(href[1], linkDest(tag))
}
for _, m := range reAssetImg.FindAllStringSubmatch(html, -1) {
add(m[1], "image")
}
return out
}
func linkDest(tag string) string {
if as := reAssetAs.FindStringSubmatch(tag); len(as) > 1 {
return as[1]
}
rel := ""
if m := reAssetRel.FindStringSubmatch(tag); len(m) > 1 {
rel = m[1]
}
switch {
case strings.Contains(rel, "stylesheet"):
return "style"
case strings.Contains(rel, "icon"):
return "image"
default:
return "empty"
}
}
// Рекламные хосты сюда не входят: manual их блокирует и всё равно проходит.
var assetHostSuffixes = []string{".vk.com", ".vk.ru", ".userapi.com", ".okcdn.ru", ".mycdn.me"}
// Относительные пути пропускаются: статика раздаётся с CDN.
func absoluteAssetURL(raw string) string {
raw = strings.TrimSpace(raw)
switch {
case strings.HasPrefix(raw, "https://"):
case strings.HasPrefix(raw, "//"):
raw = "https:" + raw
default:
return ""
}
u, err := neturl.Parse(raw)
if err != nil {
return ""
}
host := u.Hostname()
for _, suffix := range assetHostSuffixes {
if strings.HasSuffix(host, suffix) || host == suffix[1:] {
return raw
}
}
return ""
}
func assetAccept(dest string) string {
switch dest {
case "style":
return "text/css,*/*;q=0.1"
case "image":
return "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
default:
return "*/*"
}
}
func (s *captchaSession) loadAssets(assets []asset) {
if len(assets) == 0 {
return
}
sem := make(chan struct{}, assetsMaxParallel)
var wg sync.WaitGroup
var loaded, failed atomic.Int32
for _, a := range assets {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
// Шрифт браузер тянет в cors, остальное - no-cors.
mode := "no-cors"
if a.Dest == "font" {
mode = "cors"
}
_, err := s.doRaw(fhttp.MethodGet, a.URL, nil, map[string]string{
"Accept": assetAccept(a.Dest),
"Sec-Fetch-Dest": a.Dest,
"Sec-Fetch-Mode": mode,
"Sec-Fetch-Site": "same-site",
"Referer": s.pageOrigin + "/",
})
if err != nil {
failed.Add(1)
} else {
loaded.Add(1)
}
})
}
wg.Wait()
s.logger().Debugf("[Captcha] assets loaded=%d failed=%d", loaded.Load(), failed.Load())
}
@@ -0,0 +1,68 @@
package captcha
import (
"strings"
"testing"
)
const assetsPageHTML = `<html><head>
<link rel="stylesheet" href="https://static.vk.ru/vkid/1.1.1391/main.css">
<link rel="preload" as="font" href="https://static.vk.ru/fonts/vk.woff2">
<link rel="icon" href="//static.vk.ru/favicon.ico">
<script src="https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js"></script>
<script src="https://static.vk.ru/vkid/1.1.1391/chunk.js"></script>
<script src="/local/inline.js"></script>
</head><body>
<img src="https://static.vk.ru/img/logo.png">
<img src="https://static.vk.ru/img/logo.png">
</body></html>`
func TestParsePageAssetsClassifiesDest(t *testing.T) {
got := parsePageAssets(assetsPageHTML)
want := map[string]string{
"https://static.vk.ru/vkid/1.1.1391/main.css": "style",
"https://static.vk.ru/fonts/vk.woff2": "font",
"https://static.vk.ru/favicon.ico": "image",
"https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js": "script",
"https://static.vk.ru/vkid/1.1.1391/chunk.js": "script",
"https://static.vk.ru/img/logo.png": "image",
}
if len(got) != len(want) {
t.Fatalf("assets = %d, want %d: %+v", len(got), len(want), got)
}
for _, a := range got {
if want[a.URL] != a.Dest {
t.Fatalf("%s dest = %q, want %q", a.URL, a.Dest, want[a.URL])
}
}
}
// Относительные пути виджета обслуживает не CDN, и абсолютного URL для них нет.
func TestParsePageAssetsDropsRelative(t *testing.T) {
for _, a := range parsePageAssets(assetsPageHTML) {
if a.URL == "/local/inline.js" {
t.Fatal("relative asset must be dropped")
}
}
}
// Рекламу manual блокирует и всё равно проходит - авто-путь не должен её тянуть.
func TestParsePageAssetsDropsForeignHosts(t *testing.T) {
html := `<script src="https://ad.mail.ru/static/sync-loader.js"></script>` +
`<script src="https://static.vk.ru/vkid/app.js"></script>`
got := parsePageAssets(html)
if len(got) != 1 || got[0].URL != "https://static.vk.ru/vkid/app.js" {
t.Fatalf("assets = %+v, want only the vk.ru script", got)
}
}
func TestParsePageAssetsCaps(t *testing.T) {
var html strings.Builder
for i := range assetsMaxCount + 20 {
html.WriteString(`<script src="https://static.vk.ru/` + string(rune('a'+i%26)) + string(rune('a'+i/26)) + `.js"></script>`)
}
if got := len(parsePageAssets(html.String())); got > assetsMaxCount {
t.Fatalf("assets = %d, want <= %d", got, assetsMaxCount)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,25 @@
package captcha
import (
"context"
"encoding/json"
"strings"
"errors"
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
// Значения query в лог не уезжают - в них session_token.
func TestSafeURL(t *testing.T) {
tests := []struct{ raw, want string }{
{"", "-"},
{"https://api.vk.ru/not_robot_captcha?variant=popup&domain=vk.com&session_token=jwt", "api.vk.ru/not_robot_captcha?domain,session_token,variant"},
{"https://id.vk.ru/", "id.vk.ru/"},
{"https://api.vk.ru", "api.vk.ru/"},
}
for _, tt := range tests {
if got := SafeURL(tt.raw); got != tt.want {
t.Errorf("SafeURL(%q) = %q, want %q", tt.raw, got, tt.want)
}
}
}
func TestCaptchaInitSettingContentRefPrefersSettingsKey(t *testing.T) {
setting := captchaInitSetting{
Type: "slider",
@@ -34,45 +45,6 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
}
}
func TestParseCaptchaPageSPA(t *testing.T) {
html := `<html><head><script>
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script></head><body><div id="spa_root"></div>
<script>window.vk = { statsMeta: {"hash":"39RzeEGG2Qq7t4YUwG"}, brlefapmjnpg: "1d958dbe-0663-42f2-95e5-0e2780ed2f93", id: 0 };</script>
</body></html>`
page, err := parseCaptchaPage(html)
if err != nil {
t.Fatal(err)
}
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
}
if page.DebugInfo != "1d958dbe-0663-42f2-95e5-0e2780ed2f93" {
t.Fatalf("debug_info = %q, want brlefapmjnpg uuid", page.DebugInfo)
}
}
func TestParseCaptchaPageMissingPoW(t *testing.T) {
if _, err := parseCaptchaPage(`<html><body><div id="spa_root"></div></body></html>`); err == nil {
t.Fatal("expected error when powInput/difficulty absent")
}
}
func TestSolveCaptchaPoWRawHex(t *testing.T) {
got := solveCaptchaPoW(context.Background(), "input", 1)
if len(got) != 64 {
t.Fatalf("pow = %q, want 64-hex", got)
}
if !strings.HasPrefix(got, "0") {
t.Fatalf("pow = %q, want leading zero for difficulty 1", got)
}
if again := solveCaptchaPoW(context.Background(), "input", 1); again != got {
t.Fatalf("pow not deterministic: %q vs %q", got, again)
}
}
func TestParseCaptchaInitSession(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"show_captcha_type": "slider",
@@ -141,56 +113,183 @@ func TestCaptchaDomainFromRedirectURI(t *testing.T) {
}
}
func TestCaptchaConnectionFieldsGatedByFamily(t *testing.T) {
for _, family := range []browserprofile.Kind{browserprofile.Firefox, browserprofile.Safari} {
if got := captchaConnectionFields(family, true); got != nil {
t.Fatalf("%s must omit NetworkInformation telemetry, got %v", family, got)
}
func TestPickSliderAttempts(t *testing.T) {
tests := []struct {
name string
indexes []int
limit int
want []int
}{
{
name: "skips neighbours of the top guess",
indexes: []int{20, 19, 21, 18, 40, 5},
limit: 2,
want: []int{20, 40},
},
{
name: "falls back to neighbours when nothing is far enough",
indexes: []int{20, 19, 21},
limit: 3,
want: []int{20, 19, 21},
},
{
name: "limit above candidate count",
indexes: []int{7},
limit: 4,
want: []int{7},
},
}
got := captchaConnectionFields(browserprofile.Chrome, true)
if len(got) != 2 || got[0][0] != "connectionRtt" || got[1][0] != "connectionDownlink" {
t.Fatalf("chrome connection fields = %v, want rtt+downlink", got)
}
// Реальный браузер шлёт фиксированную выборку, не привязанную к курсору.
var rtt []int
if err := json.Unmarshal([]byte(got[0][1]), &rtt); err != nil || len(rtt) != captchaConnectionSamples {
t.Fatalf("connectionRtt = %s, want %d samples", got[0][1], captchaConnectionSamples)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guesses := make([]sliderGuess, 0, len(tt.indexes))
for _, idx := range tt.indexes {
guesses = append(guesses, sliderGuess{Index: idx})
}
got := pickSliderAttempts(guesses, tt.limit)
if len(got) != len(tt.want) {
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
}
for i := range tt.want {
if got[i].Index != tt.want[i] {
t.Fatalf("attempts = %v, want %v", got, tt.want)
}
}
})
}
}
func TestCaptchaMobileAccelerometerIsGravity(t *testing.T) {
var pts []struct{ X, Y, Z float64 }
if err := json.Unmarshal([]byte(captchaMobileAccelerometer()), &pts); err != nil {
t.Fatal(err)
// VK раздаёт страницу то с id.vk.ru, то с api.vk.ru: Origin от одного хоста при
// странице на другом браузер выдать не может.
func TestAPIRequestHeadersFollowPageOrigin(t *testing.T) {
const pageQuery = "/not_robot_captcha?domain=vk.com&session_token=x&variant=popup"
cases := []struct {
name string
page string
apiHost string
wantSite string
wantRef string
wantOrig string
}{
{
name: "cross-origin page",
page: "https://id.vk.ru" + pageQuery,
apiHost: "api.vk.ru",
wantSite: "same-site",
wantRef: "https://id.vk.ru/",
wantOrig: "https://id.vk.ru",
},
{
name: "same-origin page keeps full url in referer",
page: "https://api.vk.ru" + pageQuery,
apiHost: "api.vk.ru",
wantSite: "same-origin",
wantRef: "https://api.vk.ru" + pageQuery,
wantOrig: "https://api.vk.ru",
},
}
if len(pts) != 3 {
t.Fatalf("samples = %d, want 3", len(pts))
}
// Магнитуда каждого сэмпла ~ g (покоящийся телефон).
for _, p := range pts {
mag := p.X*p.X + p.Y*p.Y + p.Z*p.Z
if mag < 64 || mag > 144 { // |a| в [8, 12]
t.Fatalf("accelerometer magnitude^2 = %.1f, not gravity-like", mag)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &captchaSession{apiHost: tc.apiHost}
s.setPageURL(tc.page)
got := s.apiRequestHeaders()
if got["Sec-Fetch-Site"] != tc.wantSite {
t.Fatalf("Sec-Fetch-Site = %q, want %q", got["Sec-Fetch-Site"], tc.wantSite)
}
if got["Referer"] != tc.wantRef {
t.Fatalf("Referer = %q, want %q", got["Referer"], tc.wantRef)
}
if got["Origin"] != tc.wantOrig {
t.Fatalf("Origin = %q, want %q", got["Origin"], tc.wantOrig)
}
})
}
}
func TestReverseSwapPairs(t *testing.T) {
got := reverseSwapPairs([]int{1, 2, 3, 4, 5, 6})
want := []int{5, 6, 3, 4, 1, 2}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d", len(got), len(want))
// Пока challenge есть в window.init, виджет не ходит в initSession - и мы тоже.
func TestParseCaptchaInitGlobal(t *testing.T) {
html := `<script>window.init = {"hosts":{"api":"api.vk.ru"},` +
`"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"a2V5"}]},` +
`"tail":{"brace":"}"}};</script>`
got := parseCaptchaInitGlobal(html)
if !got.Found || got.APIHost != "api.vk.ru" || got.ShowType != "slider" {
t.Fatalf("init = %+v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("reverseSwapPairs = %v, want %v", got, want)
}
if got.Content.Value != "a2V5" || got.Content.Source != "settings_key" {
t.Fatalf("content = %+v", got.Content)
}
}
func TestBuildSliderCursorIsBrowserLike(t *testing.T) {
cursor := buildSliderCursor(5, 25)
if got := captchaCursorPointCount(cursor); got < 70 {
t.Fatalf("cursor point count = %d, want at least 70", got)
// Без window.init виджет запрашивает initSession сам - и Found обязан быть false.
func TestParseCaptchaInitGlobalAbsent(t *testing.T) {
if got := parseCaptchaInitGlobal(`<script>var x = 1;</script>`); got.Found || got.APIHost != "" {
t.Fatalf("init = %+v, want empty", got)
}
}
// Битый redirect_uri не должен оставлять Origin пустым.
func TestSetPageURLFallsBackToWidgetOrigin(t *testing.T) {
s := &captchaSession{apiHost: "api.vk.ru"}
s.setPageURL("not-a-url")
if s.pageOrigin != captchaAPIOrigin || s.pageURL != captchaAPIOrigin+"/" {
t.Fatalf("page = %q / %q", s.pageOrigin, s.pageURL)
}
}
func TestParseCaptchaDebugInfo(t *testing.T) {
tests := []struct {
name, html, want string
}{
{
name: "renamed key",
html: `<script>window.vk = {stDomain: "https://st.vk.ru", qqqqqqqq: "273cc83f-426f-4d98-9ce5-92490107e3a6", id: 0};</script>`,
want: "273cc83f-426f-4d98-9ce5-92490107e3a6",
},
{
name: "quoted keys of nested json are not candidates",
html: `<script>window.vk = {statsMeta: {"hash":"X84u4GhF","uuid":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, k: "273cc83f-426f-4d98-9ce5-92490107e3a6"};</script>`,
want: "273cc83f-426f-4d98-9ce5-92490107e3a6",
},
{name: "no window.vk", html: `<script>var x = 1;</script>`},
{name: "no uuid in block", html: `<script>window.vk = {id: 0, logoutUrl: ""};</script>`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseCaptchaDebugInfo(tt.html); got != tt.want {
t.Fatalf("debug_info = %q, want %q", got, tt.want)
}
})
}
}
func TestParseCaptchaDebugInfoAmbiguous(t *testing.T) {
html := `<script>window.vk = {a: "273cc83f-426f-4d98-9ce5-92490107e3a6", b: "ec772ebb-0d69-4fa0-b974-904549c8a7d1"};</script>`
if got := parseCaptchaDebugInfo(html); got != "273cc83f-426f-4d98-9ce5-92490107e3a6" {
t.Fatalf("debug_info = %q", got)
}
}
func TestParseCaptchaPageRejectsNonCaptchaHTML(t *testing.T) {
t.Parallel()
_, err := parseCaptchaPage("<html><head><title>429 Too Many Requests</title></head><body></body></html>")
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}
// Смена обфускации - не аутаж: ошибка обязана дойти до эскалации на другой решатель.
// Переименованный конверт PoW - именно этот случай, страница настоящая.
func TestParseCaptchaPageBrokenParserIsNotUnavailable(t *testing.T) {
t.Parallel()
for name, html := range map[string]string{
"pow renamed": `<script>window.vk = {}; window.zzz = "v2." + solve();</script>`,
"debug absent": `<script>window.init = {};</script>`,
} {
_, err := parseCaptchaPage(html)
if err == nil || errors.Is(err, ErrUnavailable) {
t.Fatalf("%s: err = %v, want plain parse error", name, err)
}
}
}
@@ -23,14 +23,14 @@ type Error struct {
func ParseError(errData map[string]any) *Error {
codeFloat, ok := errData["error_code"].(float64)
if !ok {
Log.Warnf("[Captcha] missing error_code in error data")
Log().Warnf("[Captcha] missing error_code in error data")
return nil
}
code := int(codeFloat)
redirectURI, ok := errData["redirect_uri"].(string)
if !ok {
Log.Warnf("[Captcha] missing redirect_uri in error data")
Log().Warnf("[Captcha] missing redirect_uri in error data")
return nil
}
@@ -43,7 +43,7 @@ func ParseError(errData map[string]any) *Error {
errorMsg, ok := errData["error_msg"].(string)
if !ok {
Log.Warnf("[Captcha] missing error_msg in error data")
Log().Warnf("[Captcha] missing error_msg in error data")
return nil
}
@@ -52,7 +52,7 @@ func ParseError(errData map[string]any) *Error {
if parsed, err := neturl.Parse(redirectURI); err == nil {
sessionToken = parsed.Query().Get("session_token")
} else {
Log.Warnf("[Captcha] failed to parse redirect_uri: %v", err)
Log().Warnf("[Captcha] failed to parse redirect_uri: %v", err)
return nil
}
}
@@ -1,8 +1,3 @@
// Package manual реализует ручное решение CAPTCHA - fallback, когда автосолвер
// (internal/provider/vk/internal/captcha) не справился или пользователь дал -manual-captcha.
// Поднимает локальный HTTP-сервер 127.0.0.1:8765, проксирующий страницу VK
// CAPTCHA (с переписыванием абсолютных URL и gzip), открывает её в браузере и
// ждёт токен/ключ после решения пользователем.
package manual
import (
@@ -22,23 +17,26 @@ import (
"regexp"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/client/ish"
"github.com/samosvalishe/free-turn-proxy/internal/logx"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
)
// Debug включает подробное логирование request/response проксируемого
// браузерного трафика. Ставится из main после разбора cfg.
var Debug bool
// debugFlag включает логирование проксируемого браузерного трафика.
var debugFlag atomic.Bool
// Log - пакетный логгер; по умолчанию no-op. main устанавливает его через
// SetLogger, чтобы вывод подчинялся -debug.
var Log logx.Logger = logx.Nop()
func SetDebug(v bool) { debugFlag.Store(v) }
// SetLogger ставит логгер пакета.
func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
var logHolder logx.Holder
func SetLogger(l logx.Logger) { logHolder.Set(l) }
func Log() logx.Logger { return logHolder.Get() }
const captchaListenPort = "8765"
@@ -76,7 +74,22 @@ func localCaptchaHosts() []string {
}
}
// blockedProxyHosts - реклама и внешняя телеметрия. Всё, что не в allowed, и так
// не проксируется; список нужен для хостов под доменами VK, которые иначе прошли
// бы по суффиксу. Captcha без них решается: adFp тогда пустой, ровно как у
// браузера с блокировщиком.
var blockedProxyHosts = []string{
"ads.vk.com", "ads.vk.ru",
"top-fwz1.mail.ru", "r0.mradx.net",
"sdk-api.apptracer.ru", "stats.vk-portal.net",
}
func isAllowedProxyHost(hostname string) bool {
for _, blocked := range blockedProxyHosts {
if strings.EqualFold(hostname, blocked) {
return false
}
}
allowed := []string{
".vk.com", ".vk.ru", ".vkontakte.ru",
".userapi.com", ".okcdn.ru", ".mycdn.me",
@@ -322,18 +335,6 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
}
}
func newCaptchaProxyTransport(dialer net.Dialer) *http.Transport {
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: false,
DialContext: dialer.DialContext,
}
}
func startCaptchaServer(srv *http.Server, logPrefix string) error {
var listenErrs []string
var listening bool
@@ -347,12 +348,12 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error {
listening = true
wrappedListener, err := ish.WrapListener(listener)
if err != nil {
Log.Warnf("%s: failed to wrap listener for iSH: %v", logPrefix, err)
Log().Warnf("%s: failed to wrap listener for iSH: %v", logPrefix, err)
wrappedListener = listener
}
go func(listener net.Listener) {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
Log.Errorf("%s: %s", logPrefix, err)
Log().Errorf("%s: %s", logPrefix, err)
}
}(wrappedListener)
}
@@ -379,16 +380,12 @@ func runCaptchaServerAndWait(ctx context.Context, handler http.Handler, captchaU
shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
Log.Warnf("%s: shutdown warning: %v", logPrefix, err)
Log().Warnf("%s: shutdown warning: %v", logPrefix, err)
}
}()
fmt.Println("\n==============================================")
fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED")
fmt.Println("If your browser didn't open automatically,")
fmt.Println("manually open this URL: " + localCaptchaOrigin())
fmt.Println("==============================================")
fmt.Println()
Log().Warnf("%s: ACTION REQUIRED: manual captcha solving needed", logPrefix)
Log().Warnf("%s: if the browser did not open, go to %s", logPrefix, localCaptchaOrigin())
present(captchaURL)
@@ -412,6 +409,25 @@ func notifyKey(keyCh chan<- string, key string) {
type loggingTransport struct {
rt http.RoundTripper
// Открытие виджета: смещения от него дают человеческую раскладку пауз.
started time.Time
}
func (t *loggingTransport) elapsed() string {
return "+" + time.Since(t.started).Truncate(time.Millisecond).String()
}
// Конверт PoW живого браузера из тела check - эталон для телеметрии авторешателя.
func browserPowEnvelope(body []byte) string {
form, err := neturl.ParseQuery(string(body))
if err != nil {
return ""
}
hash := form.Get("hash")
if hash == "" {
return ""
}
return captcha.DecodePowEnvelope(hash)
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -420,66 +436,59 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
if isCaptchaRequest {
b, err := io.ReadAll(req.Body)
if err != nil {
Log.Warnf("[Captcha Proxy] failed to read request body: %v", err)
Log().Warnf("[Captcha Proxy] failed to read request body: %v", err)
b = nil
}
req.Body = io.NopCloser(bytes.NewReader(b))
if Debug {
Log.Debugf("[Captcha Proxy] real browser sent %s data: %s", req.URL.Path, string(b))
if debugFlag.Load() {
Log().Debugf("[Captcha Proxy] real browser sent %s data: %s", req.URL.Path, string(b))
if env := browserPowEnvelope(b); env != "" {
Log().Debugf("[Captcha Proxy] real browser pow: %s", env)
}
for k, v := range req.Header {
Log.Debugf("[Captcha Proxy] header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
}
}
if strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") || strings.Contains(req.URL.Path, "captchaNotRobot.check") {
parsedBody, err := neturl.ParseQuery(string(b))
if err != nil {
Log.Warnf("[Captcha Proxy] failed to parse request body: %v", err)
}
device := parsedBody.Get("device")
browserFp := parsedBody.Get("browser_fp")
// сохраняем только если есть device. componentDone обычно его содержит.
if device != "" && browserFp != "" {
sp := browserprofile.Saved{
Profile: browserprofile.Profile{
UserAgent: req.Header.Get("User-Agent"),
SecChUa: req.Header.Get("Sec-Ch-Ua"),
SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"),
SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"),
AcceptLanguage: req.Header.Get("Accept-Language"),
},
DeviceJSON: device,
BrowserFp: browserFp,
}
if err := browserprofile.Save(sp); err != nil {
Log.Warnf("[Captcha Proxy] failed to save browser profile: %v", err)
} else {
Log.Infof("[Captcha Proxy] saved real browser profile")
}
Log().Debugf("[Captcha Proxy] header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
}
}
}
return t.rt.RoundTrip(req)
start := time.Now()
resp, err := t.rt.RoundTrip(req)
// Весь трафик виджета, а не только check/componentDone: иначе не видно, зовёт
// ли живой браузер то, чего не зовём мы, и с какими паузами.
if err != nil {
Log().Debugf("[Captcha Proxy] http %s %s failed t=%s after=%s %s err=%v",
req.Method, captcha.SafeURL(req.URL.String()), t.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req), err)
return nil, err
}
Log().Debugf("[Captcha Proxy] http %s %s status=%d t=%s after=%s %s",
req.Method, captcha.SafeURL(req.URL.String()), resp.StatusCode, t.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req))
return resp, nil
}
func navSummary(req *http.Request) string {
return captcha.NavSummary(
req.Header.Get("Sec-Fetch-Dest"),
req.Header.Get("Sec-Fetch-Site"),
req.Header.Get("Referer"))
}
// SolveViaProxy проксирует VK redirect_uri через локальный HTTP-сервер,
// переписывая абсолютные URL так, чтобы браузер всё время оставался на
// 127.0.0.1:8765; возвращает результирующий auth-токен.
func SolveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer) (string, error) {
return solveViaProxy(ctx, redirectURI, dialer, openBrowser)
func SolveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile) (string, error) {
return solveViaProxy(ctx, redirectURI, dialer, profile, openBrowser)
}
// SolveViaProxyWithPresenter передаёт URL вызывающему после запуска сервера.
func SolveViaProxyWithPresenter(ctx context.Context, redirectURI string, dialer net.Dialer, present func(string)) (string, error) {
func SolveViaProxyWithPresenter(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile, present func(string)) (string, error) {
if present == nil {
present = func(string) {}
}
return solveViaProxy(ctx, redirectURI, dialer, present)
return solveViaProxy(ctx, redirectURI, dialer, profile, present)
}
func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, present func(string)) (string, error) {
func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile, present func(string)) (string, error) {
keyCh := make(chan string, 1)
targetURL, err := neturl.Parse(redirectURI)
@@ -487,7 +496,13 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := &loggingTransport{rt: newCaptchaProxyTransport(dialer)}
// Апстрим уходит с TLS-отпечатком персоны: браузерный UA поверх Go-JA3 сам по
// себе выдавал бы автоматизацию.
client, err := personanet.NewClient(profile, dialer, nil, personanet.NoFollowRedirects())
if err != nil {
return "", fmt.Errorf("captcha proxy client: %w", err)
}
transport := &loggingTransport{rt: personanet.ProxyRoundTripper(client), started: time.Now()}
proxy := &httputil.ReverseProxy{
Transport: transport,
@@ -495,7 +510,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
rewriteProxyRequest(req.Out, targetURL)
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
Log.Errorf("[Captcha Proxy] %s %s: %v", r.Method, r.URL.String(), err)
Log().Errorf("[Captcha Proxy] %s %s: %v", r.Method, r.URL.String(), err)
http.Error(w, "ошибка прокси капчи, детали в консоли клиента", http.StatusBadGateway)
},
ModifyResponse: func(res *http.Response) error {
@@ -514,8 +529,8 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding")
if Debug {
Log.Debugf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
if debugFlag.Load() {
Log().Debugf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
}
shouldInspectBody := strings.Contains(contentType, "text/html") ||
@@ -533,7 +548,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
reader = gzReader
defer func() {
if err := gzReader.Close(); err != nil {
Log.Warnf("[Captcha Proxy] close gzip reader: %v", err)
Log().Warnf("[Captcha Proxy] close gzip reader: %v", err)
}
}()
}
@@ -568,9 +583,11 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
}
bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL))
res.Header.Del("Content-Encoding")
}
// Тело отдаём разжатым независимо от типа: для JSON-ответа check
// оставшийся Content-Encoding ломал декодирование в браузере.
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)))
@@ -583,10 +600,10 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
token := r.FormValue("token")
if token != "" {
Log.Infof("[Captcha] received success token from browser (%d bytes)", len(token))
Log().Infof("[Captcha] received success token from browser (%d bytes)", len(token))
notifyKey(keyCh, token)
} else {
Log.Warnf("[Captcha] received empty token from browser")
Log().Warnf("[Captcha] received empty token from browser")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/plain")
@@ -649,7 +666,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Log.Debugf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
Log().Debugf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
// не логируем полный redirect URL - шум в консоли
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
@@ -95,3 +95,33 @@ func TestRewriteProxyRedirectLocation(t *testing.T) {
})
}
}
func TestIsAllowedProxyHost(t *testing.T) {
t.Parallel()
tests := []struct {
hostname string
want bool
}{
{"id.vk.ru", true},
{"api.vk.com", true},
{"vk.com", true},
{"sun9-1.userapi.com", true},
{"ads.vk.com", false},
{"ads.vk.ru", false},
{"top-fwz1.mail.ru", false},
{"sdk-api.apptracer.ru", false},
{"stats.vk-portal.net", false},
{"evil.com", false},
}
for _, tc := range tests {
t.Run(tc.hostname, func(t *testing.T) {
t.Parallel()
if got := isAllowedProxyHost(tc.hostname); got != tc.want {
t.Fatalf("isAllowedProxyHost(%q) = %v, want %v", tc.hostname, got, tc.want)
}
})
}
}
@@ -0,0 +1,387 @@
package captcha
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
const powResultGlobal = "captchaPowResult"
var (
rePowArgs = regexp.MustCompile(`\}\(\s*["']([A-Za-z0-9_-]{8,})["']\s*,\s*(\d+)\s*,\s*["'][^"']*["']\s*(?:,|\)\s*\))`)
rePowPrefix = regexp.MustCompile(powResultGlobal + `["'\]]{0,3}\s*=\s*["']([A-Za-z0-9._-]{0,8})["']\s*\+`)
)
type powParams struct {
Input string
Difficulty int
Prefix string
}
// powResult - конверт window.captchaPowResult: виджету уходит не голый хэш.
// Порядок полей повторяет порядок ключей у JSON.stringify страницы.
type powResult struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
DurationMs int64 `json:"duration_ms"`
Telemetry json.RawMessage `json:"telemetry"`
TelHash string `json:"tel_hash"`
}
func parsePowParams(html string) (powParams, error) {
m := rePowArgs.FindStringSubmatch(html)
if len(m) < 3 {
return powParams{}, errors.New("captcha pow args not found")
}
difficulty, err := strconv.Atoi(m[2])
if err != nil || difficulty <= 0 {
return powParams{}, fmt.Errorf("invalid captcha difficulty %q", m[2])
}
prefix := rePowPrefix.FindStringSubmatch(html)
if len(prefix) < 2 {
return powParams{}, errors.New("captcha pow envelope not recognized")
}
return powParams{Input: m[1], Difficulty: difficulty, Prefix: prefix[1]}, nil
}
func (s *captchaSession) powEnvelope(p powParams) (string, error) {
hash, nonce := solvePoW(s.ctx, p.Input, p.Difficulty)
if hash == "" {
return "", errors.New("captcha pow failed")
}
telemetry, err := marshalJS(s.powTelemetry())
if err != nil {
return "", fmt.Errorf("captcha pow telemetry: %w", err)
}
telHash, err := telemetryHash(telemetry)
if err != nil {
return "", fmt.Errorf("captcha pow tel_hash: %w", err)
}
envelope, err := marshalJS(powResult{
Hash: hash,
Nonce: nonce,
DurationMs: powDurationMs(nonce),
Telemetry: telemetry,
TelHash: telHash,
})
if err != nil {
return "", fmt.Errorf("captcha pow encode: %w", err)
}
// Печатаем до base64: диффать с живым браузером (см. DecodePowEnvelope) иначе нечем.
s.logger().Debugf("[Captcha] pow envelope: %s", envelope)
return p.Prefix + base64.StdEncoding.EncodeToString(envelope), nil
}
// DecodePowEnvelope разворачивает значение window.captchaPowResult обратно в JSON.
func DecodePowEnvelope(value string) string {
payload := value
if _, rest, ok := strings.Cut(value, "."); ok {
payload = rest
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return ""
}
return string(raw)
}
func solvePoW(ctx context.Context, input string, difficulty int) (string, int) {
if input == "" || difficulty <= 0 {
return "", 0
}
target := strings.Repeat("0", difficulty)
buf := make([]byte, 0, len(input)+20)
buf = append(buf, input...)
for nonce := 0; nonce <= 10_000_000; nonce++ {
if nonce&1023 == 0 {
select {
case <-ctx.Done():
return "", 0
default:
}
}
buf = strconv.AppendInt(buf[:len(input)], int64(nonce), 10)
sum := sha256.Sum256(buf)
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return hashHex, nonce
}
}
return "", 0
}
// Цикл в браузере синхронный: nonce 53 -> 1 мс, 267 -> 4 мс.
func powDurationMs(nonce int) int64 {
return max(1, int64(math.Round(float64(nonce+1)*0.015)))
}
// telemetryHash повторяет канонизатор страницы: ключи по алфавиту (map в Go
// сортирует их сам), undefined на нашей стороне не бывает.
func telemetryHash(telemetry []byte) (string, error) {
var v any
if err := json.Unmarshal(telemetry, &v); err != nil {
return "", err
}
canonical, err := marshalJS(v)
if err != nil {
return "", err
}
sum := sha256.Sum256(canonical)
return hex.EncodeToString(sum[:]), nil
}
// marshalJS - JSON без HTML-экранирования: JSON.stringify не трогает < > &.
func marshalJS(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
// powTelemetryData - пробы PoW-скрипта; порядок полей = порядок проб, так их
// пишет JSON.stringify.
type powTelemetryData struct {
Globals powProbe `json:"globals"`
UA powProbe `json:"ua"`
Frame powProbe `json:"frame"`
MatchMedia powProbe `json:"match_media"`
Plugins powProbe `json:"plugins"`
NavTamper powProbe `json:"nav_tamper"`
Referrer powProbe `json:"referrer"`
DevTools powProbe `json:"devtools"`
CSS powProbe `json:"css"`
NativeIntegrity powProbe `json:"native_integrity"`
CookieTest powProbe `json:"cookie_test"`
AncestorOrigins powProbe `json:"ancestor_origins"`
SandboxBehavior powProbe `json:"sandbox_behavior"`
MaxTouchPoints powProbe `json:"max_touch_points"`
TimezoneLocale powProbe `json:"timezone_locale"`
DevicePixelRatio powProbe `json:"device_pixel_ratio"`
}
type powProbe struct {
OK bool `json:"ok"`
Result any `json:"result"`
}
func probe(result any) powProbe { return powProbe{OK: true, Result: result} }
type powGlobals struct {
Doc bool `json:"doc"`
Win bool `json:"win"`
Nav bool `json:"nav"`
Webdriver bool `json:"webdriver"`
Subtle bool `json:"subtle"`
Secure bool `json:"secure"`
GCS bool `json:"gcs"`
RAF bool `json:"raf"`
Wasm bool `json:"wasm"`
PluginsLen int `json:"plugins_len"`
LanguagesLen int `json:"languages_len"`
HW int `json:"hw"`
Mem *int `json:"mem"`
}
type powUA struct {
UserAgent string `json:"userAgent"`
UserAgentData *powUAData `json:"userAgentData"`
}
type powUAData struct {
Brands []browserprofile.Brand `json:"brands"`
Platform string `json:"platform"`
Mobile bool `json:"mobile"`
Architecture *string `json:"architecture"`
}
type powFrame struct {
FrameElement *string `json:"frameElement"`
AncestorOriginsLen int `json:"ancestorOriginsLen"`
ParentAccessible bool `json:"parentAccessible"`
}
type powMatchMedia struct {
PrefersDark bool `json:"prefersDark"`
PrefersLight bool `json:"prefersLight"`
ReducedMotion bool `json:"reducedMotion"`
PointerFine bool `json:"pointerFine"`
}
type powPlugins struct {
Length int `json:"length"`
Names []string `json:"names"`
Descriptions []string `json:"descriptions"`
MimeTypes [][]string `json:"mimeTypes"`
IsChrome bool `json:"isChrome"`
}
type powNavTamper struct {
Tampered bool `json:"tampered"`
ElCtor string `json:"el_ctor"`
StyleCtor string `json:"style_ctor"`
NavCtor string `json:"nav_ctor"`
AlertNative bool `json:"alert_native"`
ToStringNative bool `json:"to_string_native"`
}
type powReferrer struct {
Referrer string `json:"referrer"`
InIframe bool `json:"inIframe"`
Domain string `json:"domain"`
}
type powDevTools struct {
Open bool `json:"open"`
DelayMs int `json:"delay_ms"`
}
type powCSS struct {
ExpectedMissing int `json:"expectedMissing"`
}
type powNativeIntegrity struct {
ProtoMatch bool `json:"protoMatch"`
XHRNative bool `json:"xhrNative"`
XHRSendNative bool `json:"xhrSendNative"`
AddEventListenerNative bool `json:"addEventListenerNative"`
AlertNative bool `json:"alertNative"`
ToStringNative bool `json:"toStringNative"`
}
type powCookieTest struct {
Write bool `json:"write"`
}
type powAncestorOrigins struct {
AncestorOrigin *string `json:"ancestorOrigin"`
}
type powSandboxBehavior struct {
OriginIsNull bool `json:"originIsNull"`
LocalStorage bool `json:"localStorage"`
SessionStorage bool `json:"sessionStorage"`
}
type powMaxTouchPoints struct {
MaxTouchPoints int `json:"maxTouchPoints"`
}
type powTimezoneLocale struct {
Timezone string `json:"timezone"`
Languages []string `json:"languages"`
}
type powDevicePixelRatio struct {
DPR float64 `json:"dpr"`
Orientation string `json:"orientation"`
OrientationAngle int `json:"orientationAngle"`
}
var chromePDFPlugins = []string{
"PDF Viewer",
"Chrome PDF Viewer",
"Chromium PDF Viewer",
"Microsoft Edge PDF Viewer",
"WebKit built-in PDF",
}
const chromePDFDescription = "Portable Document Format"
var chromePDFMimeTypes = []string{"application/pdf", "text/pdf"}
func chromePlugins() powPlugins {
out := powPlugins{
Length: len(chromePDFPlugins),
Names: chromePDFPlugins,
Descriptions: make([]string, len(chromePDFPlugins)),
MimeTypes: make([][]string, len(chromePDFPlugins)),
IsChrome: true,
}
for i := range chromePDFPlugins {
out.Descriptions[i] = chromePDFDescription
out.MimeTypes[i] = chromePDFMimeTypes
}
return out
}
// powTelemetry описывает страницу такой, какой её видел бы браузер персоны:
// страницу мы тянем верхнеуровневой навигацией (Sec-Fetch-Dest: document),
// поэтому frame и referrer описывают top-level документ - врозь их менять нельзя.
func (s *captchaSession) powTelemetry() powTelemetryData {
p := s.profile
// На Android нет встроенного PDF-вьювера, значит и списка плагинов.
plugins := powPlugins{Names: []string{}, Descriptions: []string{}, MimeTypes: [][]string{}}
if !p.IsMobile() {
plugins = chromePlugins()
}
dark := prefersDark(p)
return powTelemetryData{
Globals: probe(powGlobals{
Doc: true, Win: true, Nav: true,
Webdriver: p.Webdriver(),
Subtle: true,
Secure: true,
GCS: true,
RAF: true,
Wasm: true,
PluginsLen: plugins.Length,
LanguagesLen: len(p.Languages()),
HW: p.HardwareConcurrency(),
Mem: p.DeviceMemory(),
}),
UA: probe(powUA{
UserAgent: p.UserAgent,
UserAgentData: &powUAData{
Brands: p.Brands(),
Platform: p.PlatformName(),
Mobile: p.IsMobile(),
},
}),
Frame: probe(powFrame{ParentAccessible: true}),
MatchMedia: probe(powMatchMedia{PrefersDark: dark, PrefersLight: !dark, PointerFine: !p.IsMobile()}),
Plugins: probe(plugins),
NavTamper: probe(powNavTamper{
ElCtor: "HTMLDivElement", StyleCtor: "CSSStyleDeclaration", NavCtor: "Navigator",
AlertNative: true, ToStringNative: true,
}),
Referrer: probe(powReferrer{
Referrer: "https://" + s.domain + "/",
Domain: strings.TrimPrefix(s.pageOrigin, "https://"),
}),
DevTools: probe(powDevTools{}),
CSS: probe(powCSS{}),
NativeIntegrity: probe(powNativeIntegrity{
ProtoMatch: true, XHRNative: true, XHRSendNative: true,
AddEventListenerNative: true, AlertNative: true, ToStringNative: true,
}),
CookieTest: probe(powCookieTest{Write: true}),
AncestorOrigins: probe(powAncestorOrigins{}),
SandboxBehavior: probe(powSandboxBehavior{LocalStorage: true, SessionStorage: true}),
MaxTouchPoints: probe(powMaxTouchPoints{MaxTouchPoints: p.MaxTouchPoints()}),
TimezoneLocale: probe(powTimezoneLocale{Timezone: p.Timezone(), Languages: p.Languages()}),
DevicePixelRatio: probe(powDevicePixelRatio{
DPR: p.DevicePixelRatio(),
Orientation: p.Orientation(),
}),
}
}
// prefers-color-scheme у живого посетителя не скачет между сессиями - берём бит персоны.
func prefersDark(p browserprofile.Profile) bool {
return len(p.VisitorID) > 0 && p.VisitorID[0]%2 == 1
}
@@ -0,0 +1,169 @@
package captcha
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"strconv"
"strings"
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
// Живая страница captcha, снятая целиком: PoW-скрипт обфусцирован, поэтому
// контракт проверяется на настоящей разметке, а не на её пересказе. Обновлять
// фикстуру - task captcha:page.
func TestParseCaptchaPageFixture(t *testing.T) {
html, err := os.ReadFile("testdata/not_robot_captcha.html")
if err != nil {
t.Fatal(err)
}
page, err := parseCaptchaPage(string(html))
if err != nil {
t.Fatal(err)
}
if page.Pow.Input != "ST9BEClStK0wSdo4" || page.Pow.Difficulty != 2 || page.Pow.Prefix != "v2." {
t.Fatalf("pow = %+v", page.Pow)
}
if page.DebugInfo != "273cc83f-426f-4d98-9ce5-92490107e3a6" {
t.Fatalf("debug_info = %q", page.DebugInfo)
}
if page.Init.Found {
t.Fatalf("init = %+v", page.Init)
}
}
// Незнакомая разметка обязана ронять авторешение, а не уезжать в check заведомо
// неверным hash.
func TestParsePowParamsRejectsUnknownMarkup(t *testing.T) {
tests := []struct {
name string
html string
}{
{name: "no pow script", html: `<html><body><div id="spa_root"></div></body></html>`},
{
name: "args without envelope",
html: `<script>(function(a,b,c){})("fnZQN7lKKXvo37tH",2,"pow_timeout"));</script>`,
},
{
name: "envelope without args",
html: `<script>window['captchaPowResult']='v2.'+btoa(x);}(powInput,difficulty,"pow_timeout"));</script>`,
},
{
name: "zero difficulty",
html: `<script>window['captchaPowResult']='v2.'+btoa(x);}("fnZQN7lKKXvo37tH",0,"pow_timeout"));</script>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := parsePowParams(tt.html); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestParsePowParamsAcceptsAdditionalArgs(t *testing.T) {
html := `<script>window['captchaPowResult']='v2.'+btoa(x);}('fnZQN7lKKXvo37tH',2,'pow_timeout',["globals"]));</script>`
got, err := parsePowParams(html)
if err != nil {
t.Fatal(err)
}
if got.Input != "fnZQN7lKKXvo37tH" || got.Difficulty != 2 || got.Prefix != "v2." {
t.Fatalf("pow = %+v", got)
}
}
func TestSolvePoW(t *testing.T) {
got, nonce := solvePoW(context.Background(), "input", 1)
if len(got) != 64 || !strings.HasPrefix(got, "0") {
t.Fatalf("pow = %q, want 64-hex with leading zero", got)
}
sum := sha256.Sum256([]byte("input" + strconv.Itoa(nonce)))
if hex.EncodeToString(sum[:]) != got {
t.Fatalf("pow hash does not match sha256(input+nonce)")
}
}
// Эталон снят с персоны seed=fixture; tel_hash сверен с канонизатором самой
// страницы. Любая правка значений телеметрии обязана осознанно двигать эталон.
const (
goldenTelemetry = `{"globals":{"ok":true,"result":{"doc":true,"win":true,"nav":true,"webdriver":false,"subtle":true,"secure":true,"gcs":true,"raf":true,"wasm":true,"plugins_len":5,"languages_len":2,"hw":12,"mem":8}},` +
`"ua":{"ok":true,"result":{"userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","userAgentData":{"brands":[{"brand":"Google Chrome","version":"146"},{"brand":"Chromium","version":"146"},{"brand":"Not)A;Brand","version":"24"}],"platform":"Windows","mobile":false,"architecture":null}}},` +
`"frame":{"ok":true,"result":{"frameElement":null,"ancestorOriginsLen":0,"parentAccessible":true}},` +
`"match_media":{"ok":true,"result":{"prefersDark":true,"prefersLight":false,"reducedMotion":false,"pointerFine":true}},` +
`"plugins":{"ok":true,"result":{"length":5,"names":["PDF Viewer","Chrome PDF Viewer","Chromium PDF Viewer","Microsoft Edge PDF Viewer","WebKit built-in PDF"],"descriptions":["Portable Document Format","Portable Document Format","Portable Document Format","Portable Document Format","Portable Document Format"],"mimeTypes":[["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"]],"isChrome":true}},` +
`"nav_tamper":{"ok":true,"result":{"tampered":false,"el_ctor":"HTMLDivElement","style_ctor":"CSSStyleDeclaration","nav_ctor":"Navigator","alert_native":true,"to_string_native":true}},` +
`"referrer":{"ok":true,"result":{"referrer":"https://vk.com/","inIframe":false,"domain":"id.vk.ru"}},` +
`"devtools":{"ok":true,"result":{"open":false,"delay_ms":0}},` +
`"css":{"ok":true,"result":{"expectedMissing":0}},` +
`"native_integrity":{"ok":true,"result":{"protoMatch":true,"xhrNative":true,"xhrSendNative":true,"addEventListenerNative":true,"alertNative":true,"toStringNative":true}},` +
`"cookie_test":{"ok":true,"result":{"write":true}},` +
`"ancestor_origins":{"ok":true,"result":{"ancestorOrigin":null}},` +
`"sandbox_behavior":{"ok":true,"result":{"originIsNull":false,"localStorage":true,"sessionStorage":true}},` +
`"max_touch_points":{"ok":true,"result":{"maxTouchPoints":0}},` +
`"timezone_locale":{"ok":true,"result":{"timezone":"Europe/Moscow","languages":["ru-RU","en-US"]}},` +
`"device_pixel_ratio":{"ok":true,"result":{"dpr":1,"orientation":"landscape-primary","orientationAngle":0}}}`
goldenTelHash = "838102524be656361f2f88dba3dd16d3baaa20b5b33251b472b365ff646b99e9"
)
// Конверт целиком: порядок ключей, телеметрия персоны и её sha256 - всё, что VK
// проверяет на check.
func TestPowEnvelope(t *testing.T) {
s := &captchaSession{
ctx: context.Background(),
profile: browserprofile.For(browserprofile.Desktop, browserprofile.Identity{Seed: "fixture"}),
domain: "vk.com",
pageOrigin: "https://id.vk.ru",
}
got, err := s.powEnvelope(powParams{Input: "input", Difficulty: 1, Prefix: "v2."})
if err != nil {
t.Fatal(err)
}
payload, ok := strings.CutPrefix(got, "v2.")
if !ok {
t.Fatalf("envelope = %q, want v2. prefix", got)
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatal(err)
}
hash, nonce := solvePoW(context.Background(), "input", 1)
want := `{"hash":"` + hash + `","nonce":` + strconv.Itoa(nonce) +
`,"duration_ms":` + strconv.FormatInt(powDurationMs(nonce), 10) +
`,"telemetry":` + goldenTelemetry + `,"tel_hash":"` + goldenTelHash + `"}`
if string(raw) != want {
t.Fatalf("envelope =\n%s\nwant\n%s", raw, want)
}
}
func TestTelemetryHashCanonicalizesKeys(t *testing.T) {
// Тот же объект с переставленными ключами обязан дать тот же tel_hash.
shuffled := `{"b":{"y":1,"x":[2,{"n":null,"m":"s"}]},"a":true}`
ordered := `{"a":true,"b":{"x":[2,{"m":"s","n":null}],"y":1}}`
first, err := telemetryHash([]byte(shuffled))
if err != nil {
t.Fatal(err)
}
second, err := telemetryHash([]byte(ordered))
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("tel_hash = %s vs %s", first, second)
}
sum := sha256.Sum256([]byte(ordered))
if first != hex.EncodeToString(sum[:]) {
t.Fatalf("tel_hash = %s, want sha256 of canonical form", first)
}
}
+77 -279
View File
@@ -10,13 +10,11 @@ import (
"image/color"
_ "image/jpeg" // регистрация JPEG-декодера для image.Decode
"math"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
)
type sliderPuzzle struct {
@@ -32,17 +30,13 @@ type sliderGuess struct {
Score int64
ScoreRGB int64
ScoreLuma int64
ScoreReverse int64
ScoreText float64
ConsensusRank int
}
func (s *captchaSession) solveSliderCaptcha(
sessionToken string,
browserFP string,
hash string,
content captchaContentRef,
debugInfo string,
) (string, error) {
if content.Value == "" {
return "", errors.New("slider content settings missing")
@@ -71,49 +65,32 @@ func (s *captchaSession) solveSliderCaptcha(
return "", err
}
limit := puzzle.Attempts
if limit > len(guesses) {
limit = len(guesses)
}
limit := min(puzzle.Attempts, len(guesses))
if limit <= 0 {
return "", errors.New("slider has no attempts available")
}
attempts := pickSliderAttempts(guesses, limit)
s.logger().Debugf("[Captcha] slider guesses ranked: total=%d limit=%d", len(guesses), limit)
deviceJSON := captchaDeviceInfo
deviceSource := "default"
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
deviceSource = "saved"
}
s.logger().Debugf("[Captcha] slider componentDone device_source=%s device_bytes=%d", deviceSource, len(deviceJSON))
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", s.domain},
{"adFp", ""},
{"access_token", ""},
{"browser_fp", browserFP},
{"device", deviceJSON},
}); err != nil {
return "", fmt.Errorf("captcha componentDone failed: %w", err)
if err := s.sendComponentDone(sessionToken); err != nil {
return "", err
}
for i := 0; i < limit; i++ {
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
for i, guess := range attempts {
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, len(attempts), guess.Index)
answerData, err := json.Marshal(struct {
Value []int `json:"value"`
}{Value: guesses[i].Swaps})
}{Value: guess.Swaps})
if err != nil {
return "", err
}
check, err := s.performCaptchaCheck(
sessionToken,
browserFP,
hash,
string(answerData),
buildSliderCursor(guesses[i].Index, len(guesses)),
debugInfo,
)
// Протяжка ручки: координаты никуда не уходят, но время сессии задаёт
// длину массивов телеметрии.
if dragErr := s.dwell(650, 1750); dragErr != nil {
return "", dragErr
}
check, err := s.performCaptchaCheck(sessionToken, string(answerData))
if err != nil {
return "", err
}
@@ -128,6 +105,10 @@ func (s *captchaSession) solveSliderCaptcha(
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaRateLimit
}
// Виджет показал ошибку - человеку нужно время, чтобы это осознать.
if dwellErr := s.dwell(700, 1600); dwellErr != nil {
return "", dwellErr
}
}
return "", errors.New("slider guesses exhausted")
}
@@ -192,9 +173,12 @@ func splitSliderSteps(steps []int) (int, []int, int, error) {
tail := append([]int(nil), steps[1:]...)
attempts := 4
if len(tail)%2 != 0 {
// Штатный формат: [size, ...пары свапов, attempts].
attempts = tail[len(tail)-1]
tail = tail[:len(tail)-1]
Log.Warnf("[Captcha] slider payload had odd-length tail; fallback attempts=%d", attempts)
Log().Debugf("[Captcha] slider attempts from payload=%d", attempts)
} else {
Log().Debugf("[Captcha] slider payload without attempts counter; default=%d", attempts)
}
if attempts <= 0 {
attempts = 4
@@ -220,15 +204,6 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
}
guesses[idx-1] = sliderGuess{Index: idx, Swaps: active}
guesses[idx-1].ScoreLuma = seamScoreLuma(img, gridSize, mapping)
if len(active) > 2 {
reverseMapping, err := applySliderSwaps(gridSize, reverseSwapPairs(active))
if err != nil {
return nil, err
}
guesses[idx-1].ScoreReverse = seamScoreLuma(img, gridSize, reverseMapping)
} else {
guesses[idx-1].ScoreReverse = guesses[idx-1].ScoreLuma
}
}
lumaOrder := append([]sliderGuess(nil), guesses...)
@@ -243,69 +218,25 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
lumaRank[g.Index] = rank
}
stage2Count := candidateCount
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)
}
// Второй проход тяжелее первого - считаем параллельно, каждый в свою ячейку.
errs := make([]error, candidateCount)
var wg sync.WaitGroup
for range workers {
for i := range guesses {
wg.Go(func() {
for index := range jobCh {
mapping, err := applySliderSwaps(gridSize, guesses[index-1].Swaps)
if err != nil {
resCh <- stage2Result{index: index, err: err}
continue
}
rgb, text := seamScoreRGBText(img, gridSize, mapping)
resCh <- stage2Result{index: index, rgb: rgb, text: text}
mapping, err := applySliderSwaps(gridSize, guesses[i].Swaps)
if err != nil {
errs[i] = err
return
}
guesses[i].ScoreRGB, guesses[i].ScoreText = seamScoreRGBText(img, gridSize, mapping)
})
}
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
if err := errors.Join(errs...); err != nil {
return nil, err
}
stage2 := make([]sliderGuess, 0, stage2Count)
for _, g := range guesses {
if _, ok := stage2Set[g.Index]; ok {
stage2 = append(stage2, g)
}
}
rgbOrder := append([]sliderGuess(nil), stage2...)
rgbOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(rgbOrder, func(i, j int) bool {
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
return rgbOrder[i].Index < rgbOrder[j].Index
@@ -317,7 +248,7 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
rgbRank[g.Index] = rank
}
textOrder := append([]sliderGuess(nil), stage2...)
textOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(textOrder, func(i, j int) bool {
if textOrder[i].ScoreText == textOrder[j].ScoreText {
return textOrder[i].Index < textOrder[j].Index
@@ -329,26 +260,9 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
textRank[g.Index] = rank
}
reverseOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(reverseOrder, func(i, j int) bool {
if reverseOrder[i].ScoreReverse == reverseOrder[j].ScoreReverse {
return reverseOrder[i].Index < reverseOrder[j].Index
}
return reverseOrder[i].ScoreReverse < reverseOrder[j].ScoreReverse
})
reverseRank := make(map[int]int, len(reverseOrder))
for rank, g := range reverseOrder {
reverseRank[g.Index] = rank
}
for i := range guesses {
g := &guesses[i]
g.ConsensusRank = lumaRank[g.Index] + reverseRank[g.Index]
if _, ok := stage2Set[g.Index]; ok {
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
} else {
g.ConsensusRank += candidateCount
}
g.ConsensusRank = lumaRank[g.Index] + rgbRank[g.Index] + textRank[g.Index]
g.Score = int64(g.ConsensusRank)
}
@@ -364,10 +278,25 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
return guesses, nil
}
func reverseSwapPairs(swaps []int) []int {
out := make([]int, 0, len(swaps))
for i := len(swaps) - 2; i >= 0; i -= 2 {
out = append(out, swaps[i], swaps[i+1])
// pickSliderAttempts разносит попытки по номеру кандидата: соседние отличаются
// одним свапом и промахиваются вместе.
func pickSliderAttempts(guesses []sliderGuess, limit int) []sliderGuess {
const minGap = 3
out := make([]sliderGuess, 0, limit)
taken := func(index, gap int) bool {
return slices.ContainsFunc(out, func(g sliderGuess) bool {
return absInt(g.Index-index) < gap
})
}
for _, gap := range []int{minGap, 1} {
for _, g := range guesses {
if len(out) == limit {
return out
}
if !taken(g.Index, gap) {
out = append(out, g)
}
}
}
return out
}
@@ -376,10 +305,7 @@ func activeSwapsForIndex(swaps []int, index int) []int {
if index <= 0 {
return []int{}
}
end := index * 2
if end > len(swaps) {
end = len(swaps)
}
end := min(index*2, len(swaps))
return append([]int(nil), swaps[:end]...)
}
@@ -409,19 +335,15 @@ func applySliderSwaps(gridSize int, swaps []int) ([]int, error) {
func seamScoreLuma(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++ {
for row := range gridSize {
for col := range gridSize - 1 {
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++ {
for y := range min(leftDst.Dy(), rightDst.Dy()) {
yy := leftDst.Min.Y + y
a := sampleLumaMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
b := sampleLumaMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
@@ -429,19 +351,15 @@ func seamScoreLuma(img image.Image, gridSize int, mapping []int) int64 {
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
for row := range gridSize - 1 {
for col := range gridSize {
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++ {
for x := range min(topDst.Dx(), bottomDst.Dx()) {
xx := topDst.Min.X + x
a := sampleLumaMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleLumaMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
@@ -466,31 +384,24 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
}
weight := func(y int) float64 {
yf := float64(y)
best := absFloat(yf - textCenters[0])
for i := 1; i < len(textCenters); i++ {
d := absFloat(yf - textCenters[i])
if d < best {
best = d
}
best := math.Abs(yf - textCenters[0])
for _, center := range textCenters[1:] {
best = min(best, math.Abs(yf-center))
}
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++ {
for row := range gridSize {
for col := range gridSize - 1 {
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++ {
for y := range min(leftDst.Dy(), rightDst.Dy()) {
yy := leftDst.Min.Y + y
l := sampleColorMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
r := sampleColorMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
@@ -501,19 +412,15 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
for row := range gridSize - 1 {
for col := range gridSize {
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++ {
for x := range min(topDst.Dx(), bottomDst.Dx()) {
xx := topDst.Min.X + x
t := sampleColorMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleColorMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
@@ -527,37 +434,21 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
return rgbScore, textScore
}
func sampleColorMapped(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
}
func sampleColorMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) color.Color {
dx := max(dstRect.Dx(), 1)
dy := max(dstRect.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 sampleLumaMapped(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
func sampleLumaMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) uint8 {
c := sampleColorMapped(img, dstRect, srcRect, dstX, dstY)
r, g, b, _ := c.RGBA()
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
if y > 255 {
y = 255
}
y := min((299*(r>>8)+587*(g>>8)+114*(b>>8))/1000, 255)
return uint8(y) //nolint:gosec // bounded above by 255
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func absInt(v int) int {
if v < 0 {
return -v
@@ -565,7 +456,7 @@ func absInt(v int) int {
return v
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
func sliderTileRect(bounds image.Rectangle, gridSize, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
x0 := bounds.Min.X + col*bounds.Dx()/gridSize
@@ -587,96 +478,3 @@ func absDiff(left uint32, right uint32) int64 {
}
return int64(right - left)
}
func buildSliderCursor(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 + randx.Intn(40)
startY := 875 + randx.Intn(30)
denom := candidateCount - 1
if denom < 1 {
denom = 1
}
baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
targetX := baseTargetX + randx.Intn(10) - 5
targetY := 655 + randx.Intn(14)
points := make([]cursorPoint, 0, 128)
for i := 0; i < 4+randx.Intn(8); i++ {
points = append(points, cursorPoint{
X: startX + randx.Intn(5) - 2,
Y: startY + randx.Intn(5) - 2,
})
}
transitSteps := 32 + randx.Intn(26)
arcOffX := randx.Intn(60) - 30
arcOffY := -(randx.Intn(30) + 10)
for i := 1; i <= transitSteps; i++ {
t := easeInOut(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)*5) + 1
points = append(points, cursorPoint{
X: int(math.Round(bx)) + randx.Intn(jitter*2+1) - jitter,
Y: int(math.Round(by)) + randx.Intn(jitter*2+1) - jitter,
})
if i%9 == 0 && randx.Intn(3) == 0 {
last := points[len(points)-1]
points = append(points, last)
}
}
approachSteps := 16 + randx.Intn(14)
prev := points[len(points)-1]
for i := 1; i <= approachSteps; i++ {
t := easeOut(float64(i) / float64(approachSteps))
ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + randx.Intn(5) - 2
ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + randx.Intn(5) - 2
points = append(points, cursorPoint{X: ax, Y: ay})
}
settleCount := 18 + randx.Intn(24)
for i := 0; i < settleCount; i++ {
points = append(points, cursorPoint{
X: targetX + randx.Intn(7) - 3,
Y: targetY + randx.Intn(7) - 3,
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}
func easeInOut(t float64) float64 {
if t < 0.5 {
return 2 * t * t
}
p := -2*t + 2
return 1 - p*p/2
}
func easeOut(t float64) float64 {
p := 1 - t
return 1 - p*p*p
}
@@ -0,0 +1,110 @@
package captcha
import (
"encoding/json"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
)
const (
sensorDelayDefault = 100 * time.Millisecond
sensorDelayFloor = 20 * time.Millisecond
sensorDelayCeil = 2 * time.Second
// Виджет режет аккумулятор по 900 КБ, до такого объёма сессии не доживают.
sensorTicksMax = 600
emptyArray = "[]"
)
// sensorConfig - период таймера телеметрии; приходит из captchaNotRobot.settings.
type sensorConfig struct {
delay time.Duration
}
func defaultSensorConfig() sensorConfig {
return sensorConfig{delay: sensorDelayDefault}
}
func parseSensorConfig(raw map[string]any) sensorConfig {
cfg := defaultSensorConfig()
resp, ok := raw["response"].(map[string]any)
if !ok {
return cfg
}
if ms, hasDelay := resp["sensors_delay"].(float64); hasDelay && ms > 0 {
cfg.delay = min(max(time.Duration(ms)*time.Millisecond, sensorDelayFloor), sensorDelayCeil)
}
return cfg
}
// analytics - аккумулятор виджета; наполняется только downlink.
type analytics struct {
connDownlink []float64
}
// fields сохраняет порядок ключей виджета; ненаполняемые массивы уходят пустыми.
func (a analytics) fields() [][2]string {
return [][2]string{
{"accelerometer", emptyArray},
{"gyroscope", emptyArray},
{"motion", emptyArray},
{"cursor", emptyArray},
{"taps", emptyArray},
{"connectionRtt", emptyArray},
{"connectionDownlink", downlinkArray(a.connDownlink)},
}
}
func downlinkArray(values []float64) string {
if len(values) == 0 {
return emptyArray
}
data, err := json.Marshal(values)
if err != nil {
return emptyArray
}
return string(data)
}
// buildAnalytics: тиков столько, сколько таймер успел сделать за elapsed с
// момента ответа settings.
func buildAnalytics(cfg sensorConfig, downlink float64, elapsed time.Duration) analytics {
ticks := ticksIn(elapsed, cfg.delay)
if ticks == 0 {
return analytics{}
}
return analytics{connDownlink: sampleDownlink(downlink, ticks)}
}
func ticksIn(d time.Duration, delay time.Duration) int {
if d <= 0 || delay <= 0 {
return 0
}
return min(int(d/delay), sensorTicksMax)
}
// Оценка Chrome квантована шагом 0.05, считаем в этих шагах.
const (
downlinkMinSteps = 160 // 8.0
downlinkMaxSteps = 200 // 10.0
)
// sessionDownlink: у живого виджета значение постоянно ([10,10,10]), но жёсткая
// константа была бы отпечатком.
func sessionDownlink() float64 {
steps := downlinkMinSteps + randx.Intn(downlinkMaxSteps-downlinkMinSteps+1)
// Делим, а не умножаем на 0.05: иначе выходит соседний double и JSON печатает 9.200000000000001.
return float64(steps*5) / 100
}
func sampleDownlink(value float64, ticks int) []float64 {
if ticks <= 0 {
return nil
}
out := make([]float64, ticks)
for i := range out {
out[i] = value
}
return out
}
@@ -0,0 +1,107 @@
package captcha
import (
"encoding/json"
"testing"
"time"
)
// Эталон снят с успешного check живого браузера через manual-прокси: непустой
// там ровно один ключ.
func TestAnalyticsMatchesLiveBrowser(t *testing.T) {
want := [][2]string{
{"accelerometer", "[]"},
{"gyroscope", "[]"},
{"motion", "[]"},
{"cursor", "[]"},
{"taps", "[]"},
{"connectionRtt", "[]"},
{"connectionDownlink", "[10,10,10]"},
}
cfg := sensorConfig{delay: 200 * time.Millisecond}
got := buildAnalytics(cfg, 10, 600*time.Millisecond).fields()
if len(got) != len(want) {
t.Fatalf("%d fields, want %d", len(got), len(want))
}
for i, kv := range got {
if kv != want[i] {
t.Fatalf("field %d = %v, want %v", i, kv, want[i])
}
}
}
// Число сэмплов задаёт таймер виджета: сколько тиков уместилось в реальное время
// сессии, столько и точек.
func TestAnalyticsSampleCountFollowsElapsed(t *testing.T) {
cfg := sensorConfig{delay: 100 * time.Millisecond}
elapsed := 5 * time.Second
if got := len(buildAnalytics(cfg, 10, elapsed).connDownlink); got != int(elapsed/cfg.delay) {
t.Fatalf("downlink = %d, want %d", got, int(elapsed/cfg.delay))
}
}
// Сессия короче одного тика: массивы пустые, но ключи на месте.
func TestAnalyticsWithoutTicksIsEmpty(t *testing.T) {
cfg := sensorConfig{delay: 2 * time.Second}
for _, kv := range buildAnalytics(cfg, 10, 500*time.Millisecond).fields() {
if kv[1] != emptyArray {
t.Fatalf("field %q = %s, want []", kv[0], kv[1])
}
}
}
func TestParseSensorConfigFallsBackToDefaults(t *testing.T) {
cfg := parseSensorConfig(map[string]any{"error": map[string]any{"error_code": float64(5)}})
if cfg != defaultSensorConfig() {
t.Fatalf("sensor config = %+v, want defaults", cfg)
}
clamped := parseSensorConfig(map[string]any{"response": map[string]any{"sensors_delay": float64(1)}})
if clamped.delay != sensorDelayFloor {
t.Fatalf("delay = %s, want clamp to %s", clamped.delay, sensorDelayFloor)
}
}
func TestParseSensorConfigReadsDelay(t *testing.T) {
cfg := parseSensorConfig(map[string]any{"response": map[string]any{"sensors_delay": float64(250)}})
if cfg.delay != 250*time.Millisecond {
t.Fatalf("delay = %s, want 250ms", cfg.delay)
}
}
// Оценка не меняется за время captcha: замеры дали [10,10,10] и [9.6,9.6,9.6].
func TestDownlinkIsConstantWithinSession(t *testing.T) {
for _, v := range sampleDownlink(9.6, 16) {
if v != 9.6 {
t.Fatalf("downlink = %v, want 9.6", v)
}
}
}
// JSON.stringify печатает число кратчайшей записью; 9.200000000000001 в выдаче
// живого браузера невозможен и сам был бы маркером.
func TestSessionDownlinkSerializesShort(t *testing.T) {
for range 500 {
v := sessionDownlink()
if v < 8 || v > 10 {
t.Fatalf("downlink = %v, want [8, 10]", v)
}
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if len(raw) > 4 {
t.Fatalf("downlink serialized as %s, want at most 4 chars", raw)
}
}
}
func TestDownlinkArray(t *testing.T) {
if got := downlinkArray(nil); got != emptyArray {
t.Fatalf("downlinkArray(nil) = %q, want []", got)
}
if got := downlinkArray([]float64{10, 9.6}); got != "[10,9.6]" {
t.Fatalf("downlinkArray = %s, want [10,9.6]", got)
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
package personanet
import (
neturl "net/url"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
// cookieJar чинит удаление кук в tls-client: его фильтр смотрит только на
// Max-Age (jar.go, проверка Expires закомментирована), а VK гасит куки прошедшим
// Expires. Без этого remixir=DELETED уезжает в каждом запросе - живой браузер
// такую куку удаляет и не шлёт никогда.
type cookieJar struct {
inner tlsclient.CookieJar
}
func NewCookieJar() tlsclient.CookieJar {
return &cookieJar{inner: tlsclient.NewCookieJar()}
}
func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*fhttp.Cookie) {
now := time.Now()
for _, c := range cookies {
if c.MaxAge == 0 && !c.Expires.IsZero() && !c.Expires.After(now) {
c.MaxAge = -1
}
}
j.inner.SetCookies(u, cookies)
}
func (j *cookieJar) Cookies(u *neturl.URL) []*fhttp.Cookie { return j.inner.Cookies(u) }
func (j *cookieJar) GetAllCookies() map[string][]*fhttp.Cookie { return j.inner.GetAllCookies() }
@@ -0,0 +1,210 @@
// Package personanet собирает HTTP-клиент, чей TLS-отпечаток и заголовки
// принадлежат браузерной персоне. Один конструктор на всех потребителей:
// расхождение JA3 между control-plane и captcha само по себе сигнал бота.
package personanet
import (
"context"
"io"
"net"
"net/http"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"golang.org/x/net/proxy"
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
const (
// clientHelloSplitAt - фоллбэк-offset разбиения, когда SNI в ClientHello не
// распарсился. Совпадает с TURN STUN-split.
clientHelloSplitAt = 6
// clientHelloSplitDelay / clientHelloSplitJitter - пауза между сегментами
// ClientHello (base + случайная добавка [0,jitter)) для антифингерпринта тайминга.
clientHelloSplitDelay = 20 * time.Millisecond
clientHelloSplitJitter = 15 * time.Millisecond
clientTimeoutSeconds = 20
)
// splitDialer дробит первый Write результирующего conn (TLS ClientHello) по
// границам внутри SNI host_name для обхода SNI-based DPI RST. Реализует
// proxy.ContextDialer - tls-client берёт его через WithProxyDialerFactory как
// прямой (не прокси) дилер.
type splitDialer struct {
base net.Dialer
}
func (d *splitDialer) Dial(network, addr string) (net.Conn, error) {
return d.DialContext(context.Background(), network, addr)
}
func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := d.base.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
return &netconn.MultiSplitWriteConn{
Conn: c,
Delay: clientHelloSplitDelay,
Jitter: clientHelloSplitJitter,
FallbackSplitAt: clientHelloSplitAt,
}, nil
}
type Option func(*options)
type options struct {
noFollowRedirects bool
}
// NoFollowRedirects нужен прокси-режиму: по редиректам ходит браузер, иначе
// rewrite Location мёртв, а Set-Cookie промежуточных хопов теряются.
func NoFollowRedirects() Option {
return func(o *options) { o.noFollowRedirects = true }
}
// NewClient строит tls-client с ClientHello персоны поверх dialer. jar может быть
// nil, если куки ведёт вызывающий.
func NewClient(profile browserprofile.Profile, dialer net.Dialer, jar tlsclient.CookieJar, extra ...Option) (tlsclient.HttpClient, error) {
var o options
for _, apply := range extra {
apply(&o)
}
opts := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(clientTimeoutSeconds),
tlsclient.WithClientProfile(profile.ClientProfile()),
tlsclient.WithProxyDialerFactory(func(_ string, timeout time.Duration, localAddr *net.TCPAddr, _ fhttp.Header, _ tlsclient.Logger) (proxy.ContextDialer, error) {
base := dialer
base.Timeout = timeout
if localAddr != nil {
base.LocalAddr = localAddr
}
return &splitDialer{base: base}, nil
}),
}
if jar != nil {
opts = append(opts, tlsclient.WithCookieJar(jar))
}
if o.noFollowRedirects {
opts = append(opts, tlsclient.WithNotFollowRedirects())
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), opts...)
}
// RoundTripper подаёт net/http-запросы (httputil.ReverseProxy) через клиент
// персоны: иначе апстрим уходил бы с Go-отпечатком под браузерным User-Agent.
func RoundTripper(client tlsclient.HttpClient, profile browserprofile.Profile) http.RoundTripper {
return &personaRoundTripper{client: client, profile: profile}
}
type personaRoundTripper struct {
client tlsclient.HttpClient
profile browserprofile.Profile
}
func (rt *personaRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
var body io.Reader
if req.Body != nil {
body = req.Body
}
out, err := fhttp.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), body)
if err != nil {
return nil, err
}
out.Host = req.Host
for name, values := range req.Header {
// Сжатие ведёт tls-client: он же и разожмёт ответ.
if http.CanonicalHeaderKey(name) == "Accept-Encoding" {
continue
}
for _, v := range values {
out.Header.Add(name, v)
}
}
browserprofile.ApplyFhttp(out, rt.profile)
resp, err := rt.client.Do(out)
if err != nil {
return nil, err
}
header := make(http.Header, len(resp.Header))
for name, values := range resp.Header {
header[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
}
// По HTTP/2 fhttp разжимает тело, но заголовки сжатия не чистит (в отличие от
// HTTP/1.1-пути) - иначе потребитель режет тело по сжатой длине.
if resp.Uncompressed {
header.Del("Content-Encoding")
header.Del("Content-Length")
}
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor,
ProtoMinor: resp.ProtoMinor,
Header: header,
Body: resp.Body,
ContentLength: resp.ContentLength,
Request: req,
}, nil
}
func ProxyRoundTripper(client tlsclient.HttpClient) http.RoundTripper {
return &proxyRoundTripper{client: client}
}
type proxyRoundTripper struct {
client tlsclient.HttpClient
}
func (rt *proxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
var body io.Reader
if req.Body != nil {
body = req.Body
}
out, err := fhttp.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), body)
if err != nil {
return nil, err
}
out.Host = req.Host
for name, values := range req.Header {
if http.CanonicalHeaderKey(name) == "Accept-Encoding" {
continue
}
for _, v := range values {
out.Header.Add(name, v)
}
}
// Важное отличие: задаём только порядок, не трогаем User-Agent
browserprofile.ApplyProxyFhttp(out)
resp, err := rt.client.Do(out)
if err != nil {
return nil, err
}
header := make(http.Header, len(resp.Header))
for name, values := range resp.Header {
header[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
}
if resp.Uncompressed {
header.Del("Content-Encoding")
header.Del("Content-Length")
}
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor,
ProtoMinor: resp.ProtoMinor,
Header: header,
Body: resp.Body,
ContentLength: resp.ContentLength,
Request: req,
}, nil
}
@@ -3,7 +3,6 @@ package vkauth
import (
"context"
"fmt"
"strings"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
@@ -11,15 +10,15 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
func (c *Client) defaultAutoSolve(
func (*Client) defaultAutoSolve(
ctx context.Context,
captchaErr *captcha.Error,
streamID int,
client tlsclient.HttpClient,
profile browserprofile.Profile,
) (string, error) {
log := captcha.Log
log.Infof("[STREAM %d] [Captcha] Solving captcha...", streamID)
log := captcha.Log()
log.Infof("[STREAM %d] [Captcha] Solving captcha (platform=%s)...", streamID, profile.Platform)
if captchaErr.SessionToken == "" {
return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
@@ -28,45 +27,10 @@ func (c *Client) defaultAutoSolve(
return "", fmt.Errorf("no redirect_uri for auto-solve")
}
var savedProfile *browserprofile.Saved
switch sp, err := browserprofile.Load(); {
case err == nil && savedProfileUsable(*sp):
savedFamily := browserprofile.Family(sp.Profile)
log.Infof("[STREAM %d] [Captcha] Using captured browser profile (authority) family=%s mobile=%t ua=%q device=%t browser_fp=%t",
streamID, savedFamily, browserprofile.IsMobile(sp.Profile), sp.UserAgent, sp.DeviceJSON != "", sp.BrowserFp != "")
savedProfile = sp
profile = sp.Profile
if savedFamily != "" && normalizeFamily(savedFamily) != normalizeFamily(c.browser) {
if rebuilt, rebuildErr := c.newTLSClientForKind(savedFamily, tlsclient.NewCookieJar()); rebuildErr == nil {
client = rebuilt
log.Infof("[STREAM %d] [Captcha] Rebuilt TLS client to match captured family=%s", streamID, savedFamily)
} else {
log.Warnf("[STREAM %d] [Captcha] Failed to rebuild TLS for captured family=%s: %v; keeping -browser TLS", streamID, savedFamily, rebuildErr)
}
}
case err == nil:
log.Warnf("[STREAM %d] [Captcha] Captured profile unusable (empty UA); using -browser fallback", streamID)
default:
log.Debugf("[STREAM %d] [Captcha] No captured browser profile: %v", streamID, err)
}
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, savedProfile, log)
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, log)
if err != nil {
return "", err
}
log.Infof("[STREAM %d] [Captcha] solver succeeded", streamID)
return successToken, nil
}
func savedProfileUsable(saved browserprofile.Saved) bool {
return strings.TrimSpace(saved.UserAgent) != ""
}
func normalizeFamily(k browserprofile.Kind) browserprofile.Kind {
switch k {
case browserprofile.Firefox, browserprofile.Safari:
return k
default:
return browserprofile.Chrome
}
}
+37 -17
View File
@@ -1,21 +1,37 @@
package vkauth
import (
"strings"
"errors"
"sync"
"sync/atomic"
"time"
"github.com/pion/stun/v3"
)
// StreamCredentialsCache хранит TURN-реквизиты для группы потоков
// и счётчик ошибок для решений об инвалидации.
type StreamCredentialsCache struct {
creds TurnCredentials
mutex sync.RWMutex
fetchMu sync.Mutex
errorCount atomic.Int32
lastErrorTime atomic.Int64
}
// Store отображает cache-id ((streamID-1) / streamsPerCache) -> StreamCredentialsCache.
func (c *StreamCredentialsCache) lookup(link string, streamID int) (TurnCredentials, []string, bool) {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.creds.Link != link || !time.Now().Before(c.creds.ExpiresAt) || len(c.creds.ServerAddrs) == 0 {
return TurnCredentials{}, nil, false
}
return c.creds, orderAddrs(c.creds.ServerAddrs, streamID), true
}
func (c *StreamCredentialsCache) store(creds TurnCredentials) {
c.mutex.Lock()
c.creds = creds
c.mutex.Unlock()
}
type Store struct {
mu sync.RWMutex
caches map[int]*StreamCredentialsCache
@@ -33,9 +49,8 @@ func NewStore(streamsPerCache int) *Store {
}
// CacheID группирует потоки в блоки по streamsPerCache: потоки 1..streamsPerCache
// делят один кэш реквизитов, streamsPerCache+1.. - следующий. streamID 1-based
// (единый базис udprelay и tcpfwd); первый поток блока инициирует fetch к VK,
// остальные переиспользуют тёплый кэш.
// делят один кэш реквизитов, streamsPerCache+1.. - следующий. streamID 1-based;
// первый поток блока инициирует fetch к VK, остальные переиспользуют тёплый кэш.
func (s *Store) CacheID(streamID int) int {
if streamID < 1 {
return 0
@@ -64,26 +79,31 @@ func (s *Store) Get(streamID int) *StreamCredentialsCache {
return cache
}
// Invalidate сбрасывает реквизиты кэша потока и обнуляет счётчик ошибок.
func (c *StreamCredentialsCache) Invalidate() {
func (c *StreamCredentialsCache) Invalidate() bool {
c.mutex.Lock()
had := c.creds.Username != ""
c.creds = TurnCredentials{}
c.mutex.Unlock()
c.errorCount.Store(0)
c.lastErrorTime.Store(0)
return had
}
// IsAuthError проверяет ошибку по эвристике TURN-клиента:
// auth/401/stale-nonce - признак инвалидации кэша.
func IsAuthError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "401") ||
strings.Contains(s, "Unauthorized") ||
strings.Contains(s, "authentication") ||
strings.Contains(s, "invalid credential") ||
strings.Contains(s, "stale nonce")
// Ответ TURN-сервера приходит типизированным - код берём из него, а не из текста.
if turnErr, ok := errors.AsType[*stun.TurnError](err); ok {
// 486 означает занятую квоту, а не невалидные реквизиты.
switch turnErr.ErrorCodeAttr.Code {
case stun.CodeUnauthorized, stun.CodeWrongCredentials, stun.CodeStaleNonce:
return true
default:
return false
}
}
return errors.Is(err, ErrVKAuthFailed)
}
+101 -90
View File
@@ -5,59 +5,38 @@ import (
"errors"
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/logx"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
tlsclient "github.com/bogdanfinn/tls-client"
)
// Config конфигурирует Client. Нулевые значения - безопасные дефолты,
// кроме Dialer (должен быть задан явно для кастомного DNS).
type Config struct {
// Credentials для перебора по порядку. nil/empty -> DefaultCredentials.
Credentials []VKCredentials
// Dialer для HTTP-транспорта запросов к VK API.
Dialer net.Dialer
// ManualOnly форсирует ручной путь captcha с первой попытки.
ManualOnly bool
// StreamsPerCache - делитель streamID -> cacheID. <=0 -> дефолт.
Credentials []VKCredentials
Dialer net.Dialer
ManualOnly bool
StreamsPerCache int
// StreamsAlive возвращает число подключённых потоков; используется для
// решения, является ли исчерпанная captcha фатальной или только throttle.
// nil -> 1.
StreamsAlive func() int32
// AutoSolver / ManualSolver - подключаемые решалки captcha. nil отключает
// соответствующий путь (поток переходит к следующей попытке).
AutoSolver AutoSolveFunc
ManualSolver ManualSolveFunc
// Browser - браузерный профиль (UA + JA3 + client hints) для control-plane.
// Нулевое значение -> Chrome (исторический дефолт для прямых вызовов в тестах).
Browser browserprofile.Kind
// Log - уровневый логгер. nil -> no-op.
Log logx.Logger
StreamsAlive func() int32
AutoSolver AutoSolveFunc
ManualSolver ManualSolveFunc
Platform browserprofile.Platform
FingerprintSeed string
StatePaths []string
Log logx.Logger
}
// Client - фасад VK-аутентификации и кэша реквизитов. Владеет кэшем группы
// потоков, глобальным throttle запросов, таймером блокировки captcha и счётчиком
// ошибок аутентификации для инвалидации устаревших TURN-реквизитов.
type Client struct {
credentials []VKCredentials
dialer net.Dialer
manualOnly bool
browser browserprofile.Kind
platform browserprofile.Platform
streamsFn func() int32
autoSolver AutoSolveFunc
manualSolve ManualSolveFunc
@@ -67,14 +46,15 @@ type Client struct {
lockout atomic.Int64
fetchMu sync.Mutex
lastFetchTime time.Time
personaMu sync.RWMutex
identity browserprofile.Identity
persona browserprofile.Profile
gens genStore
// tokenChain - 4-шаговый получатель токена для пары credentials.
// В prod подключён (*Client).getTokenChain; тесты подменяют fake.
tokenChain tokenChainFn
// minFetchIntervalFn ограничивает частоту запросов к VK. Тесты снижают.
fetchMu sync.Mutex
lastFetchTime time.Time
captchaAttempt int
tokenChain tokenChainFn
minFetchIntervalFn func() time.Duration
}
@@ -85,7 +65,7 @@ func New(cfg Config) *Client {
credentials: cfg.Credentials,
dialer: cfg.Dialer,
manualOnly: cfg.ManualOnly,
browser: cfg.Browser,
platform: cfg.Platform,
streamsFn: cfg.StreamsAlive,
autoSolver: cfg.AutoSolver,
manualSolve: cfg.ManualSolver,
@@ -105,33 +85,53 @@ func New(cfg Config) *Client {
c.minFetchIntervalFn = func() time.Duration {
return 3*time.Second + time.Duration(randx.Intn(3000))*time.Millisecond
}
seed := cfg.FingerprintSeed
if seed == "" {
seed = randx.Hex(16)
}
c.gens = genStore{paths: cfg.StatePaths}
c.identity = browserprofile.Identity{Seed: seed, Gen: c.gens.load(seed)}
c.persona = browserprofile.For(c.platform, c.identity)
if c.identity.Gen > 0 {
c.log.Debugf("[VK Auth] Persona gen=%d restored | User-Agent: %s", c.identity.Gen, c.persona.UserAgent)
}
return c
}
// GetCredentials возвращает (username, password, server-addrs) для TURN-allocate,
// обращаясь к VK (с throttle + кэшем) только при необходимости. Адреса отдаются
// в порядке предпочтения для streamID (предпочтительный первым) - pipeline
// пробует их по очереди при allocate.
func (c *Client) currentPersona() browserprofile.Profile {
c.personaMu.RLock()
defer c.personaMu.RUnlock()
return c.persona
}
func (c *Client) burnPersona(streamID int) {
c.personaMu.Lock()
c.identity.Gen++
c.persona = browserprofile.For(c.platform, c.identity)
ua, gen, seed := c.persona.UserAgent, c.identity.Gen, c.identity.Seed
c.personaMu.Unlock()
c.log.Infof("[STREAM %d] [VK Auth] Persona burned, gen=%d | User-Agent: %s", streamID, gen, ua)
if !c.gens.save(seed, gen) && len(c.gens.paths) > 0 {
c.log.Warnf("[STREAM %d] [VK Auth] Persona gen not persisted (%v) - burned fingerprint returns after restart", streamID, c.gens.paths)
}
}
// GetCredentials возвращает учетные данные TURN, используя кеш или запрашивая их у VK.
func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) (string, string, []string, error) {
cache := c.store.Get(streamID)
cacheID := c.store.CacheID(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
addrs := orderAddrs(cache.creds.ServerAddrs, streamID)
cache.mutex.RUnlock()
c.log.Debugf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)", streamID, cacheID, expires, addrs[0])
return u, p, addrs, nil
if creds, addrs, ok := cache.lookup(link, streamID); ok {
c.log.Debugf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)",
streamID, cacheID, time.Until(creds.ExpiresAt), addrs[0])
return creds.Username, creds.Password, addrs, nil
}
cache.mutex.RUnlock()
cache.mutex.Lock()
defer cache.mutex.Unlock()
cache.fetchMu.Lock()
defer cache.fetchMu.Unlock()
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
return cache.creds.Username, cache.creds.Password, orderAddrs(cache.creds.ServerAddrs, streamID), nil
if creds, addrs, ok := cache.lookup(link, streamID); ok {
return creds.Username, creds.Password, addrs, nil
}
user, pass, addrs, err := c.fetchSerialized(ctx, link, streamID)
@@ -139,20 +139,16 @@ func (c *Client) GetCredentials(ctx context.Context, link string, streamID int)
return "", "", nil, err
}
cache.creds = TurnCredentials{
cache.store(TurnCredentials{
Username: user,
Password: pass,
ServerAddrs: addrs,
ExpiresAt: time.Now().Add(CredentialLifetime - CacheSafetyMargin),
ExpiresAt: time.Now().Add(CredentialLifetime - CacheSafetyMargin).Round(0),
Link: link,
}
})
return user, pass, orderAddrs(addrs, streamID), nil
}
// orderAddrs возвращает копию addrs, ротированную так, что предпочтительный
// для streamID адрес стоит первым, остальные - следом (сохраняя порядок).
// Раскидывает primary по стримам (балансировка relay-IP), оставляя остальные
// как фоллбэк при DPI-дропе.
func orderAddrs(addrs []string, streamID int) []string {
n := len(addrs)
if n <= 1 {
@@ -165,9 +161,6 @@ func orderAddrs(addrs []string, streamID int) []string {
return out
}
// HandleAuthError увеличивает счётчик ошибок аутентификации кэша потока и
// инвалидирует кэш при достижении порога внутри скользящего окна.
// Возвращает true при инвалидации.
func (c *Client) HandleAuthError(streamID int) bool {
cache := c.store.Get(streamID)
cacheID := c.store.CacheID(streamID)
@@ -190,35 +183,36 @@ func (c *Client) HandleAuthError(streamID int) bool {
return false
}
// ResetErrors обнуляет счётчик ошибок аутентификации (вызывать при успешном allocate).
func (c *Client) ResetErrors(streamID int) {
c.store.Get(streamID).errorCount.Store(0)
}
// LockoutUntilUnix возвращает unix-секунду дедлайна глобальной блокировки captcha
// или 0, если блокировки нет.
func (c *Client) DropCredentials(streamID int) {
if !c.store.Get(streamID).Invalidate() {
return
}
c.log.Warnf("[STREAM %d] [VK Auth] Deallocate unconfirmed - credentials dropped (cache=%d)",
streamID, c.store.CacheID(streamID))
}
func (c *Client) LockoutUntilUnix() int64 {
return c.lockout.Load()
}
// BackoffUntilUnix реализует provider.Provider - алиас LockoutUntilUnix.
// vkauth-lockout глобальный (без per-stream), streamID-параметра в самом
// методе нет; интерфейс provider.Provider определяет no-arg сигнатуру.
// BackoffUntilUnix - алиас LockoutUntilUnix: lockout глобальный, а provider.Provider
// требует no-arg сигнатуру (без streamID).
func (c *Client) BackoffUntilUnix() int64 { return c.LockoutUntilUnix() }
// Name реализует provider.Provider.
func (*Client) Name() string { return "vk" }
// IsAuthError оборачивает пакетный IsAuthError как метод для работы через интерфейс.
func (*Client) IsAuthError(err error) bool { return IsAuthError(err) }
// engageLockout устанавливает глобальную блокировку captcha на d с момента вызова.
func (c *Client) engageLockout(d time.Duration) {
c.lockout.Store(time.Now().Add(d).Unix())
}
// fetchSerialized соблюдает интервал 3s+jitter между запросами, затем
// выполняет перебор всех credentials.
// fetchSerialized сериализует fetch и держит min-интервал между запросами (анти
// rate-limit VK).
func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) {
c.fetchMu.Lock()
defer c.fetchMu.Unlock()
@@ -234,36 +228,53 @@ func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int)
case <-time.After(wait):
}
}
defer func() { c.lastFetchTime = time.Now() }()
return c.fetch(ctx, link, streamID)
user, pass, addrs, err := c.fetch(ctx, link, streamID)
if ctx.Err() == nil {
c.lastFetchTime = time.Now()
}
return user, pass, addrs, err
}
// fetch перебирает c.credentials, возвращая первый успех или терминальную ошибку.
func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, string, []string, error) {
if time.Now().Unix() < c.lockout.Load() {
return "", "", nil, fmt.Errorf("%w: %w", ErrCaptchaWaitRequired, ErrLockoutActive)
}
c.captchaAttempt = 0
var lastErr error
jar := tlsclient.NewCookieJar()
for _, creds := range c.credentials {
c.log.Infof("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
burns := 0
jar := personanet.NewCookieJar()
for i := 0; i < len(c.credentials); {
creds := c.credentials[i]
c.log.Debugf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
user, pass, addrs, err := c.tokenChain(ctx, link, streamID, creds, jar)
if err == nil {
c.log.Infof("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
c.log.Debugf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
return user, pass, addrs, nil
}
lastErr = err
if ctx.Err() != nil {
return "", "", nil, err
}
c.log.Warnf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
// Личность сменилась - тот же client_id проходится заново с чистыми
// куками, пока не кончатся режимы решения captcha.
if errors.Is(err, ErrPersonaBurned) && burns < maxPersonaBurns {
burns++
jar = personanet.NewCookieJar()
continue
}
i++
if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) ||
errors.Is(err, ErrInvalidJoinLink) || errors.Is(err, ErrAnonymousBlocked) ||
errors.Is(err, ErrCallFull) {
errors.Is(err, ErrCallFull) || errors.Is(err, captcha.ErrUnavailable) {
return "", "", nil, err
}
es := err.Error()
if strings.Contains(es, "error_code:29") || strings.Contains(es, "error_code: 29") || strings.Contains(es, "Rate limit") {
if errors.Is(err, ErrVKRateLimit) {
c.log.Warnf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials", streamID)
}
}
@@ -9,6 +9,7 @@ import (
"time"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/pion/stun/v3"
)
// newTestClient builds a Client with a zero-interval throttle and the supplied
@@ -30,19 +31,17 @@ func newTestClient(t *testing.T, fake tokenChainFn, opts ...func(*Client)) *Clie
func TestIsAuthError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"401 Unauthorized": true,
"alloc failed: 401": true,
"stale nonce": true,
"invalid credential": true,
"authentication required": true,
"connection refused": false,
"": false,
cases := map[error]bool{
fmt.Errorf("%w: map[error_code:5]", ErrVKAuthFailed): true,
fmt.Errorf("%w: map[error_code:29]", ErrVKRateLimit): false,
// Порт с "401" внутри не делает сетевую ошибку ошибкой авторизации.
errors.New("dial udp 77.88.8.8:10401: i/o timeout"): false,
errors.New("401 Unauthorized"): false,
errors.New("connection refused"): false,
}
for msg, want := range cases {
got := IsAuthError(errors.New(msg))
if got != want {
t.Errorf("IsAuthError(%q) = %v, want %v", msg, got, want)
for err, want := range cases {
if got := IsAuthError(err); got != want {
t.Errorf("IsAuthError(%v) = %v, want %v", err, got, want)
}
}
if IsAuthError(nil) {
@@ -50,6 +49,29 @@ func TestIsAuthError(t *testing.T) {
}
}
func TestIsAuthErrorTurnCode(t *testing.T) {
t.Parallel()
codes := map[stun.ErrorCode]bool{
// 486 - занята квота по валидным реквизитам, менять их незачем.
stun.CodeAllocQuotaReached: false,
stun.CodeUnauthorized: true,
stun.CodeStaleNonce: true,
stun.CodeWrongCredentials: true,
stun.CodeInsufficientCapacity: false,
stun.CodeForbidden: false,
}
for code, want := range codes {
err := fmt.Errorf("TURN allocate: %w", &stun.TurnError{
StunMessageType: stun.NewType(stun.MethodAllocate, stun.ClassErrorResponse),
ErrorCodeAttr: stun.ErrorCodeAttribute{Code: code, Reason: []byte("reason")},
})
if got := IsAuthError(err); got != want {
t.Errorf("IsAuthError(code %d) = %v, want %v", code, got, want)
}
}
}
func TestStoreCacheGrouping(t *testing.T) {
t.Parallel()
@@ -221,3 +243,26 @@ func TestThrottleHonorsContextCancel(t *testing.T) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestDropCredentialsClearsCacheOnce(t *testing.T) {
t.Parallel()
c := newTestClient(t, nil)
cache := c.store.Get(1)
cache.creds = TurnCredentials{
Username: "u",
Password: "p",
ServerAddrs: []string{"turn:1.2.3.4:19302"},
ExpiresAt: time.Now().Add(time.Minute),
Link: "l",
}
c.DropCredentials(1)
if cache.creds.Username != "" {
t.Fatal("credentials survived DropCredentials")
}
// Второй стрим того же кэша не должен повторять сброс: иначе один обрыв даёт N строк.
if cache.Invalidate() {
t.Fatal("empty cache reported as invalidated")
}
}
+3 -72
View File
@@ -1,81 +1,12 @@
package vkauth
import (
"context"
"net"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"golang.org/x/net/proxy"
)
const (
// clientHelloSplitAt - фоллбэк-offset разбиения, когда SNI в ClientHello не
// распарсился. Совпадает с TURN STUN-split.
clientHelloSplitAt = 6
// clientHelloSplitDelay / clientHelloSplitJitter - пауза между сегментами
// ClientHello (base + случайная добавка [0,jitter)) для антифингерпринта тайминга.
clientHelloSplitDelay = 20 * time.Millisecond
clientHelloSplitJitter = 15 * time.Millisecond
)
// splitDialer оборачивает base.DialContext и дробит первый Write результирующего
// conn (TLS ClientHello) по границам внутри SNI host_name для обхода SNI-based
// DPI RST. Реализует proxy.ContextDialer - tls-client берёт его через
// WithProxyDialerFactory как прямой (не прокси) дилер.
type splitDialer struct {
base net.Dialer
}
func (d *splitDialer) Dial(network, addr string) (net.Conn, error) {
return d.DialContext(context.Background(), network, addr)
}
func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := d.base.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
return &netconn.MultiSplitWriteConn{
Conn: c,
Delay: clientHelloSplitDelay,
Jitter: clientHelloSplitJitter,
FallbackSplitAt: clientHelloSplitAt,
}, nil
}
// clientProfileForKind мапит семейство браузера в TLS/HTTP2-профиль.
func clientProfileForKind(kind browserprofile.Kind) profiles.ClientProfile {
switch kind {
case browserprofile.Safari:
return profiles.Safari_16_0
case browserprofile.Firefox:
return profiles.Firefox_148
}
return profiles.Chrome_146
}
func (c *Client) newTLSClient(jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
return c.newTLSClientForKind(c.browser, jar)
}
func (c *Client) newTLSClientForKind(kind browserprofile.Kind, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
tlsclient.WithTimeoutSeconds(20),
tlsclient.WithClientProfile(clientProfileForKind(kind)),
tlsclient.WithCookieJar(jar),
tlsclient.WithProxyDialerFactory(func(_ string, timeout time.Duration, localAddr *net.TCPAddr, _ fhttp.Header, _ tlsclient.Logger) (proxy.ContextDialer, error) {
base := c.dialer
base.Timeout = timeout
if localAddr != nil {
base.LocalAddr = localAddr
}
return &splitDialer{base: base}, nil
}),
)
func (c *Client) newTLSClient(profile browserprofile.Profile, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
return personanet.NewClient(profile, c.dialer, jar)
}
@@ -0,0 +1,170 @@
package vkauth
import (
"context"
"errors"
"fmt"
"net"
"reflect"
"strings"
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
tlsclient "github.com/bogdanfinn/tls-client"
)
func testCaptchaErr() *captcha.Error {
return &captcha.Error{SessionToken: "sess", RedirectURI: "https://vk.ru/captcha"}
}
func TestPersonaStableUntilBurned(t *testing.T) {
c := New(Config{FingerprintSeed: "install-1"})
first, again := c.currentPersona(), c.currentPersona()
if !reflect.DeepEqual(first, again) {
t.Fatal("persona not stable")
}
same := New(Config{FingerprintSeed: "install-1"})
if !reflect.DeepEqual(same.currentPersona(), c.currentPersona()) {
t.Fatal("same seed produced different persona")
}
other := New(Config{FingerprintSeed: "install-2"})
if other.currentPersona().VisitorID == c.currentPersona().VisitorID {
t.Fatal("different seeds share visitorId")
}
}
func TestEmptySeedIsRandomPerProcess(t *testing.T) {
a := New(Config{}).currentPersona()
b := New(Config{}).currentPersona()
if a.VisitorID == b.VisitorID {
t.Fatal("empty seed shares visitorId across processes")
}
}
func TestBurnPersonaAdvancesIdentity(t *testing.T) {
c := New(Config{FingerprintSeed: "install-1"})
before := c.currentPersona()
c.burnPersona(1)
if c.currentPersona().VisitorID == before.VisitorID {
t.Fatal("burn kept the rejected visitorId")
}
}
func TestAutoFailureBurnsPersonaAndRestartsChain(t *testing.T) {
c := newTestClient(t, nil)
c.autoSolver = func(context.Context, *captcha.Error, int, tlsclient.HttpClient, browserprofile.Profile) (string, error) {
return "", errors.New("auto rejected")
}
var manualPersona browserprofile.Profile
c.manualSolve = func(_ context.Context, _ *captcha.Error, _ net.Dialer, p browserprofile.Profile) (string, error) {
manualPersona = p
return "manual-token", nil
}
burned := c.currentPersona()
_, err := c.solveCaptcha(context.Background(), nil, burned, 1, "lnk", "name", "t1", testCaptchaErr())
if !errors.Is(err, ErrPersonaBurned) {
t.Fatalf("auto failure err = %v, want ErrPersonaBurned", err)
}
fresh := c.currentPersona()
if fresh.VisitorID == burned.VisitorID {
t.Fatal("auto failure kept the rejected persona")
}
data, err := c.solveCaptcha(context.Background(), nil, fresh, 1, "lnk", "name", "t1", testCaptchaErr())
if err != nil {
t.Fatalf("manual attempt: %v", err)
}
if !strings.Contains(data, "success_token=manual-token") {
t.Fatalf("retry data = %q", data)
}
if manualPersona.VisitorID != fresh.VisitorID {
t.Fatal("manual ran with a persona other than the current one")
}
}
func TestExhaustedModesBurnPersonaAndLock(t *testing.T) {
c := newTestClient(t, nil)
c.captchaAttempt = 2
burned := c.currentPersona()
_, err := c.solveCaptcha(context.Background(), nil, burned, 1, "lnk", "name", "t1", testCaptchaErr())
if !errors.Is(err, ErrCaptchaWaitRequired) {
t.Fatalf("err = %v, want ErrCaptchaWaitRequired", err)
}
if c.currentPersona().VisitorID == burned.VisitorID {
t.Fatal("exhausted modes kept the rejected persona")
}
if c.LockoutUntilUnix() == 0 {
t.Fatal("lockout not engaged")
}
}
func TestFetchRestartsSameCredsWithFreshJar(t *testing.T) {
var ids []string
var jars []tlsclient.CookieJar
calls := 0
fake := func(_ context.Context, _ string, _ int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
calls++
ids = append(ids, creds.ClientID)
jars = append(jars, jar)
if calls == 1 {
return "", "", nil, ErrPersonaBurned
}
return "u", "p", []string{"1.1.1.1:443"}, nil
}
c := newTestClient(t, fake)
if _, _, _, err := c.fetch(context.Background(), "lnk", 1); err != nil {
t.Fatalf("fetch: %v", err)
}
if calls != 2 {
t.Fatalf("tokenChain called %d times, want 2", calls)
}
if ids[0] != ids[1] {
t.Fatalf("restart switched client_id: %v", ids)
}
if jars[0] == jars[1] {
t.Fatal("restart reused the cookie jar of the burned persona")
}
}
func TestFetchBoundsPersonaRestarts(t *testing.T) {
calls := 0
fake := func(context.Context, string, int, VKCredentials, tlsclient.CookieJar) (string, string, []string, error) {
calls++
return "", "", nil, ErrPersonaBurned
}
c := newTestClient(t, fake)
if _, _, _, err := c.fetch(context.Background(), "lnk", 1); err == nil {
t.Fatal("fetch returned nil error on endless burns")
}
if want := len(c.credentials) + maxPersonaBurns; calls != want {
t.Fatalf("tokenChain called %d times, want %d", calls, want)
}
}
func TestCaptchaUnavailableKeepsPersona(t *testing.T) {
c := newTestClient(t, nil)
c.autoSolver = func(context.Context, *captcha.Error, int, tlsclient.HttpClient, browserprofile.Profile) (string, error) {
return "", fmt.Errorf("%w: captcha page http 429 (bytes=120)", captcha.ErrUnavailable)
}
kept := c.currentPersona()
_, err := c.solveCaptcha(context.Background(), nil, kept, 1, "lnk", "name", "t1", testCaptchaErr())
if !errors.Is(err, captcha.ErrUnavailable) {
t.Fatalf("err = %v, want captcha.ErrUnavailable", err)
}
if c.currentPersona().VisitorID != kept.VisitorID {
t.Fatal("persona burned on an unavailable captcha page")
}
if c.captchaAttempt != 0 {
t.Fatalf("solve-mode ladder advanced to %d", c.captchaAttempt)
}
if c.LockoutUntilUnix() != 0 {
t.Fatal("lockout engaged on an unavailable captcha page")
}
}
@@ -0,0 +1,58 @@
package vkauth
import (
"encoding/json"
"sync"
"github.com/samosvalishe/free-turn-proxy/internal/statedir"
)
// PersonaStateFile - имя файла с поколением персоны рядом с client_config.json.
const PersonaStateFile = "vk_persona.json"
// personaState переживает перезапуск: без него сожжённый отпечаток воскресает на
// старте и VK каждый раз видит тот, что уже отверг. Seed фиксирует, чьё это
// поколение - смена -client-id даёт новую личность и счётчик с нуля.
type personaState struct {
Seed string `json:"seed"`
Gen int `json:"gen"`
}
// genStore с пустым paths - no-op (тесты и хосты без записи на диск).
type genStore struct {
paths []string
}
func (s genStore) load(seed string) int {
for _, b := range statedir.ReadEach(s.paths) {
var st personaState
if json.Unmarshal(b, &st) == nil && st.Seed == seed && st.Gen > 0 {
return st.Gen
}
}
return 0
}
// genMu сериализует load+write: сравнение с диском и запись должны быть одной
// операцией, иначе параллельные save затрут друг друга.
//
//nolint:gochecknoglobals // единственный писатель файла на процесс, лок процессный
var genMu sync.Mutex
// save не понижает поколение: несколько vk.Provider (multi-link) делят один файл
// и сжигают персоны независимо.
func (s genStore) save(seed string, gen int) bool {
if len(s.paths) == 0 {
return false
}
genMu.Lock()
defer genMu.Unlock()
if gen <= s.load(seed) {
return true
}
b, err := json.Marshal(personaState{Seed: seed, Gen: gen})
if err != nil {
return false
}
return statedir.WriteFirst(s.paths, b)
}
@@ -0,0 +1,73 @@
package vkauth
import (
"os"
"path/filepath"
"testing"
)
func statePaths(t *testing.T) []string {
t.Helper()
return []string{filepath.Join(t.TempDir(), PersonaStateFile)}
}
func TestBurnedPersonaSurvivesRestart(t *testing.T) {
paths := statePaths(t)
c := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
burned := c.currentPersona()
c.burnPersona(1)
fresh := c.currentPersona()
restarted := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
if got := restarted.currentPersona(); got.VisitorID == burned.VisitorID {
t.Fatal("restart resurrected the burned persona")
} else if got.VisitorID != fresh.VisitorID {
t.Fatal("restart did not continue from the persisted generation")
}
}
func TestPersonaStateIsSeedScoped(t *testing.T) {
paths := statePaths(t)
c := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
c.burnPersona(1)
other := New(Config{FingerprintSeed: "install-2", StatePaths: paths})
if other.identity.Gen != 0 {
t.Fatalf("gen = %d for a foreign seed, want 0", other.identity.Gen)
}
}
func TestPersonaStateSurvivesCorruptFile(t *testing.T) {
dir := t.TempDir()
broken, good := filepath.Join(dir, "broken.json"), filepath.Join(dir, PersonaStateFile)
if err := os.WriteFile(broken, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(good, []byte(`{"seed":"install-1","gen":4}`), 0o600); err != nil {
t.Fatal(err)
}
c := New(Config{FingerprintSeed: "install-1", StatePaths: []string{broken, good}})
if c.identity.Gen != 4 {
t.Fatalf("gen = %d, want 4", c.identity.Gen)
}
}
func TestGenStoreKeepsHighestGen(t *testing.T) {
s := genStore{paths: statePaths(t)}
if !s.save("install-1", 3) || !s.save("install-1", 2) {
t.Fatal("save reported failure")
}
if got := s.load("install-1"); got != 3 {
t.Fatalf("gen = %d, want 3", got)
}
}
func TestGenStoreWithoutPathsIsNoop(t *testing.T) {
var s genStore
if s.save("install-1", 1) || s.load("install-1") != 0 {
t.Fatal("empty genStore touched state")
}
}
@@ -10,7 +10,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// CaptchaSolveMode - выбор между автоматическим решением и ручным браузерным fallback.
type CaptchaSolveMode int
const (
@@ -18,8 +17,6 @@ const (
CaptchaSolveModeManual
)
// CaptchaSolveModeForAttempt выбирает решалку для конкретной попытки retry.
// Возвращает (mode, true) если режим доступен, (_, false) если исчерпан.
func CaptchaSolveModeForAttempt(attempt int, manualOnly bool) (CaptchaSolveMode, bool) {
if manualOnly {
return CaptchaSolveModeManual, attempt == 0
@@ -44,8 +41,6 @@ func CaptchaSolveModeLabel(mode CaptchaSolveMode) string {
}
}
// AutoSolveFunc возвращает success_token для captcha через in-page widget flow.
// Реализации обязаны соблюдать отмену ctx.
type AutoSolveFunc func(
ctx context.Context,
captchaErr *captcha.Error,
@@ -54,9 +49,9 @@ type AutoSolveFunc func(
profile browserprofile.Profile,
) (token string, err error)
// ManualSolveFunc открывает локальный браузерный fallback и возвращает success_token.
type ManualSolveFunc func(
ctx context.Context,
captchaErr *captcha.Error,
dialer net.Dialer,
profile browserprofile.Profile,
) (token string, err error)
@@ -2,8 +2,6 @@ package vkauth
import (
"testing"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
func TestCaptchaSolveModeForAttempt(t *testing.T) {
@@ -40,38 +38,3 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) {
}
})
}
func TestSavedProfileUsable(t *testing.T) {
t.Parallel()
if savedProfileUsable(browserprofile.Saved{}) {
t.Fatal("empty profile must be unusable (no UA to impersonate)")
}
if savedProfileUsable(browserprofile.Saved{Profile: browserprofile.Profile{UserAgent: " "}}) {
t.Fatal("whitespace UA must be unusable")
}
mobile := browserprofile.Saved{Profile: browserprofile.Profile{
UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36",
SecChUaMobile: "?1",
}}
if !savedProfileUsable(mobile) {
t.Fatal("captured mobile profile must be usable")
}
}
func TestNormalizeFamily(t *testing.T) {
t.Parallel()
cases := map[browserprofile.Kind]browserprofile.Kind{
browserprofile.Firefox: browserprofile.Firefox,
browserprofile.Safari: browserprofile.Safari,
browserprofile.Chrome: browserprofile.Chrome,
"": browserprofile.Chrome,
"weird": browserprofile.Chrome,
}
for in, want := range cases {
if got := normalizeFamily(in); got != want {
t.Fatalf("normalizeFamily(%q) = %q, want %q", in, got, want)
}
}
}
+11 -12
View File
@@ -5,19 +5,15 @@ import (
"fmt"
neturl "net/url"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/namegen"
tlsclient "github.com/bogdanfinn/tls-client"
)
// getTokenChain выполняет 4-шаговый обмен токенами VK для одной пары client_id/secret
// и возвращает тройку TURN-allocate. Ошибки captcha запускают настроенную цепочку
// auto/manual solver.
func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
profile := browserprofile.ForKind(c.browser)
profile := c.currentPersona()
httpClient, err := c.newTLSClient(jar)
httpClient, err := c.newTLSClient(profile, jar)
if err != nil {
return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err)
}
@@ -25,9 +21,16 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
name := namegen.Generate()
escapedName := neturl.QueryEscape(name)
c.log.Infof("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
c.log.Debugf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
if pageErr := c.openJoinPage(ctx, httpClient, profile, link); pageErr != nil && ctx.Err() == nil {
c.log.Warnf("[STREAM %d] [VK Auth] join page warm-up failed: %v", streamID, pageErr)
}
if delayErr := vkDelayRandom(ctx, 300, 600); delayErr != nil {
return "", "", nil, delayErr
}
// Шаг 1: анонимный app-токен.
token1, err := c.fetchAnonToken(ctx, httpClient, profile, creds)
if err != nil {
return "", "", nil, err
@@ -37,7 +40,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
return "", "", nil, delayErr
}
// Шаг 1a: прогрев getCallPreview (не критично).
previewData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s", link, token1)
if _, prevErr := c.doRequest(ctx, httpClient, profile, previewData,
"https://api.vk.ru/method/calls.getCallPreview?v="+APIVersion+"&client_id="+creds.ClientID); prevErr != nil {
@@ -48,7 +50,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
return "", "", nil, delayErr
}
// Шаг 2: анонимный call-токен (здесь может сработать captcha).
token2, err := c.fetchCallToken(ctx, httpClient, profile, streamID, link, escapedName, token1, creds)
if err != nil {
return "", "", nil, err
@@ -58,7 +59,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
return "", "", nil, delayErr
}
// Шаг 3: ok.ru session_key.
sessionKey, err := c.fetchOkRuSession(ctx, httpClient, profile)
if err != nil {
return "", "", nil, err
@@ -68,6 +68,5 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
return "", "", nil, delayErr
}
// Шаг 4: TURN-реквизиты.
return c.fetchTurnCreds(ctx, httpClient, profile, streamID, link, token2, sessionKey)
}
@@ -9,8 +9,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// fetchAnonToken - шаг 1 цепочки: обменивает app client_id/client_secret
// на анонимный access token из login.vk.ru.
func (c *Client) fetchAnonToken(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, creds VKCredentials) (string, error) {
data := fmt.Sprintf("client_secret=%s&client_id=%s&scopes=audio_anonymous,video_anonymous,photos_anonymous,profile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=%s",
creds.ClientSecret, creds.ClientID, creds.ClientID)
@@ -14,8 +14,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// fetchCallToken - шаг 2 цепочки: вызывает calls.getAnonymousToken и ведёт
// цикл retry captcha до получения call-токена или исчерпания всех режимов решения.
func (c *Client) fetchCallToken(
ctx context.Context,
httpClient tlsclient.HttpClient,
@@ -28,7 +26,7 @@ func (c *Client) fetchCallToken(
data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=%s&access_token=%s",
link, escapedName, token1)
for attempt := 0; ; attempt++ {
for {
resp, err := c.doRequest(ctx, httpClient, profile, data, urlAddr)
if err != nil {
return "", err
@@ -37,7 +35,7 @@ func (c *Client) fetchCallToken(
if errObj, hasErr := resp["error"].(map[string]any); hasErr {
captchaErr := captcha.ParseError(errObj)
if captchaErr != nil && captchaErr.IsCaptcha() {
retryData, err := c.solveCaptcha(ctx, httpClient, profile, streamID, attempt, link, escapedName, token1, captchaErr)
retryData, err := c.solveCaptcha(ctx, httpClient, profile, streamID, link, escapedName, token1, captchaErr)
if err != nil {
return "", err
}
@@ -48,6 +46,9 @@ func (c *Client) fetchCallToken(
c.log.Errorf("[STREAM %d] [VK Auth] terminal link error: %v", streamID, termErr)
return "", termErr
}
if sentinel := apiErrSentinel(errObj); sentinel != nil {
return "", fmt.Errorf("%w: %v", sentinel, errObj)
}
return "", fmt.Errorf("VK API error: %v", errObj)
}
@@ -63,32 +64,34 @@ func (c *Client) fetchCallToken(
}
}
// classifyLinkError распознаёт ТЕРМИНАЛЬНЫЕ ответы VK в join-флоу и возвращает
// соответствующий sentinel (или nil, если ошибку можно ретраить дальше по
// client_id). Терминал = ни client_id, ни captcha не помогут, поэтому fast-fail
// вместо бесконечного цикла "решить captcha -> ошибка -> следующий client_id".
//
// Порядок важен: сначала явные коды (терминальные и транзиентные), и только для
// неизвестного кода - матч по тексту error_msg. Так текстовый матч не может
// перекрыть транзиентный код (5 auth-failed, 6/9/29 rate/flood, 14 captcha) и
// убить рабочее подключение из-за подстроки в сообщении.
func classifyLinkError(errObj map[string]any) error {
code := 0
func apiErrCode(errObj map[string]any) int {
if f, ok := errObj["error_code"].(float64); ok {
code = int(f)
return int(f)
}
return 0
}
func apiErrSentinel(errObj map[string]any) error {
switch apiErrCode(errObj) {
case 5:
return ErrVKAuthFailed
case 29:
return ErrVKRateLimit
}
return nil
}
func classifyLinkError(errObj map[string]any) error {
code := apiErrCode(errObj)
// Явные терминальные коды: 9008 Join link is not valid, 9000 Call not found.
if code == 9000 || code == 9008 {
return ErrInvalidJoinLink
}
// Явные транзиентные коды: гасить нельзя, текст ниже к ним не применяем.
switch code {
case 5, 6, 9, 14, 29:
return nil
}
// Код неизвестен/плавает - осторожный матч по тексту.
msg := ""
if s, ok := errObj["error_msg"].(string); ok {
msg = strings.ToLower(s)
@@ -96,7 +99,6 @@ func classifyLinkError(errObj map[string]any) error {
switch {
case strings.Contains(msg, "not valid") || strings.Contains(msg, "not found"):
return ErrInvalidJoinLink
// Анонимный вход запрещён. Матчим по "anonym" (не "authoriz" - коллизия с auth-failed).
case strings.Contains(msg, "anonym"):
return ErrAnonymousBlocked
case strings.Contains(msg, "full"):
@@ -105,19 +107,21 @@ func classifyLinkError(errObj map[string]any) error {
return nil
}
// solveCaptcha выполняет одну попытку решения captcha и возвращает тело POST
// для следующего retry или ошибку при исчерпании всех режимов.
func (c *Client) solveCaptcha(
ctx context.Context,
httpClient tlsclient.HttpClient,
profile browserprofile.Profile,
streamID, attempt int,
streamID int,
link, escapedName, token1 string,
captchaErr *captcha.Error,
) (retryData string, err error) {
attempt := c.captchaAttempt
c.captchaAttempt++
solveMode, hasSolveMode := CaptchaSolveModeForAttempt(attempt, c.manualOnly)
if !hasSolveMode {
c.log.Warnf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1)
c.burnPersona(streamID)
c.engageLockout(60 * time.Second)
if c.streamsFn() == 0 {
c.log.Errorf("[STREAM %d] [Captcha] FATAL: 0 connected streams and solve modes exhausted", streamID)
@@ -150,9 +154,6 @@ func (c *Client) solveCaptcha(
break
}
c.log.Infof("[STREAM %d] [Captcha] Triggering manual captcha fallback", streamID)
// Ручной решалке выделяется свой 3-минутный бюджет - жёсткий parent-deadline
// не обрезает время пользователя. Отмена parent (завершение приложения)
// всё равно propagate, горутина не переживает процесс.
manualCtx, manualCancel := context.WithTimeout(ctx, 3*time.Minute)
type manualRes struct {
@@ -161,7 +162,7 @@ func (c *Client) solveCaptcha(
}
resCh := make(chan manualRes, 1)
go func() {
t, e := c.manualSolve(manualCtx, captchaErr, c.dialer)
t, e := c.manualSolve(manualCtx, captchaErr, c.dialer, profile)
resCh <- manualRes{t, e}
}()
@@ -188,15 +189,31 @@ func (c *Client) solveCaptcha(
manualCancel()
}
// Сессию свернули (стоп, смена сети, рецикл) - решателю просто не дали доработать.
// Считать это провалом значит жечь поколение персоны и вешать lockout на своих же
// перезапусках.
if solveErr != nil && ctx.Err() != nil {
c.log.Warnf("[STREAM %d] [Captcha] solve interrupted by shutdown: %v", streamID, solveErr)
return "", solveErr
}
if solveErr != nil && errors.Is(solveErr, captcha.ErrUnavailable) {
c.captchaAttempt = attempt
c.log.Warnf("[STREAM %d] [Captcha] captcha unavailable, persona kept: %v", streamID, solveErr)
return "", solveErr
}
if solveErr != nil {
c.log.Warnf("[STREAM %d] [Captcha] %s failed (attempt %d): %v",
streamID, CaptchaSolveModeLabel(solveMode), attempt+1, solveErr)
nextSolveMode, hasNextSolveMode := CaptchaSolveModeForAttempt(attempt+1, c.manualOnly)
if hasNextSolveMode {
c.log.Infof("[STREAM %d] [Captcha] Falling back to %s",
c.log.Infof("[STREAM %d] [Captcha] Falling back to %s with a new persona",
streamID, CaptchaSolveModeLabel(nextSolveMode))
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, ""), nil
c.burnPersona(streamID)
return "", ErrPersonaBurned
}
c.burnPersona(streamID)
c.engageLockout(60 * time.Second)
if c.streamsFn() == 0 {
c.log.Errorf("[STREAM %d] [Captcha] FATAL: 0 connected streams and manual captcha failed/timed out", streamID)
@@ -211,7 +228,6 @@ func (c *Client) solveCaptcha(
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, successToken), nil
}
// buildCaptchaRetryData формирует тело POST для следующей попытки captcha.
func buildCaptchaRetryData(link, escapedName, token1 string, captchaErr *captcha.Error, successToken string) string {
if captchaErr.CaptchaSid == "" {
return fmt.Sprintf(
@@ -10,8 +10,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// fetchTurnCreds - шаг 4 цепочки: вызывает vchat.joinConversationByLink
// и извлекает TURN username, credential и список адресов из ответа.
func (c *Client) fetchTurnCreds(
ctx context.Context,
httpClient tlsclient.HttpClient,
@@ -45,9 +43,9 @@ func (c *Client) fetchTurnCreds(
return "", "", nil, fmt.Errorf("missing or empty urls in turn_server")
}
c.log.Infof("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
c.log.Debugf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
for i, u := range urlsRaw {
c.log.Infof("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
c.log.Debugf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
}
for _, u := range urlsRaw {
@@ -11,8 +11,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// fetchOkRuSession - шаг 3 цепочки: получает анонимный ok.ru session_key
// через auth.anonymLogin.
func (c *Client) fetchOkRuSession(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile) (string, error) {
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",
@@ -14,8 +14,34 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
// doRequest отправляет POST form-запрос по url через tls-клиент с браузерным
// профилем и десериализует JSON-тело ответа.
func (c *Client) openJoinPage(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, link string) error {
req, err := fhttp.NewRequestWithContext(ctx, fhttp.MethodGet, "https://vk.ru/call/join/"+link, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Sec-Fetch-Site", "none")
browserprofile.ApplyFhttp(req, profile)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.log.Warnf("[VK Auth] close join page body: %s", closeErr)
}
}()
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return err
}
return nil
}
func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, data, url string) (map[string]any, error) {
parsedURL, err := neturl.Parse(url)
if err != nil {
@@ -28,7 +54,6 @@ func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient,
return nil, err
}
req.Host = domain
browserprofile.ApplyFhttp(req, profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://vk.ru")
@@ -36,9 +61,7 @@ func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient,
req.Header.Set("Sec-Fetch-Site", "same-site")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
if browserprofile.Family(profile) != browserprofile.Firefox {
req.Header.Set("Priority", "u=1, i")
}
browserprofile.ApplyFhttp(req, profile)
httpResp, err := httpClient.Do(req)
if err != nil {
+15 -37
View File
@@ -7,13 +7,11 @@ import (
"github.com/samosvalishe/free-turn-proxy/internal/provider"
)
// VKCredentials - пара app_id/app_secret для получения анонимных токенов.
type VKCredentials struct {
ClientID string
ClientSecret string
}
// TurnCredentials - разрешённые TURN-реквизиты для группы потоков.
type TurnCredentials struct {
Username string
Password string
@@ -22,23 +20,15 @@ type TurnCredentials struct {
Link string
}
// DefaultCredentials - публичные app_id/secret официальных VK-клиентов. Это НЕ
// приватные креды пользователя - VK раздаёт их в JS-бандле страницы калла.
// Клиент перебирает по порядку при ошибках авторизации. В списке только app_id
// с доступом к calls.getAnonymousToken (проверено живым звонком); приложения
// без calls-scope ("Unknown method passed") исключены.
//
//nolint:gosec // public VK SDK app credentials, not user secrets
//nolint:gosec
var DefaultCredentials = []VKCredentials{
{ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
{ClientID: "2274003", ClientSecret: "hHbZxrka2uZ6jB1inYsH"}, // VK_ANDROID_APP
{ClientID: "51453752", ClientSecret: "4UyuCUsdK8pVCNoeQuGi"}, // VK_MESSENGER_DESKTOP
{ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"}, // VK_IPHONE_APP
{ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"},
{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"},
{ClientID: "2274003", ClientSecret: "hHbZxrka2uZ6jB1inYsH"},
{ClientID: "51453752", ClientSecret: "4UyuCUsdK8pVCNoeQuGi"},
{ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"},
}
// APIVersion - версия VK API во всех calls.* запросах. Держать единой и близкой
// к версии живого web-клиента звонка (рассинхрон = fingerprint-аномалия).
const APIVersion = "5.282"
const (
@@ -47,31 +37,19 @@ const (
MaxCacheErrors = 3
ErrorWindow = 10 * time.Second
DefaultStreamsPerCache = 10
DefaultStreamsPerCache = 12
maxPersonaBurns = 2
)
// Sentinel-ошибки auth-потока. Строковые формы стабильны (используются в логах).
//
// ErrCaptchaWaitRequired и ErrFatalCaptchaNoStreams также матчатся через
// provider.ErrBackoffActive / provider.ErrFatalNoStreams - pipeline проверяет
// generic-sentinels, vkauth-внутренний код может проверять и старые.
var (
ErrCaptchaWaitRequired = errors.Join(provider.ErrBackoffActive, errors.New("CAPTCHA_WAIT_REQUIRED"))
ErrFatalCaptchaNoStreams = errors.Join(provider.ErrFatalNoStreams, errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS"))
ErrLockoutActive = errors.New("global lockout active")
// Terminal call/link errors: retrying other client_ids or solving the captcha
// won't help — only the user changing the link / call settings will. We fast-
// fail on these instead of looping "solve captcha -> error -> next credential"
// forever, and the joined provider.ErrFatalNoStreams lets the host surface a
// specific, actionable message.
//
// ErrInvalidJoinLink: VK 9008 "Join link is not valid" (broken link) or 9000
// "Call not found" (the call ended / no longer exists).
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
// ErrAnonymousBlocked: the call is live but anonymous join is disabled
// ("authorized users only") — the user must allow joining by link in VK.
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
// ErrCallFull: the call has reached its participant limit.
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
ErrPersonaBurned = errors.New("persona burned")
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
ErrVKAuthFailed = errors.New("VK authorization failed")
ErrVKRateLimit = errors.New("VK rate limit")
)
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"net"
"sync"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
manualcaptcha "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha/manual"
)
@@ -14,7 +15,7 @@ var proxyManualMu sync.Mutex
// ProxyManualSolver показывает UI после запуска локального captcha-прокси.
func ProxyManualSolver(show func(url string), hide func()) ManualSolverFunc {
return func(ctx context.Context, e *captcha.Error, dialer net.Dialer) (string, error) {
return func(ctx context.Context, e *captcha.Error, dialer net.Dialer, profile browserprofile.Profile) (string, error) {
if e.RedirectURI == "" {
return "", fmt.Errorf("manual captcha: no redirect_uri")
}
@@ -25,6 +26,6 @@ func ProxyManualSolver(show func(url string), hide func()) ManualSolverFunc {
defer hide()
}
return manualcaptcha.SolveViaProxyWithPresenter(ctx, e.RedirectURI, dialer, show)
return manualcaptcha.SolveViaProxyWithPresenter(ctx, e.RedirectURI, dialer, profile, show)
}
}

Some files were not shown because too many files have changed in this diff Show More