47 Commits

Author SHA1 Message Date
Никита Sonic 05ce9566a8 fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
2026-09-19 16:04:39 +03:00
Никита Sonic 8617d25f3a chore(release): 2.0.0-beta.14 (#57)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-27 02:40:24 +03:00
Никита Sonic e9af114f3c fix(worker): запись в Telegram шла без ожидания и backpressure (#42) (#56)
Скрипт воркера писал в сокет Telegram так:

    server.addEventListener("message", (event) => {
      writer.write(chunk).catch(shutdown);
    });

`write()` вызывался поверх незавершённого, `writer.ready` не спрашивался вовсе.
Пока в клиенте голодала отправка, настоящего потока вверх через воркер не
возникало, и код держался. В beta.12 голодание починили — поток появился, и
репортёр #42 сразу получил переподключения на обоих клиентах, которых на
beta.11 с тем же воркером не было.

Запись сериализована цепочкой промисов: следующий чанк уходит после того, как
записан предыдущий, и только когда писатель готов. Кто разворачивал воркер
раньше — нужен передеплой, о чём сказано в docs/CLOUDFLARE_WORKER.md.

Причина у репортёра не подтверждена: рантайма Workers у меня нет, проверить
можно только у него.

Заодно счётчик «промолчали». Соединение, которое открылось и ничего не
прислало за `IO_TIMEOUT`, закрывалось и не попадало ни в один счётчик:
`unknown_clients` растёт, только когда запрос пришёл и не разобрался, а не
когда его не дождались. Тот же репортёр сообщил, что его телефон
переустанавливает соединение примерно раз в десять секунд — ровно период
`IO_TIMEOUT`. Проверить это по диагностике было нечем, теперь есть чем.

Тесты: Ping через туннель (путь не был покрыт вовсе, а Ping бывает только на
маршруте воркера) и молчащий клиент. Второй гоняет виртуальное время, чтобы не
ждать десять секунд по-настоящему, — отсюда dev-зависимость на tokio/test-util.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-27 02:34:20 +03:00
Никита Sonic 935121b991 chore(release): 2.0.0-beta.13 (#55)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-26 17:05:46 +03:00
Никита Sonic fe9aad5ee9 fix(diag): причина отказа туннеля и судьба домена Worker'а попадали в никуда (#50) (#54)
`TransportEngine::connect` собирает подробный перечень попыток — какой адрес не
ответил, где истёк TLS, что вернул воркер, — и возвращает его в `Err`. Дальше
этот `Err` доходил до `serve`, где выбрасывался: `let _ = handle(...)`.
Увеличивался только счётчик.

Снаружи это выглядит как `туннелей 0 · сбоев 249 · падений маршрутов 395` без
единого слова о том, почему их ноль. Отличить «провайдер режет закреплённые
адреса» от «воркер отвечает отказом» нечем, хотя рядом есть журнал событий, в
который пишутся куда менее важные вещи.

Теперь причина попадает в журнал строкой вида:

    Не поднялся туннель до DC2: 149.154.167.51 — не отвечает (таймаут TCP);
    kws2.web.telegram.org — таймаут TLS/WebSocket

Дедупликация журнала делает её разовой: набор маршрутов у DC стабилен.

Там же вторая слепая зона. Домен воркера, не похожий на имя хоста, отбрасывался
молча: `https://name.workers.dev/` со схемой или слэшем не проходит
`valid_domain`, маршрут не появляется, и «воркер настроен» неотличимо от
«воркера нет». `set_worker_domains` теперь возвращает принятые и отвергнутые
по отдельности, отвергнутые называются вместе с причиной, принятые
подтверждаются.

Тексты отказов переведены на русский: их читает не разработчик, а человек,
который прислал скриншот и ждёт ответа.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-26 16:59:13 +03:00
Никита Sonic 28085f9127 chore(release): 2.0.0-beta.12 (#52)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-26 16:34:54 +03:00
Никита Sonic 923c22f9b4 fix(proxy): загрузка останавливала отправку, туннель шёл в одну сторону (#42, #32) (#51)
Оба направления туннеля обслуживал один `select!` с пометкой `biased`.
`biased` опрашивает ветки строго по порядку: пока в первой — «Telegram →
клиент» — есть данные, до второй очередь не доходит вообще. При непрерывном
потоке вниз, то есть при первичной синхронизации телефона или загрузке медиа,
исходящие пакеты клиента не читались.

Второй дефект в том же цикле: одна задача на оба направления. `tcp_w.write_all`
ждёт, пока клиент разберёт присланное, и всё это время не опрашивается чтение
от клиента. Телефон по Wi-Fi разбирает поток медленнее, чем Telegram Desktop на
той же машине через loopback, — отсюда асимметрия «на компьютере работает, на
телефоне нет».

Для MTProto это фатально: клиент обязан слать подтверждения, а за каждым
следующим куском файла — свой `upload.getFile`. Первый запрос уходит, дальше
идёт поток вниз, и следующие запросы наверх не попадают. Снаружи это выглядит
как «Подключено» при живом туннеле, нулевых сбоях и нулевых отклонениях: чаты
на месте, иконки не грузятся, отправка виснет с часиками.

Направления разделены на две независимые половины: `ws.split()` плюс
`CryptoContext::split()`, потому что шифры направлений независимы — два потока
AES-CTR со своими ключами. Ping приходит в читающую половину, а отвечает на
него пишущая, через канал на четыре слота: владелец отправляющей половины
должен оставаться ровно один.

Два теста, падающие на beta.11: за пять секунд непрерывной загрузки наверх не
уходит ни одного байта, и клиент, не успевающий читать, замораживает
собственную отправку. Снятие одного `biased` чинит только первый — это и
показывает, что дефекта два.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-26 16:28:43 +03:00
Никита Sonic 8ce3c368a3 chore(release): 2.0.0-beta.11 (#48)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 15:04:34 +03:00
Никита Sonic 0dc6b7bf3e fix(proxy): DC и маршрут в строке статуса были из разных соединений (#47)
В диагностике из #42 встречаются строки вида

    соединений 9 · туннелей 9 · DC5 · Запасной Telegram IP · сбоев 12

Такого сочетания не бывает: у DC1, DC3, DC5 и DC203 закреплённый адрес ровно
один, и маршрута «запасной адрес» у них не существует в принципе. Значит номер
и маршрут пришли из разных соединений.

Так и было. `last_dc` писало соединение при разборе init, `last_route` — другое
соединение после рукопожатия, двумя независимыми атомиками. У репортёра от
пяти до двадцати шести одновременных соединений, поэтому пара складывалась
случайно. Читается она как «до этого DC шли этим маршрутом» и в этом качестве
врала — ровно тот класс дефектов, ради которого затевалась честная диагностика
в #38.

Теперь пара пишется одним значением в момент, когда туннель поднялся:
`dc << 8 | route`. Пока туннеля не было, показывается разобранный DC и
«маршрут ещё не выбран» — это состояние тоже настоящее и его терять не надо.

Поля стали приватными, наружу выведены `last_dc()` и `last_route()`, чтобы
рассогласовать их снаружи было нельзя.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 14:59:34 +03:00
Никита Sonic fe9ab39abe chore(release): 2.0.0-beta.10 (#46)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 14:37:05 +03:00
Никита Sonic b92403b4c9 fix(proxy): клиент, с которым не договорились, закрывался молча (#42) (#45)
Диагностика от @alexsagaidak в #42 показала третий случай, которого ни один
счётчик не различал. У него ноль отклонённых и живые туннели, то есть оба
показателя говорят «всё хорошо»:

    соединений 17 · туннелей 6 · DC4 · Запасной Telegram IP · сбоев 0 ·
    падений маршрутов 26 · отклонено 0

Клиент, который дошёл до прокси, но не сумел договориться, не попадал ни в
blocked, ни в ws_failures. Ошибка из handle() выбрасывалась в `let _ =`,
соединение закрывалось, и наружу это выглядело как active, дёрнувшийся вверх и
обратно. По диагностике неотличимо от клиента, который подключился и работает.

Теперь такие клиенты считает unknown_clients, а журнал называет адрес и
причину. Причин две:

MTProto-init не разбирается под текущим секретом. Почти всегда это ссылка
tg://proxy от прошлого запуска: секрет — её половина, и клиент с сохранённой
старой ссылкой попадает ровно сюда. Со стороны Telegram это и есть «прокси
настроен неверно и будет отключён» — то, с чем пришли в #37 и что до сих пор
нельзя было подтвердить со стороны прокси.

SOCKS5-приветствие не разбирается. Сюда же попадает MTProto-соединение,
ушедшее в SOCKS5-ветку по неоднозначному первому байту, если полный init не
успел прийти за PROTOCOL_PROBE_TIMEOUT. На loopback этого не бывает, а через
Wi-Fi с телефона — уже вопрос задержки.

От ws_failures отличается тем, что тот растёт после успешного рукопожатия с
клиентом: там договорились с клиентом, но не смогли с Telegram. Различать их
важно, иначе непонятно, в какую сторону смотреть.

В интерфейсе — метрика «Не опознаны» с пояснением, в строке статуса CLI —
поле «не опознано N».

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 14:32:34 +03:00
Никита Sonic 41e8040e59 chore(release): 2.0.0-beta.9 (#44)
Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 09:12:23 +03:00
Никита Sonic 945e794eb3 fix(proxy): LAN-режим отклонял настоящие адреса Telegram (#42) (#43)
@alexsagaidak: с компьютера прокси работает, с телефона — нет. Адрес машины
вписан верно, порт верный, брандмауэр выключен, порт добавлен в исключения.

Причина нашлась в проверке «это Telegram?». Она сравнивала два первых октета:

    (149, 154) => Some(...)
    (91, 108)  => Some(...)

То есть «телеграмом» считались целиком четыре /16 — десятки тысяч чужих
адресов, — а IPv6 не распознавался вообще ни один. Telegram владеет
149.154.160.0/20, шестью /22 в 91.108, 91.105.192.0/23, 185.76.151.0/24 и пятью
блоками IPv6.

Ошибка в обе стороны, и на loopback она не видна. Там allow_direct включён, и
неопознанный адрес всё равно релеится напрямую — соединение просто работает.
На сетевом слушателе allow_direct выключен, и тот же адрес получает SOCKS5
0x02. Отсюда ровно то, что описал репортёр: на компьютере работает, с телефона
нет. Заодно чужие адреса внутри этих /16 уходили в MTProto-туннель и умирали
там.

Список сетей теперь опубликованный самим Telegram
(core.telegram.org/resources/cidr.txt), проверка по маске префикса, IPv4 и
IPv6, с тестами на края блоков и на соседей за границей.

Отдельно разрешены имена веб-инфраструктуры: telegram.org, t.me, telegram.me,
telesco.pe, cdn-telegram.org. Это не MTProto, а обычный HTTPS — клиент ходит
туда за конфигурацией, превью и файлами CDN, и на телефоне эти запросы идут
через тот же прокси. Заворачивать их в туннель нельзя, поэтому они идут прямым
релеем. Совпадение по границе метки, так что telegram.org.example.com —
посторонний домен. В ограниченном режиме имя разрешается заранее и адреса
внутри локальной сети отбрасываются: назначение выбирает чужое устройство, и
DNS-ответ не должен открывать доступ к 192.168.х этой машины.

И главное для разбора следующего такого случая: отказ перестал быть
молчаливым. Появился счётчик «Отклонено» в диагностике и в строке статуса CLI,
отклонённый адрес один раз называется в журнале, и отдельно отмечается первое
подключение с каждого сетевого адреса. Без этого «с телефона не работает»
неразличимо распадалось на два случая: телефон не дошёл до машины — и дошёл,
но попросил адрес, который мы не пускаем. Теперь первый виден как ноль
соединений и ноль отказов, второй — как соединения есть, отказы растут.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-19 09:07:50 +03:00
Никита Sonic 60264ff177 ci: страж тега на HEAD и записанный порядок выпуска (#41)
Первый заход v2.0.0-beta.8 собрался на всех трёх платформах и упал на
публикации с «Resource not accessible by integration». Сообщение про права
уводит не туда: права были ровно те же, что у beta.7 (Contents: write),
tauri-action тот же коммит, правил на теги нет.

Настоящая причина — положение тега. Токен Actions создаёт релиз только на
HEAD ветки по умолчанию. Тег поставили на chore(release), следом дописали
коммит в main, и к моменту вызова API тег отстал на один коммит.

Задача guard сверяет тег с HEAD и валится за секунды до сборок, вместо
загадочного 403 через восемь минут. Она ловит тег на старом коммите, но не
ловит гонку «запушили в main во время сборки» — то есть ровно тот случай,
который и произошёл. Поэтому главная защита не в ней, а в порядке действий,
записанном в docs/RELEASING.md: тег ставится последним, во время релиза в
main не пушим.

Логика стража прогнана на обеих ветках на реальных SHA этого репозитория.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-10 22:29:54 +03:00
by-sonic 9cb72e4acc docs: адрес для LAN теперь виден в окне, release notes перестают повторяться 2026-08-09 18:56:07 +03:00
by-sonic 60ae065938 chore(release): 2.0.0-beta.8 2026-08-09 18:52:20 +03:00
Никита Sonic 1a56e1c96e feat(gui): показывать адрес для других устройств в LAN-режиме (#40)
Второй человек за неделю не смог подключить телефон, потому что адрес негде
взять. В #36 @modx-arseniy вписал в Telegram на Android настройки «как в
клиентах на винде» — то есть 127.0.0.1, который на другом устройстве означает
само это устройство, а не компьютер с прокси. Раньше в README было написано,
что адрес показывается в интерфейсе; это оказалось неправдой, и я тогда
исправил README вместо приложения. Теперь исправлено приложение.

В LAN-режиме на главном экране появляется карточка с адресом вида
192.168.1.7:1080. Нажатие копирует его в буфер. Под адресом — предупреждение
про 127.0.0.1, потому что именно туда люди и уходят.

Адрес берётся из работающего слушателя, а не из текущих настроек: если человек
поменял порт, но не перезапустил прокси, показать надо тот, на котором прокси
реально поднят.

Логика вынесена в чистую функцию share_address и покрыта тестами: на выключенном
прокси и на loopback делиться нечем, в LAN-режиме адрес обязан содержать порт и
не быть ни 0.0.0.0, ни 127.0.0.1.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-09 18:50:02 +03:00
by-sonic a571b4289a chore(release): 2.0.0-beta.7
Диагностика перестаёт врать: счётчик падений маршрутов (#32) и видимая ошибка
записи секрета (#37).
2026-08-04 19:43:37 +03:00
Никита Sonic fe85784550 fix: диагностика перестаёт врать — падения маршрутов и потеря секрета (#38)
Два дефекта одного класса: состояние, которое не отражает реальность. Оба
найдены по данным из #32 и #37.

#32. Присланный лог показывал «сбоев 0» при том, что DC2 и DC4 всегда шли через
«Запасной Telegram IP», а DC203 всегда через «Системный DNS» — то есть
основные закреплённые адреса не использовались ни разу. Причина в счётчике:
ws_failures растёт только когда упали ВСЕ маршруты и соединение не состоялось.
Падения отдельных маршрутов через record_failure не попадали никуда, поэтому
перебор с откатом на запасной адрес выглядел как полное отсутствие проблем.

Добавлен route_failures: растёт на каждое падение маршрута, виден в строке
статуса CLI и в диагностике интерфейса. Теперь по логу сразу видно, что
закреплённый адрес мёртв, а не приходится это выводить.

#37. Симптом: Telegram пишет «прокси настроен неверно и будет отключён», при
этом Check status показывает Available. Это картина несовпадения секрета: TCP
проходит, init не разбирается под другим секретом, соединение закрывается.

Секрет мог меняться молча:

  #[cfg(not(unix))]
  fn write_secret_file(path: &Path, value: &str) {
      let _ = std::fs::write(path, value);   // ошибка выброшена
  }

create_dir_all рядом — так же. Если запись в %APPDATA%\TGLock\secret не
удавалась, программа генерировала новый секрет при каждом запуске и ничего об
этом не сообщала.

Теперь write_secret_file возвращает Result, load_or_create_secret_at отдаёт
StoredSecret с полем write_error, а оба интерфейса показывают предупреждение:
CLI строкой при старте, GUI записью в журнал. Прокси при этом продолжает
работать — просто до перезапуска.

Тесты: every_route_failure_is_counted, a_failed_write_is_reported_instead_of
_swallowed (родитель пути — файл, поэтому каталог не создать),
a_successful_write_reports_no_error (секрет переживает второй запуск).

В диагностике интерфейса добавлена подсказка: падения маршрутов больше нуля
при работающем Telegram — норма, значит закреплённый адрес недоступен и
подключение идёт через запасной.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-08-04 19:43:21 +03:00
by-sonic 8491cf9292 chore(release): 2.0.0-beta.6
Файл настроек для headless-версии (#32, #35) и исправление падения демона при
закрытом stdout.
2026-07-30 20:19:47 +03:00
by-sonic ba8dffa074 Merge branch 'main' of https://github.com/by-sonic/tglock 2026-07-30 20:19:34 +03:00
Никита Sonic 9429975403 feat(cli): файл настроек tglock.toml (#35)
* fix(bundle): спрятать CLI за фичей — beta.2 и beta.3 упаковывали не тот бинарь

Мой предыдущий фикс (mainBinaryName в #31) фиксом не был. Он изменил только
имя выходного файла: бандлер по-прежнему брал headless CLI и просто
переименовывал его в tglock. Проверено по содержимому опубликованных
артефактов, а не по имени:

  версия  файл в MacOS/  размер  признаки CLI       признаки GUI
  beta.1  tglock         17.8 МБ  нет               ipc.localhost, wry×3206
  beta.2  tglock-cli      4.3 МБ  tglock-cli×6      нет
  beta.3  tglock          4.3 МБ  tglock-cli×6      нет

То есть beta.3 тоже не запускается, и моя же проверка CFBundleExecutable это
пропустила, потому что сверяла имя.

Настоящая причина найдена воспроизведением локально. Ломается только при явном
--target: без него бандлер выбирает GUI, с ним — CLI. Локальная сборка, на
которой я объявил фикс подтверждённым, шла без --target, а обе сборки в CI — с
ним. Обобщение было неправомерным.

Исправление убирает саму возможность выбора: CLI спрятан за фичей cli, которой
нет в default. При сборке приложения второго бинаря просто не существует.

Проверено против воспроизведённой поломки:
  до : tglock.exe 1.7 МБ, признаки CLI, GUI нет
  после: tglock.exe 9.0 МБ, признаки GUI, CLI нет, tglock-cli.exe не собран

Проверки переписаны на содержимое:
- release.yml распаковывает .app и .deb и ищет ipc.localhost (есть только в
  GUI) и allow-direct (есть только в CLI). Поймала бы и beta.2, и beta.3;
- новая задача CI bundle собирает бандл с явным --target aarch64-apple-darwin
  и проверяет его так же. Ловит до публикации, а не после;
- заодно исправлен шаблон grep для .deb: dpkg-deb -c выводит путь без ./, из-за
  чего проверка ложно падала на исправной сборке.

Команда сборки CLI теперь требует --features cli; обновлены README, ci.yml и
release.yml.

* fix(ci): одна проверка бандла на Python вместо трёх копий grep

Проверка содержимого бандла, добавленная в #33, работала на Linux и давала
ложное «в бандле не GUI» на macOS. Причина в BSD grep: в UTF-8-локали он молча
не находит строки в бинарных данных там, где GNU grep находит. Из-за этого
задача macOS в релизе beta.4 упала уже после загрузки артефактов, а вместе с
ней снова пропустилась задача с CLI-бинарями.

Сами артефакты beta.4 при этом корректны — проверено скачиванием: в бандле
tglock на 20.3 МБ, universal/fat, признаки GUI на месте, признаков CLI нет.
Не хватает только tglock-cli-*.

Проверка вынесена в scripts/verify_bundle_binary.py и вызывается из всех трёх
мест: задачи CI bundle, проверки macOS и проверки Linux в релизе. Один скрипт
вместо трёх копий шелл-кода исключает и платформенные различия grep, и
расхождение копий между собой.

Скрипт проверен на реальных исторических артефактах:
  beta.4 (исправный)  → код 0
  beta.3 (сломанный)  → код 1, найдены allow-direct и secret-file
  beta.2 (сломанный)  → код 1, то же

* feat(cli): файл настроек tglock.toml

Запрошено в #32: держать все параметры и секрет в одном месте, чтобы не
собирать батник с ключами при каждом запуске.

Файл ищется рядом с бинарём под именем tglock.toml — как и просили в ишью, —
либо указывается через --config. Приоритеты: значения по умолчанию → файл →
флаги. Флаги-переключатели могут только включать: отсутствие --quiet не
отменяет quiet = true из файла, иначе файлом нельзя было бы ничего включить.

Секрет можно вписать прямо в конфиг, в том числе в форме с префиксом dd — то
есть скопировав из напечатанной ссылки tg://proxy. Отдельный secret_file
остаётся, inline-секрет важнее.

deny_unknown_fields намеренно: опечатка вроде porrt = 1443 останавливает старт
с перечислением допустимых полей. Сервис, который из-за опечатки слушает 1080
вместо 1443, хуже сервиса, который не запустился. Явный --config к
несуществующему файлу тоже ошибка, а не тихий откат к настройкам по умолчанию.

Если секрет не закреплён ни одним из способов, CLI печатает предупреждение:
после перезапуска ссылка станет другой и настроенные клиенты отвалятся.

Найдено при живом прогоне и исправлено здесь же: демон падал, если stdout
закрывался. println! при ошибке записи паникует, а канал закрывается штатно —
`| head`, закрытый терминал, перезапуск сборщика логов. Воспроизводилось
одной командой:

  tglock-cli --port 18101 | head -2
  thread 'tokio-rt-worker' panicked at stdio.rs: failed printing to stdout

Печать переведена на say(), который возвращает признак успеха; наблюдатель
статуса при закрытом stdout просто прекращает печатать, туннель продолжает
работать.

Тестов в CLI стало 26 вместо 12: приоритеты, конфликт lan и bind из разных
источников, обе формы записи секрета, отказ на опечатке и на битых значениях,
комментарии в файле. Плюс parse_secret в ядре с проверкой того, что секрет,
сам начинающийся с dd, не теряет первый байт.

В CI добавлены три шага: чтение настроек из файла, перекрытие флагом и отказ
на опечатке; отдельно — проверка, что закрытый stdout не роняет демон.

Документация: секция про файл настроек в README, полный пример с пояснениями в
tglock.example.toml, юнит systemd переведён на --config.

* fix: вернуть плоский путь бинаря, модуль настроек — в библиотеку

Проверка бандла в CI упала на этом PR:

  failed to rename app binary .../release/cli: No such file or directory

Причина моя. Я перенёс CLI в каталожную форму src/bin/cli/main.rs, и
перечисление бинарей в Tauri вывело имя приложения из имени каталога —
получилось "cli", которого не существует. Явное name = "tglock-cli" в
[[bin]] при этом игнорируется. До переноса, при плоском src/bin/cli.rs,
всё собиралось.

Путь бинаря вернулся к src/bin/cli.rs, а модуль настроек переехал в
библиотеку как cli_settings под фичей cli. Ему там и место: он работает с
ListenConfig, mtproto и proxy, а clap не использует вовсе.

Проверено воспроизведением того же условия локально — сборка бандла с явным
--target: приложение собирается, скрипт проверки подтверждает GUI-бинарь
(9.0 МБ, ipc.localhost и wry на месте, признаков CLI нет).

Тестов: 66 в библиотеке с фичей cli, 12 в бинаре, clippy чист в обоих
вариантах сборки. Живой прогон с конфигом и отказ на опечатке сохранились.

---------

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 20:19:31 +03:00
by-sonic 96eb62b097 Merge branch 'main' of https://github.com/by-sonic/tglock 2026-07-30 16:26:35 +03:00
Никита Sonic 4ae755c6ae fix(ci): одна проверка бандла на Python вместо трёх копий grep (#34)
* fix(bundle): спрятать CLI за фичей — beta.2 и beta.3 упаковывали не тот бинарь

Мой предыдущий фикс (mainBinaryName в #31) фиксом не был. Он изменил только
имя выходного файла: бандлер по-прежнему брал headless CLI и просто
переименовывал его в tglock. Проверено по содержимому опубликованных
артефактов, а не по имени:

  версия  файл в MacOS/  размер  признаки CLI       признаки GUI
  beta.1  tglock         17.8 МБ  нет               ipc.localhost, wry×3206
  beta.2  tglock-cli      4.3 МБ  tglock-cli×6      нет
  beta.3  tglock          4.3 МБ  tglock-cli×6      нет

То есть beta.3 тоже не запускается, и моя же проверка CFBundleExecutable это
пропустила, потому что сверяла имя.

Настоящая причина найдена воспроизведением локально. Ломается только при явном
--target: без него бандлер выбирает GUI, с ним — CLI. Локальная сборка, на
которой я объявил фикс подтверждённым, шла без --target, а обе сборки в CI — с
ним. Обобщение было неправомерным.

Исправление убирает саму возможность выбора: CLI спрятан за фичей cli, которой
нет в default. При сборке приложения второго бинаря просто не существует.

Проверено против воспроизведённой поломки:
  до : tglock.exe 1.7 МБ, признаки CLI, GUI нет
  после: tglock.exe 9.0 МБ, признаки GUI, CLI нет, tglock-cli.exe не собран

Проверки переписаны на содержимое:
- release.yml распаковывает .app и .deb и ищет ipc.localhost (есть только в
  GUI) и allow-direct (есть только в CLI). Поймала бы и beta.2, и beta.3;
- новая задача CI bundle собирает бандл с явным --target aarch64-apple-darwin
  и проверяет его так же. Ловит до публикации, а не после;
- заодно исправлен шаблон grep для .deb: dpkg-deb -c выводит путь без ./, из-за
  чего проверка ложно падала на исправной сборке.

Команда сборки CLI теперь требует --features cli; обновлены README, ci.yml и
release.yml.

* fix(ci): одна проверка бандла на Python вместо трёх копий grep

Проверка содержимого бандла, добавленная в #33, работала на Linux и давала
ложное «в бандле не GUI» на macOS. Причина в BSD grep: в UTF-8-локали он молча
не находит строки в бинарных данных там, где GNU grep находит. Из-за этого
задача macOS в релизе beta.4 упала уже после загрузки артефактов, а вместе с
ней снова пропустилась задача с CLI-бинарями.

Сами артефакты beta.4 при этом корректны — проверено скачиванием: в бандле
tglock на 20.3 МБ, universal/fat, признаки GUI на месте, признаков CLI нет.
Не хватает только tglock-cli-*.

Проверка вынесена в scripts/verify_bundle_binary.py и вызывается из всех трёх
мест: задачи CI bundle, проверки macOS и проверки Linux в релизе. Один скрипт
вместо трёх копий шелл-кода исключает и платформенные различия grep, и
расхождение копий между собой.

Скрипт проверен на реальных исторических артефактах:
  beta.4 (исправный)  → код 0
  beta.3 (сломанный)  → код 1, найдены allow-direct и secret-file
  beta.2 (сломанный)  → код 1, то же

---------

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 16:26:32 +03:00
by-sonic 00d24a8647 fix(ci): одна проверка бандла на Python вместо трёх копий grep
Проверка содержимого бандла, добавленная в #33, работала на Linux и давала
ложное «в бандле не GUI» на macOS. Причина в BSD grep: в UTF-8-локали он молча
не находит строки в бинарных данных там, где GNU grep находит. Из-за этого
задача macOS в релизе beta.4 упала уже после загрузки артефактов, а вместе с
ней снова пропустилась задача с CLI-бинарями.

Сами артефакты beta.4 при этом корректны — проверено скачиванием: в бандле
tglock на 20.3 МБ, universal/fat, признаки GUI на месте, признаков CLI нет.
Не хватает только tglock-cli-*.

Проверка вынесена в scripts/verify_bundle_binary.py и вызывается из всех трёх
мест: задачи CI bundle, проверки macOS и проверки Linux в релизе. Один скрипт
вместо трёх копий шелл-кода исключает и платформенные различия grep, и
расхождение копий между собой.

Скрипт проверен на реальных исторических артефактах:
  beta.4 (исправный)  → код 0
  beta.3 (сломанный)  → код 1, найдены allow-direct и secret-file
  beta.2 (сломанный)  → код 1, то же
2026-07-30 16:18:42 +03:00
by-sonic ef81d91ad1 Merge branch 'main' of https://github.com/by-sonic/tglock 2026-07-30 16:09:00 +03:00
Никита Sonic ed121d0968 fix(bundle): спрятать CLI за фичей — beta.2 и beta.3 упаковывали не тот бинарь (#33)
Мой предыдущий фикс (mainBinaryName в #31) фиксом не был. Он изменил только
имя выходного файла: бандлер по-прежнему брал headless CLI и просто
переименовывал его в tglock. Проверено по содержимому опубликованных
артефактов, а не по имени:

  версия  файл в MacOS/  размер  признаки CLI       признаки GUI
  beta.1  tglock         17.8 МБ  нет               ipc.localhost, wry×3206
  beta.2  tglock-cli      4.3 МБ  tglock-cli×6      нет
  beta.3  tglock          4.3 МБ  tglock-cli×6      нет

То есть beta.3 тоже не запускается, и моя же проверка CFBundleExecutable это
пропустила, потому что сверяла имя.

Настоящая причина найдена воспроизведением локально. Ломается только при явном
--target: без него бандлер выбирает GUI, с ним — CLI. Локальная сборка, на
которой я объявил фикс подтверждённым, шла без --target, а обе сборки в CI — с
ним. Обобщение было неправомерным.

Исправление убирает саму возможность выбора: CLI спрятан за фичей cli, которой
нет в default. При сборке приложения второго бинаря просто не существует.

Проверено против воспроизведённой поломки:
  до : tglock.exe 1.7 МБ, признаки CLI, GUI нет
  после: tglock.exe 9.0 МБ, признаки GUI, CLI нет, tglock-cli.exe не собран

Проверки переписаны на содержимое:
- release.yml распаковывает .app и .deb и ищет ipc.localhost (есть только в
  GUI) и allow-direct (есть только в CLI). Поймала бы и beta.2, и beta.3;
- новая задача CI bundle собирает бандл с явным --target aarch64-apple-darwin
  и проверяет его так же. Ловит до публикации, а не после;
- заодно исправлен шаблон grep для .deb: dpkg-deb -c выводит путь без ./, из-за
  чего проверка ложно падала на исправной сборке.

Команда сборки CLI теперь требует --features cli; обновлены README, ci.yml и
release.yml.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 16:08:56 +03:00
by-sonic 2d2a9e9302 fix(bundle): спрятать CLI за фичей — beta.2 и beta.3 упаковывали не тот бинарь
Мой предыдущий фикс (mainBinaryName в #31) фиксом не был. Он изменил только
имя выходного файла: бандлер по-прежнему брал headless CLI и просто
переименовывал его в tglock. Проверено по содержимому опубликованных
артефактов, а не по имени:

  версия  файл в MacOS/  размер  признаки CLI       признаки GUI
  beta.1  tglock         17.8 МБ  нет               ipc.localhost, wry×3206
  beta.2  tglock-cli      4.3 МБ  tglock-cli×6      нет
  beta.3  tglock          4.3 МБ  tglock-cli×6      нет

То есть beta.3 тоже не запускается, и моя же проверка CFBundleExecutable это
пропустила, потому что сверяла имя.

Настоящая причина найдена воспроизведением локально. Ломается только при явном
--target: без него бандлер выбирает GUI, с ним — CLI. Локальная сборка, на
которой я объявил фикс подтверждённым, шла без --target, а обе сборки в CI — с
ним. Обобщение было неправомерным.

Исправление убирает саму возможность выбора: CLI спрятан за фичей cli, которой
нет в default. При сборке приложения второго бинаря просто не существует.

Проверено против воспроизведённой поломки:
  до : tglock.exe 1.7 МБ, признаки CLI, GUI нет
  после: tglock.exe 9.0 МБ, признаки GUI, CLI нет, tglock-cli.exe не собран

Проверки переписаны на содержимое:
- release.yml распаковывает .app и .deb и ищет ipc.localhost (есть только в
  GUI) и allow-direct (есть только в CLI). Поймала бы и beta.2, и beta.3;
- новая задача CI bundle собирает бандл с явным --target aarch64-apple-darwin
  и проверяет его так же. Ловит до публикации, а не после;
- заодно исправлен шаблон grep для .deb: dpkg-deb -c выводит путь без ./, из-за
  чего проверка ложно падала на исправной сборке.

Команда сборки CLI теперь требует --features cli; обновлены README, ci.yml и
release.yml.
2026-07-30 16:02:26 +03:00
Никита Sonic af3b14395f fix(bundle): в v2.0.0-beta.2 упаковывался CLI вместо приложения (#31)
Симптом: на macOS приложение не запускается совсем — иконка подпрыгивает в
доке и гаснет, хотя разрешение выдано.

Причина. В #25 в крейте появился второй бинарь tglock-cli, а mainBinaryName в
tauri.conf.json задан не был. Бандлер Tauri выбрал главным исполняемым файлом
приложения headless CLI. Запуск .app стартовал консольный прокси, окна не
создавалось.

Проверено на опубликованных артефактах:

  CFBundleExecutable   размер MacOS/*
  beta.1  tglock       18.6 МБ   ← GUI, правильно
  beta.2  tglock-cli    4.5 МБ   ← консольный прокси

Задеты все платформы, не только macOS. В .deb лежит usr/bin/tglock-cli, а в
TGLock.desktop прописан Exec=tglock-cli. Установщик Windows упал с 2.0 МБ до
0.6 МБ — тот же признак. То есть beta.2 не запускается нигде.

Почему это уехало с зелёным CI: сборка релиза была успешной, потому что никто
не проверял, что внутри бандла. Тесты гоняют ядро, а не собранное приложение.

Исправление: mainBinaryName = "tglock" в tauri.conf.json. Проверено сборкой на
этой же ветке — Tauri берёт tglock.exe, установщик вернулся к нормальному
размеру.

Чтобы это не повторилось, добавлены две проверки:
- в CI, на каждый PR: mainBinaryName обязан совпадать с именем [[bin]], у
  которого required-features = ["gui"]. Проверено в обе стороны — на верном
  конфиге проходит, на подставленном tglock-cli падает;
- в release.yml, после сборки: на macOS читается CFBundleExecutable из
  Info.plist, на Linux проверяется наличие /usr/bin/tglock в .deb. Смотрит
  внутрь настоящего артефакта, а не в конфиг.

README: пример команды с хешем больше не прибит к конкретной версии, ссылка на
запуск сборки заменена на общее указание, и добавлена строка для тех, кто уже
скачал beta.2 и не смог её открыть.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 15:40:18 +03:00
Никита Sonic 59b9cdd68c docs(readme): объяснить срабатывания антивируса и зафиксировать отказ от подписи (#30)
Претензия про VirusTotal всплывала в обсуждениях и не была нигде объяснена.
Отмахнуться «это ложное срабатывание» нельзя: движок реагирует на реальное
поведение программы. Поэтому в README добавлен раздел, который объясняет
механизм и даёт способы проверить, не доверяя автору на слово.

Что написано:
- что увидит пользователь: SmartScreen на Windows, детекты у части движков на
  VirusTotal;
- почему: неподписанный файл проверяется эвристиками строже, а поведение —
  открыть локальный порт, объявить себя прокси и прописаться в настройки
  Telegram — совпадает с профилем прокси-троянов. Программа делает именно это,
  только по просьбе пользователя, и автоматически отличить одно от другого
  движок не может;
- что подписи не будет: сертификат это ежегодный платёж, проект бесплатный.
  Формулировка прямая, без «скоро подпишем»;
- три проверяемых пути: сверка sha256 с digest, который GitHub публикует на
  странице релиза (с командами под три ОС), открытый лог сборки в Actions с
  указанием конкретного run и коммита, сборка из исходников одной командой;
- если этого недостаточно — не запускать, и это названо нормальным решением, а
  не паранойей, со ссылкой на альтернативу.

Конкретные числа детектов не приводятся: они меняются от сборки к сборке и со
временем, обещать «4 из 59» значит закладывать в документацию то, что устареет.

Соответствующие пункты обновлены в ARCHITECTURE_V2.md (Current limitations) и
в статусе ISSUE_AUDIT.md — там это было записано как открытый вопрос,
требующий покупки сертификата, теперь как принятое решение.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 14:15:47 +03:00
Никита Sonic f03e9106ee docs: инструкция по Cloudflare Worker + скрипт, снять флаг пререлиза (#29)
Резервный маршрут через Worker был в коде с 2.0, но воспользоваться им никто
не мог: в ARCHITECTURE_V2.md описан только контракт эндпоинта — это
спецификация для того, кто будет писать воркер, а не руководство. Ни скрипта,
ни шагов в репозитории не было. Поэтому люди, у которых легли все обычные
маршруты, писали «не работает» вместо того, чтобы включить запасной выход.

Добавлено:
- worker/tglock-worker.js — готовый скрипт. Проверяет путь и upgrade,
  подтверждает подпротокол binary (без этого клиент рвёт рукопожатие),
  соединяется только с семью адресами Telegram, которые запрашивает TGLock,
  и поддерживает необязательный TGLOCK_TOKEN. Без списка адресов воркер стал
  бы открытым TCP-прокси для любого, кто узнает его адрес.
- docs/CLOUDFLARE_WORKER.md — когда это нужно и когда нет (таблица
  «что видно в приложении → нужен ли Worker»), установка через веб-интерфейс,
  проверка живости, подключение в GUI и через --worker, ограничение доступа,
  контракт для своих реализаций.
- Ссылки из README: в FAQ про блокировку web.telegram.org и в блок docs.

Контракт закреплён тестами, чтобы документация не разошлась с кодом:
- worker_path вынесен в функцию, из неё же строятся боевые маршруты;
- documented_worker_contract_matches_the_requested_path сверяет формат пути;
- worker_allowlist_covers_every_address_a_route_can_ask_for падает, если в
  маршрутах появится адрес, которого нет в скрипте воркера;
- connects_through_the_documented_worker_contract поднимает сервер, ведущий
  себя ровно по документации, и проверяет что туннель работает в обе стороны
  и что запрошен именно документированный URI.

Чего тесты не проверяют: развёрнутый воркер в самом Cloudflare. Это указано и
в самой инструкции.

Отдельно: снят флаг prerelease в release.yml. До правки /releases/latest
отдавал v2.0.0-beta.1, то есть кнопка «Скачать» в README вела на сборку без
CLI и без фикса рендера. Существующий релиз v2.0.0-beta.2 помечен как latest
вручную.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 14:06:19 +03:00
Никита Sonic b06272437c fix(gui): просить программный рендер, чтобы окно создавалось без 3D (#28)
Интерфейс построен на системном WebView, а тот без 3D-ускорения окно не
создаёт. Отсюда весь класс жалоб: не стартует в виртуалке, не стартует с
дефолтным драйвером Microsoft, не стартует без монитора. Диагноз в #17 дал
@de4me: в VirtualBox приложение запускается только после включения галочки
«Включить ускорение 3D».

Перед стартом Tauri TGLock теперь сам запрашивает программный рендер:
- Windows: WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS с --disable-gpu
  и --disable-gpu-compositing;
- Linux: WEBKIT_DISABLE_COMPOSITING_MODE и WEBKIT_DISABLE_DMABUF_RENDERER;
- macOS: не требуется, WebKit сам уходит в программный рендер.

Для панели со статусом программный рендер не стоит ничего заметного, поэтому
он выбран значением по умолчанию, а не аварийным режимом. Уже заданные
оператором переменные не перезаписываются, TGLOCK_FORCE_GPU=1 отключает
механизм целиком.

Логика вынесена в чистую функцию software_rendering_vars и покрыта четырьмя
тестами: значение по умолчанию, отключение через TGLOCK_FORCE_GPU, уважение
чужой переменной и правильный набор ключей на каждой платформе.

Версия поднята до 2.0.0-beta.2 в Cargo.toml, package.json и tauri.conf.json:
нужен тег, чтобы в релиз попали и tglock-cli, и этот фикс.

README получил отдельный вопрос в FAQ про «окно не появляется»,
ARCHITECTURE_V2.md — обновлённый пункт в Current limitations.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 13:40:04 +03:00
Никита Sonic 9bacc488a5 docs: синхронизировать документацию с состоянием кода (#27)
Аудит репозитория после #25 и #26. Расхождения между тем, что написано, и тем,
что есть:

- README обещал `tglock-cli-*` в таблице загрузок, но в релизе
  v2.0.0-beta.1 такого артефакта нет: задача `cli` в release.yml сработает
  только на следующем теге. Заменено на честную формулировку с командой
  сборки из main.
- ui/main.ts держал начальным значением маршрута строку «Автоматический
  маршрут», которую бэкенд больше не отдаёт: при коде 0 возвращается
  «Маршрут ещё не выбран». Иначе до первого опроса статуса интерфейс
  показывал название несуществующего маршрута.
- ARCHITECTURE_V2.md не упоминал ни разделения на библиотеку и два бинаря, ни
  фичи `gui`, ни правила различения протоколов, ни политики прямого релея, ни
  того, что GUI не запускается без WebView. Добавлены разделы, а последнее
  внесено в Current limitations вместе с отсутствием подписи бинарей.
- ISSUE_AUDIT.md описывал состояние на 29 июля. Сам аудит оставлен как
  фиксация на дату, сверху добавлен статус на 30 июля: что закрыто, что
  осталось открытым и почему, и что исправлено вне списка issues.
- HABR.md — черновик статьи, а не документация, но был указан в README как
  «подробный технический разбор». В нём «два файла, 350 строк, четыре
  платформы», тогда как сейчас 2872 строки Rust, семь файлов и три платформы
  плюс headless. Добавлена шапка с поправкой, ссылка в README переписана так,
  чтобы читателя не отправляли к устаревшим числам за документацией.

Проверено: fmt, clippy в обоих вариантах сборки, 47 + 12 тестов, npm run build,
все ссылки на файлы в .md существуют.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 13:34:43 +03:00
Никита Sonic 95059f5449 docs(readme): убрать невыполнимые обещания, свести рекламу в один блок (#26)
fix(proxy): считать туннель только после успешного рукопожатия

README обещал то, чего код не делает. Проверено по исходникам, исправлено:

- «Голосовые/видеозвонки рвутся» стояло в списке «кому подойдёт», то есть
  подразумевалось, что TGLock их лечит. Не лечит: звонки по UDP, проксируется
  только TCP. То же обещание было в FAQ. Появился явный раздел «чего TGLock
  не делает» — звонки, всё кроме Telegram, Android и iOS.
- «IP отобразится прямо в интерфейсе TGLock» в описании LAN-режима. Такого
  поля в интерфейсе нет: StatusSnapshot отдаёт только порт. Заменено на то,
  что есть — готовую tg://-ссылку и команды для поиска адреса руками.
- «Кода ~350 строк» — в действительности 2872 строки Rust (из них ~1140
  тесты) и ~380 строк TypeScript.
- «DC ID — i32 в [60..64]» — на самом деле i16 в [60..62], отрицательное
  значение означает медиа-соединение.
- Транспорт описывался как единственный маршрут через web.telegram.org. В 2.0
  это каскад: закреплённые IP, дублёры kwsN-1, системный DNS и опциональный
  Cloudflare Worker, с cooldown на упавших. Указано, что системный DNS и hosts
  не изменяются, а SNI и Host остаются настоящими.
- FAQ про macOS ссылался на файл tglock-macos-arm64, которого в релизах нет.
- «Собирает бинарники для всех 4 платформ» — их три, плюс CLI.
- Пустая колонка «Размер» в таблице загрузок заполнена реальными размерами.
- FAQ про использование как обычного SOCKS5 не отражал, что на сетевом адресе
  не-Telegram запросы отклоняются.
- FAQ про блокировку web.telegram.org обещал спасение, которого нет; теперь
  там сказано и про запас маршрутов, и про предел подхода.

Реклама RoseVPN сведена в один блок сверху: удалены секция внизу, три вставки
в FAQ и ссылка в подвале.

Добавлен раздел «Как помочь» с тем, что прислать в баг-репорте, и списком
известных ограничений, по которым issue открывать не нужно.

Правка кода, без которой один из абзацев README был бы неправдой: счётчик
Stats::ws увеличивался до WebSocket-рукопожатия, поэтому пока прокси перебирал
маршруты по несколько секунд каждый, интерфейс уже показывал «Telegram на
связи». Теперь счёт ведёт RAII-guard после успешного connect, и состояния
«порт открыт», «идёт перебор» и «туннель есть» различимы. Тест
a_tunnel_counts_only_after_the_handshake_succeeds держит это: молчащий
listener, рукопожатие в полёте, ws и last_route обязаны остаться нулями.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 13:25:08 +03:00
Никита Sonic 55653ed0bc feat(cli): headless tglock-cli, GUI behind a feature, deep test coverage (#25)
Реализует направление PR #15 поверх архитектуры 2.0. Сам PR смерджить нельзя:
он патчит src/bypass.rs, src/network.rs и src/ws_proxy.rs, которых больше нет,
и правит системный DNS — в 2.0 это не нужно, потому что адреса Telegram зашиты
в маршрутах, а SNI остаётся настоящим. Взято разделение GUI/CLI и произвольный
bind-адрес, отброшены DNS-менеджмент и проверка root: CLI не требует прав.

Closes #10, #17 — GUI не создаёт окно без 3D-ускорения, на машине без монитора
и в виртуалке. Причина в WebView под Tauri, поэтому лечится не программным
рендером, а бинарём, в котором WebView нет вовсе: при выключенной фиче gui
Tauri и фронтенд в сборку не попадают. Отдельная задача CI собирает и гоняет
CLI на голом ubuntu без Node.js и без libwebkit2gtk.

Структура:
- src/lib.rs — ядро (mtproto, proxy, transport, config), без Tauri
- src/main.rs — GUI, required-features = ["gui"]
- src/bin/cli.rs — headless-бинарь на clap
- build.rs вызывает tauri_build только при включённой фиче gui

Исправлено по пути:
- Определение протокола: SOCKS5 и MTProto различались по первому байту, но
  is_reserved_init не исключает 0x05, поэтому примерно одно соединение из 256
  уезжало в SOCKS5-ветку и умирало. Теперь неоднозначный первый байт решается
  по полному 64-байтному init и секрету.
- Ярлык маршрута в UI: код 2 подписывался как «Cloudflare Worker», хотя это
  запасной Telegram IP, а системный DNS и настоящий Worker оба показывались
  как «Автоматический маршрут». Метки переехали в transport::route_label,
  общий для обоих интерфейсов.
- Секрет прокси: под DynamicUser и ProtectHome домашней папки нет, secret_path
  возвращает None и секрет генерировался заново при каждом старте, ломая всем
  настроенным клиентам tg://-ссылку. Добавлен --secret-file.
- README обещал Rust 1.75+, тогда как Cargo.toml требует 1.88 и CI это
  проверяет. Это и есть первопричина #3.

Политика доступа: прямые не-Telegram соединения разрешены только на loopback,
на любом сетевом адресе нужен явный --allow-direct. Правило из ISSUE_AUDIT о
том, что LAN не должен становиться открытым SOCKS5, теперь выражено в типе
ListenConfig и покрыто тестами.

Тесты: 46 в библиотеке + 12 в CLI. Появился сквозной тест туннеля против
мок-релея, который реализует сторону Telegram по obfuscated2 — проверяется
не внутренняя консистентность, а что реле получает ровно тот открытый текст,
который отправил клиент, и обратно. Плюс расписание backoff, фолбэк при всех
маршрутах в cooldown, валидация Worker-доменов, отказы SOCKS5, устойчивость
секрета к перезапуску и корректная остановка по SIGTERM.

Документация: секция CLI в README с юнитом systemd и Dockerfile.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 13:12:21 +03:00
Никита Митусов e622c57a0a fix: preserve transparency in desktop icons 2026-07-29 15:49:04 +03:00
Никита Митусов 8df24391da fix: configure desktop bundle icons 2026-07-29 15:45:58 +03:00
Никита Митусов cd75429188 ci: request platform-specific Tauri bundles 2026-07-29 15:37:57 +03:00
Никита Митусов d0ec1d4a8c ci: update GitHub Actions runtimes 2026-07-29 15:26:40 +03:00
Никита Митусов c25bea1d92 feat: rebuild TGLock with adaptive transport and Tauri UI 2026-07-29 15:22:54 +03:00
Никита Sonic 39a7772151 docs(readme): крутое RU-оформление + SEO-структура с FAQ + RoseVPN hero 2026-05-25 23:45:37 +03:00
by-sonic ccf3e3150c Rebrand VPN bot mentions to @rosevpnru_bot + RoseVPN; add promo banner at top of README 2026-05-10 15:24:24 +03:00
by-sonic cfa50d66f3 docs: new Habr article v2 - macOS focus, LAN mode, stability fixes
Made-with: Cursor
2026-04-08 15:05:10 +03:00
by-sonic 1c1ecbc071 feat: configurable port - default 1080, editable in UI before connecting
Made-with: Cursor
2026-04-08 15:02:11 +03:00
by-sonic 6795c6177a feat: add LAN mode - bind to 0.0.0.0 for sharing proxy across local network
Made-with: Cursor
2026-04-08 15:00:22 +03:00
by-sonic 70c61e1330 Add Habr article
Made-with: Cursor:
2026-04-08 14:58:02 +03:00
by-sonic 09b7a03a0a v1.0.0: Clean rewrite — cross-platform, dark UI, stable WS tunnel
Made-with: Cursor:
2026-04-08 14:56:50 +03:00
114 changed files with 18810 additions and 2810 deletions
+110
View File
@@ -0,0 +1,110 @@
name: Android APK
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
apk:
name: Android ${{ matrix.arch }} debug APK
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
target: aarch64
rust-target: aarch64-linux-android
abi: arm64-v8a
- arch: x86_64
target: x86_64
rust-target: x86_64-linux-android
abi: x86_64
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install Android SDK and NDK
run: |
sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;28.0.13004108"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.0.13004108" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@1.88.0
with:
targets: ${{ matrix.rust-target }}
- uses: Swatinem/rust-cache@v2
with:
key: android-${{ matrix.target }}
- name: Install frontend dependencies
run: npm ci
# Tauri regenerates only ignored machine-specific Gradle glue. The
# checked-in Activity, service, manifest and Gradle project stay intact.
- name: Build installable APK with the bundled frontend
run: |
chmod +x gen/android/gradlew
npm run tauri -- android build --debug --apk --target ${{ matrix.target }} --ci
- name: Check APK signature and packaged Rust engine
run: |
apk=$(find gen/android/app/build/outputs/apk -name '*.apk' -print -quit)
test -n "$apk"
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify "$apk"
unzip -l "$apk" | grep 'lib/${{ matrix.abi }}/libtglock_lib.so'
- uses: actions/upload-artifact@v4
with:
name: tglock-android-${{ matrix.arch }}-debug
path: gen/android/app/build/outputs/apk/**/*.apk
if-no-files-found: error
retention-days: 14
emulator:
name: Android 15 emulator smoke
needs: apk
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v4
with:
name: tglock-android-x86_64-debug
path: emulator-apk
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Launch and exercise the native app
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
target: google_apis
profile: pixel_2
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 180 python3 scripts/android_smoke.py emulator-apk
- uses: actions/upload-artifact@v4
if: always()
with:
name: android-emulator-smoke-evidence
path: android-smoke-evidence/
if-no-files-found: warn
retention-days: 14
+59
View File
@@ -0,0 +1,59 @@
name: Static ARM64 CLI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
static-cli:
name: ARM64 musl build and runtime checks
runs-on: ubuntu-24.04-arm
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CC_aarch64_unknown_linux_musl: musl-gcc
steps:
- uses: actions/checkout@v6
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install -y musl-tools binutils
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-musl
- name: Test the static headless target
run: cargo test --locked --target aarch64-unknown-linux-musl --no-default-features --features cli --lib --bins
- name: Build static CLI
run: cargo build --locked --release --target aarch64-unknown-linux-musl --no-default-features --features cli --bin tglock-cli
- name: Verify architecture and static linkage
run: |
binary=target/aarch64-unknown-linux-musl/release/tglock-cli
readelf -h "$binary" | grep -q 'Machine:.*AArch64'
if readelf -l "$binary" | grep -q INTERP; then
echo 'A static router binary must not have a dynamic interpreter'; exit 1
fi
if readelf -d "$binary" | grep -q NEEDED; then
echo 'A static router binary must not require shared libraries'; exit 1
fi
cp "$binary" tglock-cli-aarch64-unknown-linux-musl
sha256sum tglock-cli-aarch64-unknown-linux-musl > tglock-cli-aarch64-unknown-linux-musl.sha256
- name: Start, persist the secret, stop and restart
run: |
binary=./tglock-cli-aarch64-unknown-linux-musl
"$binary" --version
timeout --preserve-status --signal=TERM 5 "$binary" --port 18080 --secret-file "$RUNNER_TEMP/tglock-secret" > first.log 2>&1
grep -q '127.0.0.1:18080' first.log
grep -q 'tg://proxy' first.log
test "$(stat -c '%a' "$RUNNER_TEMP/tglock-secret")" = 600
timeout --preserve-status --signal=TERM 5 "$binary" --port 18080 --secret-file "$RUNNER_TEMP/tglock-secret" > second.log 2>&1
test "$(grep -o 'secret=[0-9a-f]*' first.log)" = "$(grep -o 'secret=[0-9a-f]*' second.log)"
- uses: actions/upload-artifact@v4
with:
name: tglock-cli-aarch64-unknown-linux-musl
path: |
tglock-cli-aarch64-unknown-linux-musl
tglock-cli-aarch64-unknown-linux-musl.sha256
if-no-files-found: error
+239
View File
@@ -0,0 +1,239 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
windows-core:
name: Windows headless core
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Lint CLI and core
run: cargo clippy --locked --no-default-features --features cli --all-targets -- -D warnings
- name: Test CLI and core
run: cargo test --locked --no-default-features --features cli --lib --bins
- name: Build CLI for manual Windows verification
run: cargo build --locked --no-default-features --features cli --bin tglock-cli
- uses: actions/upload-artifact@v4
with:
name: tglock-cli-windows-debug
path: target/debug/tglock-cli.exe
if-no-files-found: error
retention-days: 14
rust:
name: Rust checks
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Build frontend
run: npm ci && npm run build
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
# Оба бинаря лежат в одном крейте, и бандлер Tauri без явного указания
# может взять headless CLI как главный исполняемый файл приложения.
# Именно это уехало в v2.0.0-beta.2: .app и .deb содержали tglock-cli, и
# приложение не открывалось ни на одной платформе.
- name: Main binary of the bundle must be the GUI one
run: |
node -e "
const fs = require('fs');
const conf = JSON.parse(fs.readFileSync('tauri.conf.json', 'utf8'));
const toml = fs.readFileSync('Cargo.toml', 'utf8');
const gui = toml.split('[[bin]]').slice(1)
.find(b => /required-features\s*=\s*\[\s*\"gui\"\s*\]/.test(b));
if (!gui) throw new Error('не найден [[bin]] с required-features = [\"gui\"]');
const name = (gui.match(/name\s*=\s*\"([^\"]+)\"/) || [])[1];
if (conf.mainBinaryName !== name) {
throw new Error('mainBinaryName=' + conf.mainBinaryName + ', а GUI-бинарь называется ' + name);
}
console.log('mainBinaryName указывает на GUI-бинарь:', name);
"
- name: Check formatting
run: cargo fmt --check
- name: Lint
run: cargo clippy --all-targets -- -D warnings
- name: Test
run: cargo test --all-targets
bundle:
name: Bundled app must be the GUI binary
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
# Явный --target обязателен: без него бандлер выбирал правильный бинарь, а
# с ним — нет, и именно так beta.2 и beta.3 уехали с headless CLI внутри.
- name: Bundle the app
run: npx tauri build --bundles app --target aarch64-apple-darwin
# Проверяется содержимое, а не имя файла. В beta.3 имя было уже
# правильным, потому что бандлер переименовал CLI, и проверка имени
# ничего не заметила.
- name: The bundled binary must actually be the GUI one
run: |
exe=$(find target -maxdepth 8 -path '*TGLock.app/Contents/MacOS/tglock' | head -1)
test -n "$exe" || { echo "исполняемый файл бандла не найден"; find target -maxdepth 6 -name 'TGLock.app'; exit 1; }
python3 scripts/verify_bundle_binary.py "$exe"
headless:
name: Headless CLI (no WebView, no Node)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# This job deliberately installs no Node.js, no frontend and no
# libwebkit2gtk. It fails the moment anything drags the GUI back into the
# headless build, which is the whole point of issues #10 and #17.
- name: Lint
run: cargo clippy --no-default-features --features cli --all-targets -- -D warnings
- name: Test
run: cargo test --no-default-features --features cli --lib --bins
- name: Build
run: cargo build --release --no-default-features --features cli --bin tglock-cli
- name: Start, advertise a proxy link and stop on SIGTERM
run: |
./target/release/tglock-cli --help
./target/release/tglock-cli --version
# --preserve-status makes this assert the shutdown path: a handled
# SIGTERM exits 0, an unhandled one would surface as 143 and fail.
timeout --preserve-status --signal=TERM 5 \
./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > cli.log 2>&1
cat cli.log
grep -q 'tg://proxy' cli.log
grep -q '127.0.0.1:18080' cli.log
test "$(stat -c '%a' "$PWD/secret")" = 600
- name: Keep the same proxy link across a restart
run: |
first=$(grep -o 'secret=[0-9a-f]*' cli.log)
timeout --preserve-status --signal=TERM 5 \
./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > restart.log 2>&1
test "$first" = "$(grep -o 'secret=[0-9a-f]*' restart.log)"
# Настройки из файла и приоритет флага над файлом.
- name: Read settings from a file
run: |
cat > tglock.toml <<'CONF'
port = 18090
secret = "dd00112233445566778899aabbccddeeff"
worker = ["example.workers.dev"]
CONF
timeout --preserve-status --signal=TERM 5 \
./target/release/tglock-cli --config tglock.toml > conf.log 2>&1
cat conf.log
grep -q '127.0.0.1:18090' conf.log
grep -q 'secret=dd00112233445566778899aabbccddeeff' conf.log
grep -q 'example.workers.dev' conf.log
# Флаг должен перекрывать файл.
timeout --preserve-status --signal=TERM 5 \
./target/release/tglock-cli --config tglock.toml --port 18091 > over.log 2>&1
grep -q '127.0.0.1:18091' over.log
# Опечатка в файле обязана быть ошибкой, а не молчаливым дефолтом.
echo 'porrt = 1443' > bad.toml
if ./target/release/tglock-cli --config bad.toml; then
echo "опечатка в конфиге прошла незамеченной"; exit 1
fi
# Закрытый stdout не должен ронять демон: println! при ошибке записи
# паникует, а канал закрывается штатно — `| head`, закрытый терминал,
# перезапуск сборщика логов.
- name: Survive a closed stdout
run: |
set +e
timeout --signal=TERM 5 ./target/release/tglock-cli --port 18092 2> pipe-err.log | head -2
set -e
cat pipe-err.log
if grep -qi 'panic' pipe-err.log; then
echo "демон упал из-за закрытого stdout"; exit 1
fi
msrv:
name: Rust 1.88 compatibility
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Build frontend
run: npm ci && npm run build
- name: Install minimum supported Rust
uses: dtolnay/rust-toolchain@1.88.0
- name: Check locked dependency graph
run: cargo check --locked
- name: Check the headless dependency graph too
run: cargo check --locked --no-default-features --features cli --lib --bins
frontend:
name: Frontend
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Type-check and build
run: npm ci && npm run build
- name: Worker contract and failure-path tests
run: npm run test:worker
- name: Offline protocol probe parser checks
run: node scripts/probe_proxy.mjs --self-test
+213 -41
View File
@@ -1,64 +1,236 @@
name: Build & Release
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*'
tags: ["v*"]
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
# Токен Actions умеет создавать релиз только на HEAD ветки по умолчанию. Если
# тег отстал хоть на один коммит, GitHub отвечает «Resource not accessible by
# integration» — сообщение про права, хотя права в порядке и дело в положении
# тега. Так утонул v2.0.0-beta.8: тег поставили, следом дописали коммит в main,
# и три сборки по восемь минут закончились загадочным 403.
#
# Проверка занимает секунды и идёт до сборок. Но она НЕ закрывает гонку: если
# запушить в main уже после её прохождения, публикация всё равно упадёт — как и
# случилось с beta.8. Единственная настоящая защита — порядок действий: тег
# ставится последним, и пока идёт релиз, в main не пушим.
guard:
name: Тег должен стоять на HEAD
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Сверить тег с веткой по умолчанию
env:
GH_TOKEN: ${{ github.token }}
run: |
branch=$(gh api "repos/$GITHUB_REPOSITORY" -q .default_branch)
head=$(gh api "repos/$GITHUB_REPOSITORY/commits/$branch" -q .sha)
if [ "$head" = "$GITHUB_SHA" ]; then
echo "$GITHUB_REF_NAME и $branch указывают на $head — собираем."
exit 0
fi
echo "::error::$GITHUB_REF_NAME стоит на $GITHUB_SHA, а $branch — на $head. Публикация упадёт с 403, потому что токен Actions создаёт релиз только на HEAD ветки по умолчанию."
echo "Переставьте тег и запушьте заново:"
echo " git tag -d $GITHUB_REF_NAME"
echo " git push origin :refs/tags/$GITHUB_REF_NAME"
echo " git tag -a $GITHUB_REF_NAME $head -m 'TGLock ...'"
echo " git push origin $GITHUB_REF_NAME"
exit 1
publish:
needs: guard
name: Build ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- platform: macOS universal
os: macos-14
args: --bundles app,dmg --target universal-apple-darwin
rust-targets: aarch64-apple-darwin,x86_64-apple-darwin
- platform: Windows x64
os: windows-latest
args: --bundles nsis --target x86_64-pc-windows-msvc
rust-targets: x86_64-pc-windows-msvc
- platform: Linux x64
os: ubuntu-22.04
args: --bundles appimage,deb --target x86_64-unknown-linux-gnu
rust-targets: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Install Linux desktop dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
targets: ${{ matrix.rust-targets }}
- name: Build release
run: cargo build --release
- name: Install frontend dependencies
run: npm ci
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Build and publish release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
name: tg_unblock-windows-x64
path: target/release/tg_unblock.exe
tagName: v__VERSION__
releaseName: TGLock v__VERSION__
# Только инструкция по скачиванию: она одна и та же для любой версии.
# Список изменений дописывается в release notes при выпуске — иначе
# выходит как в бетах 1-7, где каждая несла один и тот же текст «новый
# TGLock, полностью обновлённый интерфейс» и понять, что поменялось,
# было невозможно.
releaseBody: |
## Что скачивать
release:
needs: build
- **Windows** — `.exe`, установщик
- **macOS** — универсальный `.dmg` (или `.app.tar.gz`)
- **Linux** — `.AppImage` или `.deb`
- **Сервер, контейнер, машина без монитора** — `tglock-cli-*`: то же самое, но без графического интерфейса
Если Windows ругается на файл — [почему так и что с этим делать](https://github.com/by-sonic/tglock#-антивирус-ругается-smartscreen-предупреждает-virustotal-показывает-детекты).
# Publish only after every platform, CLI and exact-commit APK is verified.
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
# Проверяет СОДЕРЖИМОЕ упакованного бинаря, а не его имя.
#
# v2.0.0-beta.2 и v2.0.0-beta.3 опубликовались с зелёным CI, хотя в бандле
# лежал headless CLI вместо приложения. В beta.3 имя файла было уже
# правильным — переименованным — поэтому проверка имени прошла. Отличить
# можно только по содержимому: `wry`/`ipc.localhost` есть исключительно в
# GUI, а `allow-direct` — исключительно в CLI.
- name: The bundled binary must actually be the GUI one
if: runner.os == 'macOS'
shell: bash
run: |
app=$(find target -maxdepth 6 -name 'TGLock.app' -type d | head -1)
test -n "$app" || { echo "TGLock.app не найден"; exit 1; }
exe="$app/Contents/MacOS/tglock"
test -f "$exe" || { echo "нет $exe:"; ls -la "$app/Contents/MacOS/"; exit 1; }
python3 scripts/verify_bundle_binary.py "$exe"
- name: The bundled binary must actually be the GUI one
if: runner.os == 'Linux'
shell: bash
run: |
deb=$(find target -maxdepth 6 -name '*.deb' | head -1)
test -n "$deb" || { echo ".deb не найден"; exit 1; }
root=$(mktemp -d)
dpkg-deb -x "$deb" "$root"
exe="$root/usr/bin/tglock"
test -f "$exe" || { echo "нет /usr/bin/tglock:"; dpkg-deb -c "$deb" | grep '/bin/'; exit 1; }
python3 scripts/verify_bundle_binary.py "$exe"
arm64-build:
needs: guard
uses: ./.github/workflows/arm64.yml
permissions:
contents: read
arm64-publish:
name: Attach verified ARM64 musl CLI
needs: [publish, arm64-build]
runs-on: ubuntu-latest
steps:
- name: Download artifact
uses: actions/download-artifact@v4
- uses: actions/download-artifact@v4
with:
name: tg_unblock-windows-x64
name: tglock-cli-aarch64-unknown-linux-musl
path: arm64-cli
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
draft: true
files: arm64-cli/*
- name: Create Release
cli:
name: Headless CLI ${{ matrix.platform }}
needs: publish
strategy:
fail-fast: false
matrix:
include:
- platform: macOS universal
os: macos-14
rust-targets: aarch64-apple-darwin,x86_64-apple-darwin
asset: tglock-cli-universal-apple-darwin
- platform: Windows x64
os: windows-latest
rust-targets: x86_64-pc-windows-msvc
asset: tglock-cli-x86_64-pc-windows-msvc.exe
- platform: Linux x64
os: ubuntu-22.04
rust-targets: x86_64-unknown-linux-gnu
asset: tglock-cli-x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
# No Node.js and no desktop libraries: the CLI must build without them.
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- name: Build (unix)
if: runner.os != 'Windows'
shell: bash
run: |
IFS=',' read -ra targets <<< "${{ matrix.rust-targets }}"
binaries=()
for target in "${targets[@]}"; do
cargo build --release --locked --no-default-features --features cli \
--bin tglock-cli --target "$target"
binaries+=("target/$target/release/tglock-cli")
done
if [ "${#binaries[@]}" -gt 1 ]; then
lipo -create -output "${{ matrix.asset }}" "${binaries[@]}"
else
cp "${binaries[0]}" "${{ matrix.asset }}"
fi
chmod +x "${{ matrix.asset }}"
./"${{ matrix.asset }}" --version
- name: Build (windows)
if: runner.os == 'Windows'
shell: bash
run: |
cargo build --release --locked --no-default-features --features cli \
--bin tglock-cli --target ${{ matrix.rust-targets }}
cp "target/${{ matrix.rust-targets }}/release/tglock-cli.exe" "${{ matrix.asset }}"
./"${{ matrix.asset }}" --version
- name: Attach to the release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: tg_unblock.exe
generate_release_notes: true
body: |
## TG Unblock ${{ github.ref_name }}
Обход блокировки Telegram через WebSocket-туннель.
### Установка
1. Скачайте `tg_unblock.exe`
2. Запустите
3. Нажмите "Запустить обход"
4. Нажмите "Настроить автоматически"
---
**[by sonic VPN](https://t.me/bysonicvpn_bot)** — полный обход блокировок для всех приложений
tag_name: ${{ github.ref_name }}
draft: true
prerelease: false
files: ${{ matrix.asset }}
+6 -3
View File
@@ -1,6 +1,9 @@
/target
/dist
/dist-ui
/node_modules
/gen/*
!/gen/android/
/tools
*.zip
*.exe
!*.rs
Cargo.lock
.claude/
Generated
+5311
View File
File diff suppressed because it is too large Load Diff
+66 -14
View File
@@ -1,26 +1,78 @@
[package]
name = "tg_unblock"
version = "0.3.1"
name = "tglock"
version = "2.0.0-beta.15"
edition = "2021"
rust-version = "1.88"
description = "Telegram unblock via local WebSocket tunnel"
license = "MIT"
autobins = false
[features]
default = ["gui"]
# The desktop GUI. Turning it off drops Tauri, the system WebView and the
# frontend bundle from the build, which is what makes headless servers and
# machines without a GPU or monitor able to build and run TGLock at all.
gui = ["dep:tauri", "dep:tauri-build", "dep:tauri-plugin-opener"]
# The headless binary. Deliberately NOT in `default`.
#
# When both binaries exist in one build, the Tauri bundler picks the wrong one
# as the application: `tauri build --target <triple>` packaged tglock-cli and
# shipped it as the app in v2.0.0-beta.2 and v2.0.0-beta.3. Keeping the CLI
# behind its own non-default feature means it simply does not exist during an
# application build, so there is nothing to pick wrongly.
cli = ["dep:clap", "dep:toml"]
[lib]
name = "tglock_lib"
path = "src/lib.rs"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "tglock"
path = "src/main.rs"
required-features = ["gui"]
[[bin]]
name = "tglock-cli"
path = "src/bin/cli.rs"
required-features = ["cli"]
[dependencies]
eframe = "0.31"
egui = "0.31"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["blocking"] }
tauri = { version = "2", features = [], optional = true }
tauri-plugin-opener = { version = "2", optional = true }
clap = { version = "4", features = ["derive"], optional = true }
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
# нужны комментарии.
toml = { version = "0.8", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
open = "5"
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
native-tls = "0.2"
tokio = { version = "1", features = [
"rt-multi-thread",
"net",
"io-util",
"time",
"macros",
"sync",
"signal",
] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
# Explicit ring provider supports Android and static musl without OpenSSL.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
webpki-roots = "0.26"
futures-util = "0.3"
aes = "0.8"
ctr = "0.9"
cipher = "0.4"
sha2 = "0.10"
rand = "0.8"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser"] }
[build-dependencies]
tauri-build = { version = "2", features = [], optional = true }
[[bin]]
name = "tg_unblock"
path = "src/main.rs"
[dev-dependencies]
rcgen = "0.13"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
# `start_paused` в тестах: таймаут ожидания запроса от клиента — десять секунд,
# и ждать их по-настоящему в тесте нельзя. В сборку не попадает: dev-зависимости
# участвуют только в тестах.
tokio = { version = "1", features = ["test-util"] }
+295
View File
@@ -0,0 +1,295 @@
# TGLock v2: переписал обход Telegram с нуля — теперь работает на маке, и один прокси на всю квартиру
> **Это черновик статьи, а не документация.** Цифры в нём описывают код на
> момент написания: «два файла, 350 строк, четыре платформы». Сейчас это 2872
> строки Rust (из них около 1140 — тесты), семь файлов и три платформы плюс
> headless-бинарь. Актуальное описание — [README](README.md) и
> [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md).
**Простой · 7 мин · Rust · Open source · macOS · Сетевые технологии**
**TL;DR:** Полмесяца назад я выложил TGLock — обход блокировки Telegram через WebSocket-туннель. Статья залетела на 183K просмотров. А потом всё сломалось. Соединения рвались через 2 минуты, DC определялся неправильно, маководы плакали в комментах. Переписал с нуля. 350 строк. Работает на macOS, Windows, Linux. Один прокси — все устройства в квартире. Код: [github.com/by-sonic/tglock](https://github.com/by-sonic/tglock).
---
## Что случилось после первой статьи
Первая версия TGLock делала простую вещь: SOCKS5-прокси заворачивал MTProto в WebSocket через `web.telegram.org`. Провайдер видит HTTPS — Telegram работает. Концепция правильная. Реализация — нет.
Через неделю после релиза прилетело:
> «Работает 2 минуты, потом Telegram пишет ошибку прокси»
> «По умолчанию выбирает неправильное сетевое подключение, вешается на VMware-адаптер»
> «Порт 1080 занят, как поменять?»
> «А на маке будет?»
Последний вопрос задавали чаще всего. GoodbyeDPI — только Windows. Zapret — есть tpws, но это терминал и ручная настройка. GUI для обхода Telegram на маке — **не существует**. Вообще.
Решил: не патчить старый код. Переписать с нуля.
## Что было не так с v1
### Обрыв через 2 минуты
Главный баг. WebSocket-серверы Telegram (`kws*.web.telegram.org`) шлют **Ping-фреймы** каждые ~60 секунд. Если клиент не отвечает Pong — сервер закрывает соединение.
В v1 я использовал `split()` из `futures` чтобы разделить WebSocket-стрим на два потока — чтение и запись. Красиво, идиоматично, по учебнику. И сломано.
Ping приходит в read-поток. Pong нужно отправить через write-поток. Между ними — channel или shared state. В теории работает. На практике — Pong опаздывает на десятки миллисекунд, сервер считает клиента мёртвым.
**Решение в v2:** один `tokio::select!` цикл, без split. WebSocket остаётся единым объектом. `biased` приоритизирует чтение WS — Pong улетает мгновенно:
```rust
loop {
tokio::select! {
biased;
msg = ws.next() => match msg {
Some(Ok(Message::Binary(data))) => {
tcp_w.write_all(data.as_ref()).await?;
tcp_w.flush().await?;
}
Some(Ok(Message::Ping(p))) => {
ws.send(Message::Pong(p)).await?;
}
_ => break,
},
n = tcp_r.read(&mut buf) => match n {
Ok(0) | Err(_) => break,
Ok(n) => { ws.send(Message::Binary(buf[..n].to_vec())).await?; }
},
}
}
```
Обратите внимание на `flush()` после каждого write в TCP. Без него tokio буферизует данные, Telegram Desktop ждёт ответ, не дожидается, переподключается. Ещё один баг v1, который маскировался под «нестабильное соединение».
### Неправильный DC
В v1 DC определялся по IP-адресу. Таблица маппинга из документации Telegram:
```
149.154.160-163 → DC1
149.154.164-167 → DC2
91.108.56-59 → DC5
...
```
Проблема: в подсети `149.154.164-167` живут **и DC2, и DC4**. Telegram Desktop мог коннектиться к IP, который по таблице выглядит как DC2, а на самом деле хочет DC4. Прокси открывает WebSocket к `kws2.web.telegram.org`, отправляет данные — сервер дропает соединение. Пользователь видит «прокси не настроен».
**Решение в v2:** не угадывать DC по IP. Telegram Desktop использует **obfuscated2** транспорт. Первые 64 байта — зашифрованный init-пакет. Внутри — настоящий DC ID.
Ключ: байты `[8..40]`, IV: `[40..56]`. Алгоритм: AES-256-CTR. DC ID: `i32` в байтах `[60..64]` расшифрованного пакета.
```rust
fn dc_from_init(init: &[u8; 64]) -> Option<u8> {
use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher};
let mut dec = *init;
let mut c = ctr::Ctr128BE::<Aes256>::new(
init[8..40].into(),
init[40..56].into(),
);
c.apply_keystream(&mut dec);
let id = i32::from_le_bytes([dec[60], dec[61], dec[62], dec[63]]);
let dc = id.unsigned_abs() as u8;
(1..=5).contains(&dc).then_some(dc)
}
```
12 строк. Три крейта (`aes`, `ctr`, `cipher`). Зато DC определяется **точно**, а не «скорее всего».
IP-маппинг остался как fallback — на случай если init-пакет повреждён (что на практике не случается).
### Привязка к Windows
v1 использовала:
- `netsh` для смены DNS
- Хардкоженный путь к шрифтам Windows
- `taskkill` для остановки процессов
- `ipconfig /flushdns`
Ни одна из этих вещей не нужна для WebSocket-прокси. DNS менять не надо — `web.telegram.org` резолвится нормально. Новый интерфейс работает в нативной оболочке Tauri 2 и использует системную типографику. Весь старый платформо-специфичный код был мусором.
**v2: 0 строк платформо-специфичного кода.** Один и тот же бинарник компилируется на Windows, macOS и Linux без единого `#[cfg(target_os)]`.
## macOS: почему это важно
Среди разработчиков, дизайнеров, людей из IT — процент маководов огромный. А инструментов для обхода блокировки Telegram на маке — ноль целых, ноль десятых.
- **GoodbyeDPI** — Windows only. Даже не обсуждается.
- **Zapret** — есть `tpws` для macOS, но это CLI. Нужно: `brew install`, `sudo`, правка конфигов, ручная настройка системного прокси через `networksetup`. Для техничных людей — ОК. Для остальных — нет.
- **VPN** — работает, но гонит весь трафик. Для одного Telegram — оверкилл за $5/мес.
TGLock v2: скачал бинарник, запустил, нажал кнопку. Всё. Никакого `brew`, никакого `sudo`, никаких конфигов.
Для macOS собирается единое universal-приложение: один пакет работает и на Apple Silicon, и на Intel. Сборка выполняется автоматически в GitHub Actions.
### Gatekeeper
Apple блокирует неподписанные приложения. Developer ID стоит $99/год. Для бесплатного open-source — не вариант. Решение стандартное:
```bash
xattr -cr ~/Downloads/tglock-macos-arm64
chmod +x ~/Downloads/tglock-macos-arm64
```
Две команды, один раз.
## LAN-режим: один прокси на всю квартиру
Это была самая частая просьба в ишью:
> «Цель: пустить все домашние устройства с Telegram через один такой прокси на компе в локальной сети»
В v1 прокси слушал `127.0.0.1:1080` — только локально. Устройства в сети не могли подключиться.
В v2 — чекбокс **LAN** в интерфейсе. Включаешь — прокси биндится на `0.0.0.0`. Все устройства в домашней сети могут использовать прокси.
```
Телефон ──┐
Планшет ───┤── SOCKS5 → 192.168.1.42:1080 ──► TGLock ──► WSS → DC
Ноутбук ───┘
```
Приложение автоматически определяет LAN IP и показывает его в интерфейсе. На телефоне в настройках Telegram: SOCKS5, адрес — IP компьютера, порт — тот что в приложении.
Один компьютер. Все устройства. Без VPN, без роутера, без конфигов.
### Настраиваемый порт
Ещё один ишью: «порт 1080 занят другим сервисом».
Теперь порт можно менять прямо в интерфейсе. По умолчанию 1080, но если занят — ставишь любой другой. Порт валидируется при старте, в настройках Telegram автоматически отображается текущий.
## Архитектура v2
Два файла. 350 строк. Всё.
### proxy.rs (~160 строк)
```
TcpListener (0.0.0.0 | 127.0.0.1 : port)
SOCKS5 handshake
├── IP ∈ Telegram? ──► read 64-byte init
│ │
│ ▼
│ dc_from_init (AES-256-CTR)
│ │
│ ▼
│ WSS kws{dc}.web.telegram.org
│ │
│ ▼
│ select! { ws ⟷ tcp }
└── IP ∉ Telegram? ──► direct TCP relay
```
Никаких абстракций, traits, generics. Прямолинейный async-код. Каждое соединение — один `tokio::spawn`, один `select!` цикл.
### main.rs (~190 строк)
GUI на Tauri 2 с TypeScript-интерфейсом. Настройки и диагностика открываются как внутренние страницы одного окна; статистика в реальном времени показывает активные соединения, WS-туннели, текущий DC и аптайм.
Кнопка «Настроить автоматически» — открывает Telegram через `tg://socks?server=...&port=...`. Один клик до рабочего Telegram.
## CI/CD: бинарники для всех
GitHub Actions при пуше тега `v*` собирает:
| Файл | Платформа |
|---|---|
| `tglock.exe` | Windows x64 |
| `tglock-macos-arm64` | macOS Apple Silicon |
| `tglock-macos-x64` | macOS Intel |
| `tglock-linux-x64` | Linux x64 |
Четыре платформы, один workflow, автоматические релизы. Скачал — запустил — работает.
```yaml
strategy:
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: tglock.exe
- os: macos-latest
target: aarch64-apple-darwin
artifact: tglock-macos-arm64
- os: macos-latest
target: x86_64-apple-darwin
artifact: tglock-macos-x64
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: tglock-linux-x64
```
## Что изменилось: v1 vs v2
| | v1 | v2 |
|---|---|---|
| DC detection | IP-маппинг (ненадёжен) | AES-256-CTR из init (точно) |
| Ping/Pong | Игнорировался → обрыв через 2 мин | `biased` select → мгновенный Pong |
| flush() | Нет → потеря данных | Явный flush после каждого write |
| WS timeout | Нет → бесконечное зависание | 10 секунд |
| Платформы | Windows | Windows, macOS, Linux |
| DNS | Менял системный DNS | Не трогает |
| Адаптеры | Определял сетевой адаптер (баг с VMware) | Не определяет, не нужно |
| Порт | Хардкод 1080 | Настраиваемый |
| LAN | Нет | Чекбокс, 0.0.0.0 |
| Файлов | 4 модуля + bat-скрипт | 2 файла |
| Строк | ~800 | ~350 |
Половину кода удалил. Стало стабильнее.
## Сравнение с альтернативами (2026)
| | GoodbyeDPI | Zapret | VPN | **TGLock v2** |
|---|---|---|---|---|
| Подход | Фрагментация | Desync | Туннель | WebSocket |
| IP-шейпинг | Не обходит | Не обходит | Обходит | **Обходит** |
| macOS | Нет | CLI | Да | **GUI** |
| Нужен сервер | Нет | Нет | Да | **Нет** |
| Весь трафик | Нет | Нет | Да | **Только Telegram** |
| LAN-шаринг | Нет | Можно настроить | Да | **Чекбокс** |
| Стоимость | 0₽ | 0₽ | $3–10/мес | **0₽** |
## Цифры v2
- **350** строк кода
- **2** файла
- **4** платформы (Win x64, macOS ARM64, macOS x64, Linux x64)
- **0** строк платформо-специфичного кода
- **0** серверов
- **0₽**
## Скачать
**[github.com/by-sonic/tglock](https://github.com/by-sonic/tglock)** → [Releases](https://github.com/by-sonic/tglock/releases/latest)
Или собрать:
```bash
git clone https://github.com/by-sonic/tglock.git
cd tglock
cargo build --release
```
macOS: после скачивания `xattr -cr tglock-macos-arm64 && chmod +x tglock-macos-arm64`
**P.S.** Для полного обхода блокировок (YouTube, Discord, Instagram и всё остальное) — **[RoseVPN](https://t.me/rosevpnru_bot)**.
---
*by sonic*
**Теги:** telegram, rust, websocket, macos, socks5, mtproto, обход блокировок, open-source, кроссплатформенность
**Хабы:** Rust · Open source · macOS · Сетевые технологии
-307
View File
@@ -1,307 +0,0 @@
# Как я написал обход блокировки Telegram на Rust — без VPN, без серверов, через WebSocket
**Простой · 7 мин · Rust · Open source · Windows · Сетевые технологии · Из песочницы**
**TL;DR:** Написал open-source десктопное приложение **TG Unblock** на Rust, которое в один клик обходит блокировку Telegram через локальный WebSocket-прокси. Трафик заворачивается в обычный HTTPS к `web.telegram.org` — DPI не видит MTProto, провайдер не может шейпить. Без VPN, без серверов, без абонентки. Код на GitHub — [by-sonic/tglock](https://github.com/by-sonic/tglock).
---
## Предыстория: почему GoodbyeDPI не спасает
С весны 2025 года Telegram в России стал работать, мягко говоря, через боль. Сообщения доходят по 10 секунд, медиа не грузятся, звонки рвутся. Классическая картина: провайдер + DPI = страдания.
Первое, что приходит в голову — **GoodbyeDPI**. Запустил, пакеты фрагментируются, DPI не узнаёт MTProto... и вроде работает. Но:
- **Пинг 200+ мс** — при норме 40–60
- **Постоянные переподключения** — DPI переобучается и режет соединения
- **IP-шейпинг** — провайдер троттлит весь трафик к подсетям Telegram (149.154.x.x, 91.108.x.x)
GoodbyeDPI обманывает DPI на уровне пакетов, но **не решает проблему IP-шейпинга**. Если провайдер тупо режет скорость ко всем IP Telegram — хоть как фрагментируй, будет медленно.
VPN — вариант. Но:
- Платные стоят денег и сливают скорость
- Бесплатные сливают данные
- Не все работают стабильно
- Для одного Telegram гонять весь трафик через VPN — оверкилл
Нужно решение, которое **маскирует сам факт подключения к Telegram**, а не просто прячет протокол.
## Идея: WebSocket-туннель через web.telegram.org
Я провёл серию тестов. Прямое подключение к серверам Telegram (149.154.167.51:443) — либо таймаут, либо 200+ мс. А вот `web.telegram.org` отвечает стабильно за 50–80 мс через HTTPS. Провайдер его не трогает — это же «обычный сайт».
И тут я полез в [документацию MTProto](https://core.telegram.org/mtproto/transports) и нашёл золотую жилу:
> **WebSocket:** Implementation of the WebSocket transport is pretty much the same as with TCP... all data received and sent through WebSocket messages is to be treated as a single duplex stream of bytes, just like with TCP.
Telegram **официально поддерживает WebSocket-транспорт**. Серверы `pluto.web.telegram.org`, `venus.web.telegram.org` и т.д. — это не просто веб-клиент. Это **полноценные точки входа в сеть Telegram** через WSS.
Схема:
```
Telegram Desktop
▼ SOCKS5
┌──────────────────┐
│ TG Unblock │ 127.0.0.1:1080
│ WS-прокси │
└──────┬───────────┘
├── IP Telegram? ──► WSS к {dc}.web.telegram.org/apiws
│ (провайдер видит: HTTPS к web.telegram.org)
└── Другой IP? ────► Прямой TCP (без изменений)
```
Провайдер видит:
- Соединение к `venus.web.telegram.org` по порту 443
- Обычный TLS/HTTPS трафик
- Никакого MTProto
DPI видит:
- Ничего подозрительного
- Обычный WebSocket внутри HTTPS
Результат:
- **Полная скорость** — провайдер не шейпит web.telegram.org
- **Нет переподключений** — DPI не трогает HTTPS
- **Нулевая задержка** — нет промежуточных серверов, трафик идёт напрямую к Telegram
## Реализация: Rust, SOCKS5, WebSocket
### Почему Rust?
Не Electron. Не Python. Не Node.js. **Rust.** Потому что:
- Один бинарник ~6 МБ, без зависимостей
- Нативная скорость — прокси не должен добавлять задержку
- Async I/O через tokio — тысячи одновременных соединений
- Компилируется, запускается, работает
### Архитектура
Приложение состоит из 4 модулей:
| Модуль | Что делает |
|---|---|
| `main.rs` | GUI на egui + управление прокси |
| `ws_proxy.rs` | SOCKS5-сервер + WebSocket-туннель |
| `bypass.rs` | DNS-настройка, системные утилиты |
| `network.rs` | Сетевая диагностика |
### SOCKS5 → WebSocket: как это работает
Когда Telegram Desktop подключается через SOCKS5-прокси, происходит следующее:
**1. SOCKS5 handshake**
```rust
// Клиент: [0x05, 0x01, 0x00] — SOCKS5, 1 метод, no auth
// Сервер: [0x05, 0x00] — принято
// Клиент: [0x05, 0x01, 0x00, 0x01, IP, PORT] — CONNECT к IP:PORT
```
**2. Определение DC по IP**
Telegram использует фиксированные подсети для каждого Data Center. Из [документации](https://core.telegram.org/mtproto/transports):
```rust
fn telegram_dc(ip: Ipv4Addr) -> Option<u8> {
let o = ip.octets();
match (o[0], o[1]) {
(149, 154) => Some(match o[2] {
160..=163 => 1, // DC1
164..=167 => 2, // DC2
168..=171 => 3, // DC3
172..=175 => 1, // DC1 alt
_ => 2,
}),
(91, 108) => Some(match o[2] {
56..=59 => 5, // DC5
8..=11 => 3, // DC3
12..=15 => 4, // DC4
_ => 2,
}),
(91, 105) => Some(2),
(185, 76) => Some(2),
_ => None,
}
}
```
**3. WebSocket-туннель**
Каждый DC имеет именованный WebSocket-эндпоинт (имена из официальной документации Telegram):
| DC | Имя | URL |
|---|---|---|
| 1 | Pluto | `wss://pluto.web.telegram.org/apiws` |
| 2 | Venus | `wss://venus.web.telegram.org/apiws` |
| 3 | Aurora | `wss://aurora.web.telegram.org/apiws` |
| 4 | Vesta | `wss://vesta.web.telegram.org/apiws` |
| 5 | Flora | `wss://flora.web.telegram.org/apiws` |
Обязательный заголовок (из доки Telegram): `Sec-WebSocket-Protocol: binary`.
```rust
let mut request = ws_url.as_str().into_client_request()?;
request.headers_mut().insert(
"Sec-WebSocket-Protocol", "binary".parse()?,
);
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
request, None, false, Some(connector),
).await?;
```
**4. Двунаправленный relay**
Ключевая цитата из документации Telegram:
> All data received and sent through WebSocket messages is to be treated as a **single duplex stream of bytes**, just like with TCP.
Это значит, что нам не нужно парсить MTProto. Просто relay байтов: TCP → WebSocket binary frame, WebSocket binary frame → TCP.
```rust
let up = async {
let mut buf = vec![0u8; 32768];
loop {
match tcp_rx.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let msg = Message::Binary(buf[..n].to_vec());
if ws_tx.send(msg).await.is_err() { break; }
}
Err(_) => break,
}
}
};
let down = async {
while let Some(Ok(msg)) = ws_rx.next().await {
if let Message::Binary(data) = msg {
if tcp_tx.write_all(&data).await.is_err() { break; }
}
}
};
tokio::select! { _ = up => {}, _ = down => {} }
```
### GUI: egui, не Electron
Нативный GUI через `egui` / `eframe`. Никакого браузера, никакого DOM, никакого JavaScript. Вся отрисовка — immediate mode, 60 FPS.
Кнопка «Запустить обход» делает:
1. Меняет DNS на Cloudflare (1.1.1.1) — обходит DNS-блокировку
2. Запускает SOCKS5-прокси на 127.0.0.1:1080
3. Предлагает автонастройку Telegram через `tg://socks?server=127.0.0.1&port=1080`
Кнопка «Настроить автоматически» — открывает Telegram Desktop с готовой конфигурацией прокси. Один клик.
## Технические детали, которые пришлось решить
### Проблема 1: Не-Telegram трафик
Если Telegram Desktop пускает через SOCKS5 не только MTProto, но и запросы к CDN, стикер-серверам, обновлениям — их нельзя заворачивать в WebSocket. Решение: проверяем IP по маппингу Telegram-подсетей. Telegram IP → WebSocket. Всё остальное → прямой TCP passthrough.
### Проблема 2: Определение DC
Telegram Desktop использует obfuscated2 транспорт. Первые 64 байта — зашифрованный хендшейк, в котором закодирован DC ID. Парсить его — целый проект.
Решение проще: определяем DC по destination IP. Telegram использует фиксированные подсети для каждого DC — маппинг стабильный и документированный.
### Проблема 3: TLS к WebSocket-эндпоинтам
WebSocket-соединение идёт через WSS (TLS). Используем `native-tls` — системные сертификаты Windows, без привязки к OpenSSL.
```rust
let connector = tokio_tungstenite::Connector::NativeTls(
native_tls::TlsConnector::new()?,
);
```
### Проблема 4: Graceful shutdown
При остановке прокси нужно:
- Сбросить DNS обратно на DHCP
- Корректно закрыть все WebSocket-соединения
- Не оставить Telegram без связи
Используем `AtomicBool` для флага остановки — все задачи проверяют его и завершаются.
## Сравнение с альтернативами
| | GoodbyeDPI | Zapret | VPN | **TG Unblock** |
|---|---|---|---|---|
| Подход | Фрагментация пакетов | Desync пакетов | Туннель через сервер | WebSocket-туннель |
| DPI видит MTProto? | Нет | Нет | Нет | **Нет** |
| IP-шейпинг? | Не обходит | Не обходит | Обходит | **Обходит** |
| Нужен сервер? | Нет | Нет | Да | **Нет** |
| Скорость | Зависит от DPI | Зависит от DPI | Зависит от сервера | **Полная** |
| Весь трафик? | Нет | Нет | Да | **Только Telegram** |
| Стоимость | Бесплатно | Бесплатно | $3–10/мес | **Бесплатно** |
## Стек
| Технология | Зачем |
|---|---|
| **Rust** | Скорость, один бинарник, без зависимостей |
| **egui / eframe** | Нативный GUI без браузера |
| **tokio** | Async I/O, тысячи соединений |
| **tokio-tungstenite** | WebSocket-клиент с TLS |
| **native-tls** | Системные сертификаты Windows |
| **GitHub Actions** | CI/CD — автобилд при новом теге |
## Цифры
- **5 DC** — полный маппинг всех Telegram Data Center
- **1 бинарник** — ~6 МБ, без зависимостей
- **0 серверов** — всё работает локально
- **0₽** — полностью бесплатно и open-source
- **1 клик** — от запуска до работающего Telegram
## Как попробовать
### Скачать готовый .exe
1. Скачайте `tg_unblock.exe` из [Releases](https://github.com/by-sonic/tglock/releases)
2. Запустите (желательно от администратора — для DNS)
3. Нажмите **«Запустить обход»**
4. Нажмите **«Настроить автоматически»**
5. В Telegram нажмите «Подключить»
### Собрать из исходников
```bash
git clone https://github.com/by-sonic/tglock.git
cd tglock
cargo build --release
# Бинарник: target/release/tg_unblock.exe
```
## Что дальше
- **Автоопределение DC из obfuscated2** — парсинг первых 64 байт для точного маппинга
- **Fallback на GoodbyeDPI** — если WebSocket-эндпоинт недоступен
- **Linux / macOS** — porability через tokio + egui (уже почти готово)
- **Статистика** — скорость, задержка, количество туннелей в реальном времени
## Вместо заключения
Telegram — это не просто мессенджер. Для миллионов людей это рабочий инструмент, канал связи, источник информации. Когда он работает через боль — страдают все.
GoodbyeDPI — отличный инструмент, но у него есть потолок. Когда DPI побеждён, а трафик всё равно шейпится — нужен другой подход. WebSocket-туннель через `web.telegram.org` — это как проехать мимо камеры на легальной машине вместо того, чтобы заклеивать номера.
Код полностью открыт. Если пригодился — поставьте звезду на GitHub. Если нашли баг — PR приветствуются.
**GitHub:** [github.com/by-sonic/tglock](https://github.com/by-sonic/tglock)
**P.S.** Если нужен полный обход блокировок для всех приложений (YouTube, Discord, Instagram и др.) — попробуйте [by sonic VPN](https://t.me/bysonicvpn_bot). Быстрый, стабильный, без ограничений скорости.
---
*by sonic*
**Теги:** telegram, dpi bypass, websocket, rust, socks5, mtproto, обход блокировок, open-source
**Хабы:** Rust · Open source · Windows · Сетевые технологии
+493 -118
View File
@@ -1,159 +1,534 @@
<!-- ════════════════════════ ROSEVPN — sponsor ════════════════════════ -->
<p align="center">
<h1 align="center">TG Unblock</h1>
<p align="center">
<b>Обход блокировки Telegram через WebSocket-туннель</b><br>
Без VPN. Без серверов. Без абонентки. Один клик.
</p>
<p align="center">
<a href="https://github.com/by-sonic/tglock/releases"><img src="https://img.shields.io/github/v/release/by-sonic/tglock?style=for-the-badge&color=blue" alt="Release"></a>
<a href="https://github.com/by-sonic/tglock/blob/main/LICENSE"><img src="https://img.shields.io/github/license/by-sonic/tglock?style=for-the-badge" alt="License"></a>
<a href="https://github.com/by-sonic/tglock/stargazers"><img src="https://img.shields.io/github/stars/by-sonic/tglock?style=for-the-badge&color=yellow" alt="Stars"></a>
<img src="https://img.shields.io/badge/rust-1.70%2B-orange?style=for-the-badge&logo=rust" alt="Rust">
<img src="https://img.shields.io/badge/platform-Windows-0078D6?style=for-the-badge&logo=windows" alt="Windows">
</p>
<a href="https://t.me/rosevpnru_bot">
<img src="https://img.shields.io/badge/%F0%9F%8C%B9%20RoseVPN-%D0%9F%D0%BE%D0%BF%D1%80%D0%BE%D0%B1%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D0%B1%D0%B5%D1%81%D0%BF%D0%BB%D0%B0%D1%82%D0%BD%D0%BE-E63946?style=for-the-badge&logo=telegram&logoColor=white&labelColor=0a0a0a" height="44" alt="RoseVPN — попробовать бесплатно в Telegram"/>
</a>
</p>
<p align="center">
<b>Быстрый VPN для России</b> — YouTube без буферизации, Discord/Instagram/ChatGPT снова работают.<br/>
<sub>Подключение в Telegram через <a href="https://t.me/rosevpnru_bot"><b>@rosevpnru_bot</b></a> — бесплатный пробный период, без регистрации, без карты.</sub>
</p>
<p align="center">
<a href="https://t.me/rosevpnru_bot"><img alt="YouTube — без буферов" src="https://img.shields.io/badge/YouTube-%D0%B1%D0%B5%D0%B7%20%D0%B1%D1%83%D1%84%D0%B5%D1%80%D0%BE%D0%B2-E63946?style=flat-square&logo=youtube&logoColor=white"></a>
<a href="https://t.me/rosevpnru_bot"><img alt="Discord — голос работает" src="https://img.shields.io/badge/Discord-%D0%B3%D0%BE%D0%BB%D0%BE%D1%81%20%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%B0%D0%B5%D1%82-E63946?style=flat-square&logo=discord&logoColor=white"></a>
<a href="https://t.me/rosevpnru_bot"><img alt="Instagram — открывается" src="https://img.shields.io/badge/Instagram-%D0%BE%D1%82%D0%BA%D1%80%D1%8B%D0%B2%D0%B0%D0%B5%D1%82%D1%81%D1%8F-E63946?style=flat-square&logo=instagram&logoColor=white"></a>
<a href="https://t.me/rosevpnru_bot"><img alt="ChatGPT — доступен" src="https://img.shields.io/badge/ChatGPT-%D0%B4%D0%BE%D1%81%D1%82%D1%83%D0%BF%D0%B5%D0%BD-E63946?style=flat-square&logo=openai&logoColor=white"></a>
</p>
---
## Что это?
<!-- ═════════════════════════════ TGLOCK ═════════════════════════════ -->
**TG Unblock** — десктопное приложение на Rust, которое обходит блокировку Telegram через локальный WebSocket-прокси. Провайдер видит обычный HTTPS к `web.telegram.org`, а не MTProto — DPI не может обнаружить и заблокировать трафик.
<div align="center">
### Почему не GoodbyeDPI / Zapret?
# 🔓 TGLock
| | GoodbyeDPI | Zapret | **TG Unblock** |
|---|---|---|---|
| Метод | Фрагментация пакетов | Desync пакетов | WebSocket-туннель |
| DPI видит MTProto? | Нет (обфускация) | Нет (desync) | **Нет (обычный HTTPS)** |
| IP-шейпинг обходит? | Нет | Нет | **Да** |
| Скорость | Зависит от DPI | Зависит от DPI | **Полная** |
| Переподключения | Возможны | Возможны | **Нет** |
| Настройка | Много параметров | Стратегии | **Один клик** |
### Обход блокировки Telegram через WebSocket-туннель
## Скачать
**Один клик. Без VPN. Без серверов. Без подписки.**
> **[Скачать последний релиз](https://github.com/by-sonic/tglock/releases)**
<p>
<a href="https://github.com/by-sonic/tglock/releases/latest"><img alt="Скачать последний релиз" src="https://img.shields.io/github/v/release/by-sonic/tglock?style=for-the-badge&color=2ea043&label=%D1%81%D0%BA%D0%B0%D1%87%D0%B0%D1%82%D1%8C"></a>
<a href="https://github.com/by-sonic/tglock/releases"><img alt="Всего загрузок" src="https://img.shields.io/github/downloads/by-sonic/tglock/total?style=for-the-badge&color=0969da&label=%D0%B7%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BE%D0%BA"></a>
<a href="https://github.com/by-sonic/tglock/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/by-sonic/tglock?style=for-the-badge&color=f5a623"></a>
</p>
Или собрать из исходников:
<p>
<img alt="Windows" src="https://img.shields.io/badge/Windows-0078D6?style=flat&logo=windows&logoColor=white">
<img alt="macOS" src="https://img.shields.io/badge/macOS-000000?style=flat&logo=apple&logoColor=white">
<img alt="Linux" src="https://img.shields.io/badge/Linux-FCC624?style=flat&logo=linux&logoColor=black">
<img alt="Rust" src="https://img.shields.io/badge/Rust-CE422B?style=flat&logo=rust&logoColor=white">
<a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/github/license/by-sonic/tglock?style=flat&color=lightgrey"></a>
</p>
</div>
> **Telegram стал тормозить или перестал открываться?** Запусти TGLock — и мессенджер снова работает. Не нужны VPN, прокси-серверы, абонентская плата или регистрация.
<p align="center">
<img src="docs/assets/tglock-v2-main.jpeg" width="420" alt="Новый интерфейс TGLock"/>
</p>
---
## 🤔 Что это и зачем
TGLock — это **локальный прокси** на твоём компьютере: принимает и MTProto, и SOCKS5. Он перехватывает соединения Telegram, заворачивает их в WebSocket и отправляет на веб-инфраструктуру Telegram — по нескольким маршрутам сразу, переключаясь на следующий, если текущий перестал отвечать. Провайдер видит обычный HTTPS.
Для CDN DC203 есть отдельный маршрут MTProto TCP к его закреплённому адресу:
этот CDN может не принимать WebSocket. Если его IP блокируется, остаётся
настроенный пользователем Worker. Диагностика показывает выбранный транспорт.
**Кому подойдёт:**
- 📱 Telegram открывается через раз, сообщения уходят с задержкой, фото и видео не грузятся
- 🛡 GoodbyeDPI, Zapret или ByeDPI больше не помогают — провайдер шейпит **по IP**
- 🍎 Нужен графический интерфейс под **macOS**
- 💻 Нужно решение для **Windows, macOS или Linux** без подписок и без своего сервера
- 🖥 Нужен вариант **для сервера или машины без монитора** — для этого есть [`tglock-cli`](#-без-графического-интерфейса-tglock-cli)
**Чего TGLock не делает** — честно, чтобы не тратить твоё время:
-**Голосовые и видеозвонки.** Они идут по UDP, а TGLock проксирует только TCP. Со звонками ничего не изменится
-**Всё, кроме Telegram.** YouTube, Discord, Instagram, ChatGPT работать не начнут: TGLock разворачивает только MTProto — протокол, который больше нигде не используется
- **Android:** экспериментальный APK и инструкция проверки — [docs/ANDROID.md](docs/ANDROID.md). Работа в фоне на физических устройствах требует проверки. **iOS:** отдельного приложения нет; доступен [LAN-режим](#-lan-режим--один-прокси-на-всю-квартиру).
**Чем отличается от VPN:** TGLock работает **только с Telegram**. Остальной трафик идёт напрямую — ничего не замедляется, мобильный трафик не расходуется впустую.
---
## ⚡ Скачать
**[👉 Последний релиз](https://github.com/by-sonic/tglock/releases/latest)**
| Платформа | Файл |
|---|---|
| **Windows 10/11** (x64) | `_x64-setup.exe` |
| **macOS** (Apple Silicon + Intel) | universal `.dmg` |
| **Linux** (x86_64) | `.deb` |
| **Linux** (x86_64, портативно) | `.AppImage` |
| **Сервер, контейнер, машина без монитора** | `tglock-cli-*` |
Все сборки весят единицы мегабайт. Исключение — `.AppImage`: он несёт своё окружение и поэтому крупный.
Дополнительные проверочные сборки доступны в артефактах GitHub Actions:
[Android ARM64 APK](docs/ANDROID.md) и [статический ARM64 CLI для OpenWrt](docs/OPENWRT.md).
Они не означают, что выпущен новый релиз.
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно не создаётся: сервер, контейнер, виртуалка, машина без монитора. Доступен начиная с `v2.0.0-beta.2`. Если ты скачал beta.2 и приложение не открывалось — это была ошибка сборки, исправлено в beta.3. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты
Так и будет. Объясню механизм и дам способы проверить, не доверяя мне на слово.
**Что ты увидишь.** На Windows — «Система Windows защитила ваш компьютер» от SmartScreen. На VirusTotal — детекты у части движков, обычно единицы из примерно шестидесяти.
**Почему.** Складываются две вещи. Установщик не подписан сертификатом, а неподписанные файлы проверяются эвристиками гораздо строже, чем подписанные. И само поведение программы — открыть локальный порт, объявить себя прокси-сервером, прописаться в настройки соединения Telegram — это ровно тот профиль, по которому эвристики ищут прокси-трояны. Программа делает именно это, только по твоей просьбе. Отличить одно от другого автоматически движок не может, поэтому и реагирует.
**Подписи не будет.** Сертификат — это ежегодный платёж, а проект бесплатный и ничего не зарабатывает. Значит предупреждение останется, и делать вид, что «скоро подпишем», я не буду.
**Как проверить вместо доверия.** Три способа, все не требуют верить мне:
1. **Сверить контрольную сумму.** GitHub публикует `sha256` каждого файла прямо на [странице релиза](https://github.com/by-sonic/tglock/releases/latest) — разверни `Assets` и увидишь digest рядом с именем. Сравни с тем, что скачалось:
```powershell
Get-FileHash .\TGLock_<версия>_x64-setup.exe -Algorithm SHA256
```
```bash
sha256sum tglock-cli-x86_64-unknown-linux-gnu # Linux
shasum -a 256 tglock-cli-universal-apple-darwin # macOS
```
Это доказывает, что файл не подменили по пути к тебе.
2. **Посмотреть, как файл собирался.** Бинарники собирает GitHub Actions из публичного коммита, лог открыт и его никто не может отредактировать задним числом. У каждого релиза на странице Actions есть свой запуск: видно, какой коммит взят и какими командами собран. Ссылка на него — в описании релиза.
3. **Собрать самому.** Для CLI это одна команда и никаких зависимостей кроме Rust:
```bash
cargo build --release --locked --no-default-features --features cli --bin tglock-cli
```
Полная сборка с интерфейсом — [ниже](#-сборка-из-исходников).
**Если этого недостаточно — не запускай.** Это нормальное решение, а не паранойя: исполняемый файл из интернета без подписи заслуживает недоверия по умолчанию. Собери из исходников или возьми [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy) — он решает ту же задачу и тоже открыт.
> **🍎 macOS:** пока сборка не нотарифицирована Apple, при первом запуске может понадобиться:
> ```bash
> xattr -cr /Applications/TGLock.app
> ```
> Это снимает карантин Gatekeeper с приложения, скачанного из GitHub.
---
## 🚀 Как пользоваться
1. **Скачай и запусти** приложение для своей системы.
2. Нажми **«Включить защиту»**.
3. TGLock откроет Telegram и предложит подключить локальный MTProto-прокси.
4. ✅ **Telegram работает.** Окно TGLock можно свернуть.
### Ручная настройка (если автоматическая не сработала)
Telegram → Настройки → **Продвинутые** → Тип соединения → **Использовать прокси** → **SOCKS5**
- Сервер: `127.0.0.1`
- Порт: `1080`
### 🏠 LAN-режим — один прокси на всю квартиру
В окне TGLock включи галочку **LAN** — приложение начнёт слушать на `0.0.0.0`. Все устройства в домашней сети (телефон, планшет, ноутбук, телевизор) смогут подключиться к `<IP-компьютера>:1080` и тоже получить рабочий Telegram.
Когда LAN-режим включён, TGLock показывает в окне **готовый адрес** вида `192.168.1.7:1080` — нажми на него, чтобы скопировать. Плюс он сам открывает ссылку `tg://proxy`, где уже подставлены адрес, порт и секрет: её достаточно переслать себе в Telegram и открыть на телефоне.
> ⚠️ На другом устройстве **не вводи `127.0.0.1`** — для него это оно само, а не твой компьютер. Нужен именно тот адрес, который показан в окне TGLock. Это же касается телефона в эмуляторе (MuMuPlayer, BlueStacks и подобных): внутри эмулятора Android — отдельная машина со своей сетью.
Если адрес нужен руками: `ipconfig` на Windows, `ip a` на Linux, `ifconfig` на macOS.
Удобно, если дома один компьютер всегда включён — он становится «домашним Telegram-роутером».
В LAN-режиме TGLock пропускает **только адреса Telegram**. Открытым SOCKS5-прокси для всего интернета он при этом не становится — иначе им бы воспользовались не только твои устройства.
> **📵 С телефона не подключается?** Открой **Диагностика** на компьютере и посмотри две цифры.
>
> - **Соединения `0` и Отклонено `0`** — телефон до компьютера не дошёл. Дело не в TGLock: проверь, что оба устройства в одной сети (телефон может сидеть на гостевом Wi-Fi или в мобильном интернете), что в роутере не включена изоляция клиентов, и что брандмауэр пускает входящие на порт TGLock.
> - **Соединения растут, Отклонено растёт** — телефон дошёл, но просит адрес, который LAN-режим не пропускает. Конкретный адрес назван в журнале событий ниже — пришли эту строку в issue.
> - **Не опознаны растёт** — телефон дошёл, но договориться не вышло. Почти всегда в Telegram на телефоне вписана ссылка от прошлого запуска, то есть другой секрет. Сверь её с той, что показана в окне сейчас.
> - **Соединения растут, Туннели `0`** — до Telegram не доходит уже сам компьютер. Это [Cloudflare Worker](docs/CLOUDFLARE_WORKER.md), а не проблема LAN.
### 🖥 Без графического интерфейса: `tglock-cli`
Для сервера, виртуалки, контейнера и машины без монитора или без 3D-ускорения. Это отдельный бинарь, в котором **нет ни Tauri, ни системного WebView** — там, где окно просто не создаётся, CLI работает.
```bash
tglock-cli # 127.0.0.1:1080, только для этого компьютера
tglock-cli --lan # 0.0.0.0:1080, только адреса Telegram
tglock-cli --bind 10.0.0.5 --port 1443 # свой адрес и порт
tglock-cli --worker my-name.workers.dev # резервный маршрут, см. docs/CLOUDFLARE_WORKER.md
tglock-cli --help # все флаги
```
#### Файл настроек
Чтобы не собирать батник с ключами при каждом запуске ([#32](https://github.com/by-sonic/tglock/issues/32)), положите рядом с бинарём `tglock.toml` — он подхватится сам. Или укажите путь: `tglock-cli --config /etc/tglock.toml`.
```toml
port = 1080
lan = true
# Секрет — вторая половина ссылки tg://proxy. Для сервиса его нужно закрепить,
# иначе после перезапуска все настроенные клиенты отвалятся. Запустите один
# раз, скопируйте из напечатанной ссылки и вставьте сюда.
secret = "dd00112233445566778899aabbccddeeff"
worker = ["my-name.workers.dev"]
```
Полный пример со всеми полями и пояснениями — [`tglock.example.toml`](tglock.example.toml).
Приоритеты: значения по умолчанию → файл → флаги. Флаг всегда сильнее файла. Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только включать: их отсутствие означает «взять из файла», а не «выключить».
Опечатка в файле — ошибка при старте с перечислением допустимых полей, а не молча проигнорированная строка. Сервис, который из-за `porrt = 1443` слушает 1080, хуже сервиса, который не запустился.
Файл с секретом внутри держите с правами `600`: это доступ к вашему прокси.
При запуске печатается готовая `tg://proxy`-ссылка — её можно открыть на любом устройстве в сети, чтобы Telegram настроился сам. Дальше в лог идёт по строке на каждое изменение состояния: сколько соединений, какой дата-центр, какой маршрут живой, сколько сбоев, сколько запросов отклонено политикой «только Telegram» и сколько клиентов не опознано. Отдельными строками отмечаются подключившиеся устройства, адреса, из-за которых был отказ, и клиенты, с которыми не удалось договориться, — по ним видно, дошёл ли телефон до сервиса вообще и не вписан ли в нём устаревший секрет.
Прав администратора не нужно: TGLock не правит ни системный DNS, ни файл `hosts` — нужные адреса Telegram зашиты в маршрутах, а TLS SNI остаётся настоящим.
`--lan` и любой другой сетевой адрес пропускают **только** адреса Telegram. Обычным SOCKS5-прокси TGLock становится исключительно по явному `--allow-direct`, и на сетевом адресе это открытый прокси для всего интернета — включайте осознанно.
#### Юнит для systemd
```ini
[Unit]
Description=TGLock — Telegram через WebSocket-туннель
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/usr/local/bin/tglock-cli --config /etc/tglock.toml
Restart=on-failure
RestartSec=5s
StateDirectory=tglock
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
```
```bash
sudo install -m755 tglock-cli-x86_64-unknown-linux-gnu /usr/local/bin/tglock-cli
sudo install -m600 tglock.example.toml /etc/tglock.toml # и отредактировать
sudo systemctl enable --now tglock
journalctl -u tglock -f
```
**Закреплённый секрет здесь обязателен**, и это не украшение: секрет — половина `tg://proxy`-ссылки. Без него он генерируется заново при каждом старте, и после первого же `systemctl restart` все настроенные клиенты перестанут подключаться. Задайте `secret` в `/etc/tglock.toml` (файл с правами `600`) либо `secret_file = "/var/lib/tglock/secret"` — тогда `StateDirectory=tglock` создаст каталог с нужными правами, а файл запишется с режимом `600`.
При старте без закреплённого секрета CLI печатает предупреждение — молча уйти в такое состояние он не даст.
Остановка по `systemctl stop` приходит как `SIGTERM` — CLI закрывает туннели и выходит с нулевым кодом, а не умирает по `SIGKILL`.
#### Docker
```dockerfile
FROM rust:1.88 AS build
WORKDIR /src
COPY . .
RUN cargo build --release --locked --no-default-features --features cli --bin tglock-cli
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/tglock-cli /usr/local/bin/tglock-cli
EXPOSE 1080
ENTRYPOINT ["tglock-cli", "--lan", "--secret-file", "/data/secret"]
```
```bash
docker run -d --name tglock -p 1080:1080 -v tglock-data:/data tglock
```
Образу не нужны ни Node.js, ни `libwebkit2gtk` — только `ca-certificates` для проверки сертификата Telegram.
---
## 🔬 Как это работает
```
Telegram Desktop / mobile (через LAN)
MTProto или SOCKS5 (127.0.0.1:1080 либо 0.0.0.0:1080)
TGLock — читает первые 64 байта
obfuscated2 init-пакета,
расшифровывает AES-256-CTR,
достаёт номер DC
WSS → kws{dc}.web.telegram.org
каскад маршрутов, см. ниже
Telegram Data Center
```
1. **Локальный прокси** принимает соединения Telegram: и MTProto (по ссылке `tg://proxy`), и SOCKS5.
2. Из первых 64 байт `obfuscated2`-пакета **расшифровывается номер DC** — AES-256-CTR, ключ в байтах `[8..40]`, IV в `[40..56]`, индекс DC — `i16` в `[60..62]`. Отрицательное значение означает медиа-соединение.
3. Основной трафик заворачивается в **WebSocket** к `kws{dc}.web.telegram.org` — это тот же домен, через который работает Telegram Web в браузере. Для CDN DC203 сначала используется обычный обфусцированный MTProto TCP к закреплённому CDN IP; его WebSocket endpoint может быть недоступен.
4. **Маршруты проверяются с ограниченным параллелизмом:** сначала запомненный или основной Telegram-маршрут, затем настроенный Worker и резервные адреса. Зависший IP не задерживает все остальные попытки. Упавший маршрут уходит в cooldown с удвоением задержки; при отказе всех маршрутов новые подключения соблюдают эту паузу. Для CDN DC203 сохраняется его собственный адрес. Системный DNS и файл `hosts` **не изменяются**: TCP-соединение идёт на выбранный IP, а TLS SNI и заголовок `Host` остаются настоящими, поэтому сертификат Telegram проверяется как обычно.
5. На прямых WebSocket-маршрутах провайдер видит **TLS-handshake к `web.telegram.org`**; на Worker-маршруте — домен Worker. Прямой CDN TCP не использует TLS и остаётся доступным для блокировки по IP.
6. Весь остальной трафик (не-Telegram) проходит **напрямую** — без замедления.
> Интерфейс различает три состояния: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«Telegram на связи»** — есть установленный транспорт: WebSocket после upgrade либо открытое TCP-соединение с CDN. Само это состояние ещё не подтверждает ответ MTProto, авторизацию аккаунта или передачу сообщений.
📖 **Архитектура 2.0, различение протоколов и честный список ограничений** — [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md). Запасной маршрут через свой Cloudflare Worker, со скриптом и пошаговой установкой — [docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md). Разбор всех issue и того, что в них было обещано зря — [docs/ISSUE_AUDIT.md](docs/ISSUE_AUDIT.md). Черновик статьи про переход v1 → v2 лежит в [HABR.md](HABR.md) — цифры там описывают код на момент написания, документацией он не является.
---
## 🆚 Сравнение с альтернативами
| | GoodbyeDPI | Zapret | AmneziaVPN | **TGLock** |
|---|:---:|:---:|:---:|:---:|
| Подход | Фрагментация пакетов | TCP/UDP desync | Полноценный VPN-туннель | **WebSocket-туннель** |
| Обходит IP-шейпинг | ❌ | ❌ | ✅ | **✅** |
| macOS (GUI) | ❌ Windows only | ❌ только CLI | ✅ | **✅** |
| Нужен сервер / подписка | ❌ | ❌ | ✅ ($) | **❌** |
| Только Telegram | ❌ | ❌ | ❌ | **✅** |
| LAN-шаринг | ❌ | сложно | ✅ | **✅ (галочка)** |
| Режим без GUI | ✅ | ✅ | ❌ | **✅ (`tglock-cli`)** |
| Размер | ~200 КБ | ~5 МБ | ~80 МБ | **2 МБ установщик, 2 МБ CLI** |
| Цена | 0 ₽ | 0 ₽ | свой сервер | **0 ₽** |
> **⚠ Когда TGLock не подойдёт:** если заблокирован не только Telegram, а ещё YouTube, Discord, Instagram или ChatGPT — обходить каждый сервис отдельно смысла нет, нужен полноценный VPN. TGLock эту задачу не решает и решать не будет: он разворачивает только MTProto.
>
> Звонки тоже не заработают — они по UDP, а TGLock проксирует только TCP.
---
## ❓ Часто задаваемые вопросы
<details>
<summary><b>Telegram заблокировали в России — это правда?</b></summary>
Полностью Telegram в РФ не заблокирован, но провайдеры **замедляют** трафик через DPI и **шейпят по IP-диапазонам** Telegram DC (149.154.160175, 91.108.48, 91.108.5659 и др.). У части пользователей мессенджер открывается через раз, видео не грузится, фото уходят минутами.
TGLock решает именно это — заворачивает Telegram-трафик в HTTPS к веб-инфраструктуре Telegram, которая под шейпинг не попадает. **Голосовые и видеозвонки он не лечит:** они идут по UDP, а TGLock проксирует только TCP.
</details>
<details>
<summary><b>Это безопасно? Что с моими данными?</b></summary>
TGLock — **локальный прокси**. Он работает только на твоём компьютере и не отправляет данные третьим сторонам. Соединение идёт к серверам Telegram через их же домен `web.telegram.org` — тот же, что использует Telegram Web в браузере. Единственное исключение — если ты сам укажешь в настройках свой Cloudflare Worker как резервный маршрут; по умолчанию это поле пустое, и никакой сторонней инфраструктуры в схеме нет.
Кода — около 2900 строк Rust (из них ~1100 приходится на тесты) и ~380 строк TypeScript на интерфейс. Всё открыто, можно прочитать и собрать самому. Бинарники в релизах собираются из этого же исходника в GitHub Actions — логи сборки публичные.
</details>
<details>
<summary><b>Чем отличается от GoodbyeDPI / Zapret / ByeDPI?</b></summary>
GoodbyeDPI, Zapret и ByeDPI **фрагментируют пакеты**, чтобы DPI не распознал MTProto. Это работает, пока провайдер блокирует *по содержимому*. Но если шейпинг идёт **по IP** (а так делают большинство крупных РФ-провайдеров с 2024–2026 — Ростелеком, МТС, Билайн, Мегафон), фрагментация не помогает: пакеты всё равно идут на «нехороший» IP и троттлятся.
TGLock же отправляет трафик на **`web.telegram.org`** — обычный HTTPS-домен, который под IP-шейпинг Telegram DC не попадает.
</details>
<details>
<summary><b>Работает ли на iPhone или Android?</b></summary>
Для Android есть [экспериментальный APK](docs/ANDROID.md): локальный MTProto-прокси
на телефоне, без VpnService. Для iOS отдельного приложения нет. Для обеих платформ
также доступны два варианта:
1. **LAN-режим на компьютере.** Включи галочку LAN, и в настройках Telegram на телефоне укажи прокси с IP компьютера. Работает, пока компьютер включён и телефон в той же сети.
2. **`tglock-cli` на своём VPS.** Headless-бинарь запускается как systemd-сервис, слушает `0.0.0.0` и пропускает только адреса Telegram — тогда телефон работает откуда угодно, а не только из дома. См. [раздел про CLI](#-без-графического-интерфейса-tglock-cli).
Проверка Android продолжается в [#9](https://github.com/by-sonic/tglock/issues/9).
Особенно важна работа после сворачивания на HyperOS и других прошивках с
ограничениями фоновых процессов; сборка APK сама по себе этого не проверяет.
</details>
<details>
<summary><b>Замедляет ли TGLock интернет?</b></summary>
Нет. Через прокси идёт **только** трафик Telegram (фильтрация по IP-диапазонам Telegram DC). YouTube, браузер, игры, торренты — всё это идёт напрямую и не замедляется. В этом главное отличие от VPN.
</details>
<details>
<summary><b>Apple ругается «приложение не проверено / нельзя открыть»</b></summary>
Сборка пока не подписана и не нотарифицирована — Apple Developer ID стоит $99 в год. Сними карантин Gatekeeper руками: перенеси приложение из `.dmg` в «Программы» и выполни в Терминале
```bash
xattr -cr /Applications/TGLock.app
```
После этого приложение запустится двойным кликом из Finder.
</details>
<details>
<summary><b>Telegram пишет «прокси не настроен» или сразу отключается</b></summary>
Открой TGLock — он должен показывать **«ПОДКЛЮЧЕНО»** и хотя бы одно активное соединение, когда Telegram пытается работать. Если соединений ноль:
- Не запущен ли уже другой прокси на порту 1080? В TGLock можно поменять порт в настройках.
- Антивирус/файрвол не блокирует localhost-подключения?
- В настройках Telegram сервер указан как `127.0.0.1`, не `localhost` — на некоторых системах это разные сетевые стеки.
- На macOS — убедись что снят Gatekeeper (`xattr -cr ...`).
</details>
<details>
<summary><b>Приложение вообще не запускается — окно не появляется</b></summary>
Так проявляется отсутствие 3D-ускорения: интерфейс построен на системном WebView, а тот без ускорения окно не создаёт. Отсюда же случаи «не работает в виртуалке», «не стартует с дефолтным драйвером Microsoft» и «нет монитора».
Начиная с **2.0.0-beta.2** TGLock сам просит у WebView программный рендер, так что на таких машинах должен запускаться. Если хочется вернуть аппаратное ускорение — запусти с переменной `TGLOCK_FORCE_GPU=1`.
Если окно всё равно не появилось, интерфейс тебе и не нужен: возьми [`tglock-cli`](#-без-графического-интерфейса-tglock-cli), которому WebView не требуется вообще. И напиши в [#10](https://github.com/by-sonic/tglock/issues/10) или [#17](https://github.com/by-sonic/tglock/issues/17), что именно за система — это как раз те ишью.
</details>
<details>
<summary><b>Порт 1080 уже занят другим приложением</b></summary>
В окне TGLock есть поле **«Порт»** — поменяй на любой свободный (например, `10800`, `1081`, `8888`). После рестарта прокси автоматически обновит deep-link для Telegram. В настройках Telegram укажи новый порт.
</details>
<details>
<summary><b>А что если провайдер заблокирует и <code>web.telegram.org</code>?</b></summary>
Это реальный риск, и TGLock 2.0 к нему подготовлен настолько, насколько может.
Маршрут не один: пробуются закреплённые IP Telegram, дублёры `kwsN-1` и системный DNS. Пока жив хотя бы один — туннель поднимается.
Если у твоего провайдера легли **все** маршруты, есть запасной выход — **свой Cloudflare Worker**. Тогда соединение идёт на твой домен `*.workers.dev`, а воркер доводит его до Telegram; блокировать его провайдеру придётся отдельно. Готовый скрипт и пошаговая установка: **[docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md)**. Нужен только аккаунт Cloudflare, бесплатного тарифа хватает, свой сервер и домен не нужны.
Признак, что пора это делать: приложение показывает «Ищем новый маршрут» и не проходит, а в диагностике туннелей 0 и растёт счётчик сбоев. Если Telegram работает — настраивать ничего не надо.
Но честно: если веб-версию Telegram заблокируют так, что её не видно и из датацентров Cloudflare, подход исчерпает себя. TGLock держится на доступности `web.telegram.org`, и никакой запас маршрутов этого не отменяет.
</details>
<details>
<summary><b>Можно ли использовать TGLock как обычный SOCKS5 для других приложений?</b></summary>
Смысла нет, и по умолчанию это запрещено.
TGLock определяет Telegram по IP получателя и заворачивает в WebSocket только его. Не-Telegram адреса он релеит напрямую — без шифрования, то есть никакой пользы для обхода в этом нет.
Поэтому такой релей разрешён **только когда прокси слушает `127.0.0.1`**, где до него дотянутся лишь процессы твоего компьютера. На `0.0.0.0` и любом сетевом адресе не-Telegram запросы отклоняются: иначе LAN-режим сделал бы из твоей машины открытый прокси для всего интернета. В `tglock-cli` это можно переопределить флагом `--allow-direct` — но на сетевом адресе ты получишь именно открытый SOCKS5, так что делай это осознанно.
</details>
<details>
<summary><b>Где скачать новые версии? Будут ли обновления?</b></summary>
Все релизы — на странице **[GitHub Releases](https://github.com/by-sonic/tglock/releases)**. При пуше тега `v*` GitHub Actions собирает и публикует установщики под Windows x64, macOS (universal) и Linux x64, плюс headless `tglock-cli` под те же три платформы. Подпишись на репозиторий (кнопка **Watch** → **Custom** → **Releases**), чтобы получать уведомления.
</details>
---
## 🛠 Стек технологий
| Технология | Зачем |
|---|---|
| **Rust** | Один бинарник, нативная скорость, без runtime-зависимостей |
| **Tauri 2** | Нативная оболочка для GUI. Опциональна: за фичей `gui`, в CLI не входит |
| **TypeScript + Vite** | Интерфейс, внутренняя навигация и строгая типизация |
| **tokio** | Async I/O, обработка сигналов для корректной остановки сервиса |
| **tokio-tungstenite** | WebSocket-клиент с rustls, ring и встроенными WebPKI roots |
| **aes** + **ctr** | Расшифровка MTProto `obfuscated2` init-пакета |
| **clap** | Разбор аргументов `tglock-cli` |
Ядро (`src/lib.rs`: разбор MTProto, каскад маршрутов, прокси) не зависит ни от Tauri, ни от оконной системы — поэтому один и тот же код обслуживает и графический интерфейс, и headless-режим.
---
## 🏗 Сборка из исходников
```bash
git clone https://github.com/by-sonic/tglock.git
cd tglock
cargo build --release
npm ci
npm run tauri build
```
Готовый `.exe` будет в `target/release/tg_unblock.exe`.
Результат — `target/release/tglock` (или `tglock.exe` на Windows).
## Как пользоваться
Минимальная версия Rust — **1.88** (`rust-version` в `Cargo.toml`, проверяется отдельной задачей в CI). На более старых тулчейнах зависимости не соберутся: часть из них требует edition 2024.
1. Запустите `tg_unblock.exe`
2. Нажмите **"Запустить обход"**
3. Нажмите **"Настроить автоматически"** — откроется Telegram, нажмите "Подключить"
4. Готово. Telegram работает на полной скорости.
### Только CLI, без графики
### Ручная настройка прокси
Если автонастройка не сработала:
**Telegram Desktop** → Настройки → Продвинутые → Тип соединения → **Использовать SOCKS5-прокси**
| Параметр | Значение |
|---|---|
| Сервер | `127.0.0.1` |
| Порт | `1080` |
| Логин | *пусто* |
| Пароль | *пусто* |
## Как это работает
```
Telegram Desktop
▼ (SOCKS5)
┌──────────────────┐
│ TG Unblock │ 127.0.0.1:1080
│ WS-прокси │
└──────┬───────────┘
▼ (определяет DC по IP)
├── Telegram IP? ──► WSS-туннель к {dc}.web.telegram.org/apiws
│ (провайдер видит обычный HTTPS)
└── Другой IP? ────► Прямое TCP-соединение (без изменений)
```bash
cargo build --release --locked --no-default-features --features cli --bin tglock-cli
```
### DC-маппинг
Ни Node.js, ни фронтенда, ни `libwebkit2gtk` для этого не нужно — при выключенной фиче `gui` Tauri и системный WebView в сборку не попадают вообще. Именно так CLI собирается на голом сервере.
Приложение автоматически определяет Data Center по IP-адресу и маршрутизирует через правильный WebSocket-эндпоинт:
### Проверки, которые гоняет CI
| DC | Подсеть | WebSocket |
|---|---|---|
| DC1 | `149.154.160.0/22` | `wss://pluto.web.telegram.org/apiws` |
| DC2 | `149.154.164.0/22` | `wss://venus.web.telegram.org/apiws` |
| DC3 | `149.154.168.0/22` | `wss://aurora.web.telegram.org/apiws` |
| DC4 | `91.108.12.0/22` | `wss://vesta.web.telegram.org/apiws` |
| DC5 | `91.108.56.0/22` | `wss://flora.web.telegram.org/apiws` |
Имена DC (`pluto`, `venus`, `aurora`, `vesta`, `flora`) — из [официальной документации MTProto](https://core.telegram.org/mtproto/transports).
## Стек
| Что | Зачем |
|---|---|
| **Rust** | Скорость, безопасность, один бинарник без зависимостей |
| **egui / eframe** | Нативный GUI без Electron, без браузера |
| **tokio** | Async I/O для высокопроизводительного проксирования |
| **tokio-tungstenite** | WebSocket-клиент с TLS |
| **native-tls** | TLS через системные сертификаты Windows |
## Структура проекта
```
tglock/
├── Cargo.toml # Зависимости
├── src/
│ ├── main.rs # GUI + управление прокси
│ ├── ws_proxy.rs # SOCKS5-сервер + WebSocket-туннель
│ ├── bypass.rs # DNS-настройка, утилиты Windows
│ └── network.rs # Сетевая диагностика
└── tg_blacklist.txt # IP-подсети и домены Telegram
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo clippy --no-default-features --features cli --all-targets -- -D warnings
cargo test --all-targets
cargo test --no-default-features --features cli --lib --bins
```
## Требования
Проверяются `obfuscated2`, выбор маршрутов и cooldown, отказы SOCKS5, сохранение и миграция секрета, двунаправленный туннель, TLS-сертификаты и SNI. `npm run test:worker` проверяет скрипт Cloudflare. Android и ARM64 CLI собираются отдельными заданиями; результаты и ограничения проверки перечислены в [аудите issues](docs/ISSUES_2026-09-19.md). Тесты с `#[ignore]` требуют живой сети.
- Windows 10/11
- [Rust 1.70+](https://rustup.rs/) (для сборки из исходников)
- Права администратора (для смены DNS, опционально)
### Свой релиз через GitHub Actions
## FAQ
Форкни репозиторий и поставь тег `v*` — `.github/workflows/release.yml` соберёт установщики под Windows x64, macOS (universal, Apple Silicon + Intel) и Linux x64, а также `tglock-cli` под те же три платформы, и опубликует их в релизе.
**Q: Это VPN?**
A: Нет. Трафик не идёт через сторонние серверы. Прокси работает локально и туннелирует только Telegram-трафик через WebSocket к официальным серверам Telegram.
---
**Q: Это безопасно?**
A: Весь код открыт. Никакой телеметрии. Никаких данных не отправляется. Соединение с Telegram остаётся end-to-end зашифрованным (MTProto).
## 🤝 Как помочь
**Q: Будет ли работать с мобильным Telegram?**
A: Пока только Telegram Desktop. Для мобильных устройств рекомендуем [by sonic VPN](https://t.me/bysonicvpn_bot).
Проект живой, PR и баг-репорты разбираются.
**Q: Замедляется ли интернет?**
A: Нет. Проксируется только трафик к серверам Telegram. Весь остальной трафик идёт напрямую.
- **Нашёл баг** — [открой issue](https://github.com/by-sonic/tglock/issues/new). Полезнее всего: ОС и версия, что показывает вкладка диагностики (маршрут, DC, число сбоев) и провайдер. Для `tglock-cli` — вывод из консоли.
- **Хочешь фичу** — тоже issue. Если её нет в планах, так и будет написано, без месяцев тишины.
- **Присылаешь PR** — перед отправкой прогони проверки выше, они те же, что в CI. Небольшие PR ревьюятся быстрее.
- **Не работает после релиза** — это регрессия, пиши сразу, такие вещи в приоритете.
## VPN для полного обхода
Известные ограничения: звонки (UDP), сервисы кроме Telegram (только MTProto),
отсутствие отдельного приложения iOS. Android пока экспериментальный;
результаты проверки устройств собираются в [#9](https://github.com/by-sonic/tglock/issues/9).
Если нужен обход блокировок для **всех** приложений (YouTube, Discord, Instagram и др.) — попробуйте **[by sonic VPN](https://t.me/bysonicvpn_bot)**. Быстрый, без ограничений скорости.
---
## Лицензия
## 📄 Лицензия
MIT — делайте что хотите.
## Автор
**by sonic** — [@bysonicvpn_bot](https://t.me/bysonicvpn_bot)
[MIT](LICENSE) — делай что хочешь. Форки, модификации, использование в коммерческих проектах — всё разрешено. Ссылка на репозиторий приветствуется, но необязательна.
---
<p align="center">
<b>Если пригодилось — поставьте ⭐ на GitHub</b>
<sub><b>by sonic</b> · <a href="https://github.com/by-sonic/tglock/issues">Issues &amp; feedback</a> · <a href="https://github.com/by-sonic/tglock/releases">Releases</a></sub>
</p>
+7
View File
@@ -0,0 +1,7 @@
fn main() {
// Only the GUI binary needs Tauri's generated context. Without this guard a
// headless build would still require the frontend bundle and the WebView
// toolchain to be present.
#[cfg(feature = "gui")]
tauri_build::build();
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "TGLock application window",
"windows": ["main"],
"permissions": ["core:default"]
}
+76
View File
@@ -0,0 +1,76 @@
# Android (experimental)
Issue #9 is implemented as a Tauri Android application sharing the current Rust
proxy engine and UI with the desktop application. The old Android PR scaffold is
retained, but its stale engine and desktop code are not imported.
## Install a test APK
Open this PR's **Android APK** check, then the workflow run's **Artifacts** section.
Download `tglock-android-arm64-debug`, unzip it, and install the `.apk` on an
ARM64 Android 7.0+ device. GitHub requires signing in to download CI artifacts.
No compiler or Android Studio is needed on the phone. The artifact expires after
14 days; maintainers can rerun the workflow to create a fresh build.
This is an automatically debug-signed test build, not a Play Store release.
Different CI runs can use different debug signing keys: if Android rejects an
update because the signatures differ, uninstall the previous test build first.
Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram
with the new link. A future production release needs a stable signing key.
1. Open TGLock and press **Включить защиту**.
2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return
to TGLock and use **Открыть Telegram** or **Скопировать ссылку**.
3. Keep LAN access off when Telegram runs on this same phone (`127.0.0.1`).
4. To stop, return through the ongoing notification and press **Выключить**.
## Lifecycle and limitations
The native foreground service starts only for an explicitly started proxy and
stops when its Rust accept loop finishes, including a normal Stop action. It is
not stopped by Activity destruction or rotation. Android 14+ declares the
`specialUse` service type, its dedicated permission, and a subtype describing the
user-controlled local proxy. Notification permission denial does not prevent the
foreground service from running; Android still exposes it in its task manager.
The service uses `START_NOT_STICKY`: after Android kills the process or the user
force-stops it, it does not restart a notification without a Rust engine. Open
TGLock and enable protection again. No boot receiver or automatic background
restart is installed. Vendor battery management and network changes may still
interrupt connections. The app does not claim to be a device-wide VPN.
The proxy secret lives in the app's private configuration directory. Desktop
installs migrate an existing valid legacy secret, preserving saved Telegram
links. The link-copy control intentionally contains this secret; the public
report-copy control includes only counters, port, route and mode.
## Build and verification
CI uses Java 17, Android SDK 36, NDK 28, Rust 1.88, and the locked npm/Rust
manifests. `npm run tauri -- android build --debug --apk --target aarch64 --ci`
bundles the frontend inside a signed APK; no development web server is required.
CI verifies the signature and the presence of each architecture's Rust library.
The x86_64 build is installed on an Android 15 emulator. The bounded smoke checks
Activity launch, process survival, and crash/ANR logs. When UIAutomator exposes
the WebView buttons, it also checks Start, five seconds in the background, Stop,
restart, and explicit force-stop/relaunch. It verifies the foreground service and
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
If buttons are inaccessible after a bounded wait, CI explicitly reports the
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
state and logcat. This does not test automatic low-memory eviction, battery
behavior, Telegram connectivity, or a physical phone.
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
machine-specific generated glue, SDK paths, native build output and signing files
remain ignored. Run the same build command locally after installing Tauri's
[Android prerequisites](https://v2.tauri.app/start/prerequisites/#android).
No physical-phone or Telegram end-to-end test has been performed by this change.
Before promoting it beyond an experimental APK, test Android 13 notification
permission grant/denial, Android 14+ service startup, Start/Stop/restart, switching
to Telegram for at least 10 minutes, rotation, Activity recreation, process death,
Wi-Fi/mobile-data handover, and restoration with the same persisted secret.
Native bridging follows [Tauri mobile plugins](https://v2.tauri.app/develop/plugins/develop-mobile/)
and uses [Tauri opener](https://v2.tauri.app/plugin/opener/) for `tg://` links.
+289
View File
@@ -0,0 +1,289 @@
# TGLock 2.0 architecture
## Сборка: ядро, GUI и headless
Крейт собирается в библиотеку и два бинаря:
- `src/lib.rs` — ядро: `mtproto`, `transport`, `proxy`, `config`. Не зависит ни
от Tauri, ни от оконной системы;
- `src/main.rs` — GUI, доступен только при включённой фиче `gui`
(`required-features`);
- `src/bin/cli.rs` — headless `tglock-cli`.
Фича `gui` включена по умолчанию и подтягивает `tauri`, `tauri-build` и `open`.
При `--no-default-features` ни Tauri, ни системный WebView, ни фронтенд в сборку
не попадают, и `build.rs` не вызывает `tauri_build`. Так TGLock собирается и
работает там, где WebView недоступен в принципе: сервер без графического
окружения, контейнер, машина без монитора. Для случаев, где WebView есть, но нет
3D-ускорения, GUI дополнительно просит программный рендер — см. Current
limitations.
## Local protocols
Один TCP-порт автоматически принимает два типа клиентов:
- MTProto proxy с постоянным 16-байтовым secret — основной режим;
- SOCKS5 без авторизации — режим совместимости.
MTProto init проверяется по secret и transport tag. Из него извлекаются DC,
признак media-соединения и тип транспорта. Для upstream создаётся новый
стандартный obfuscated2 init, а последующий поток пере-шифровывается между
локальным secret и Telegram.
### Различение протоколов
Определять протокол по первому байту нельзя. SOCKS5-приветствие начинается с
`0x05`, но MTProto init — это 64 случайных байта, и `is_reserved_init`
исключает только `0xef`, `0xee`, `0xdd`, HTTP-глаголы и заголовок TLS-записи.
Значение `0x05` попадается примерно в одном init из 256, и такое соединение
уходило в SOCKS5-ветку и умирало — снаружи это выглядит как «Telegram
отправляет сообщения через раз».
Поэтому неоднозначный первый байт разрешается так: не потребляя данные,
ожидается полный 64-байтовый init и делается попытка разобрать его под текущим
secret. Успешный разбор означает MTProto. Настоящий SOCKS5-клиент присылает
короткое приветствие и блокируется на ответе, поэтому 64 байта у него не
появятся и по истечении короткого таймаута он корректно уходит в SOCKS5-ветку.
## Политика прямого релея
Не-Telegram адреса релеятся напрямую, без шифрования, и пользы для обхода в
этом нет. Поэтому такой релей разрешён только когда прокси слушает loopback,
где до него дотягиваются лишь локальные процессы. На `0.0.0.0` и любом другом
сетевом адресе не-Telegram запросы отклоняются кодом SOCKS5 `0x02`, иначе
LAN-режим превращал бы машину в открытый прокси. Правило выражено в типе
`config::ListenConfig`, а не в условиях по месту вызова; переопределяется
только явным `--allow-direct` в CLI.
### Что считается адресом Telegram
Список сетей — опубликованный самим Telegram
(<https://core.telegram.org/resources/cidr.txt>), он лежит в `telegram_net` и
проверяется по маске префикса. До 2.0.0-beta.9 сравнивались два первых октета,
то есть «телеграмом» считались целиком `149.154.0.0/16`, `91.108.0.0/16`,
`91.105.0.0/16` и `185.76.0.0/16`, а IPv6 не распознавался вовсе. Ошибка была в
обе стороны:
- чужие адреса внутри этих `/16` уходили в MTProto-туннель и умирали там;
- настоящие адреса дата-центров по IPv6 отклонялись как посторонние.
Второе и давало «на компьютере работает, с телефона нет» (#42): на loopback
неопознанный адрес всё равно релеился напрямую, поэтому там дефект не
проявлялся, а на сетевом слушателе тот же адрес получал отказ.
Назначение делится на три вида:
| Вид | Что это | Что делаем |
|---|---|---|
| Дата-центр | IP из опубликованных сетей, v4 или v6 | заворачиваем в WebSocket |
| Веб Telegram | имя из `telegram.org`, `t.me`, `telegram.me`, `telesco.pe`, `cdn-telegram.org` | пропускаем как есть — это обычный HTTPS, а не MTProto |
| Всё остальное | — | напрямую на loopback, отказ на сетевом адресе |
Имена сопоставляются по границе метки, поэтому `telegram.org.example.com`
посторонний домен. В ограниченном режиме имя разрешается заранее, и адреса
внутри локальной сети (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`,
`100.64/10`, `fc00::/7`, `fe80::/10`) отбрасываются: назначение выбирает чужое
устройство, и DNS-ответ не должен превращать TGLock в дверь во внутреннюю сеть
этой машины.
### Отказ перестаёт быть молчаливым
Отклонённый запрос увеличивает счётчик `blocked` и один раз называет адрес в
журнале; повторы того же адреса склеиваются, чтобы не забить журнал одной
строкой. Отдельно отмечается первое подключение с каждого сетевого адреса.
Без этого «с телефона не работает» неразличимо распадалось на два случая:
телефон не дошёл до машины (сеть, брандмауэр, изоляция клиентов на роутере) —
и дошёл, но попросил адрес, который мы не пропускаем. Первый виден как ноль
соединений и ноль отказов, второй — как соединения есть, отказы растут.
Третий случай нашёлся, когда репортёр #42 прислал диагностику: у него было ноль
отказов и работающие туннели, то есть оба счётчика говорили «всё хорошо».
Клиент, который дошёл до прокси, но не сумел договориться, не попадал ни в
один из них. Соединение просто закрывалось: `active` дёргался вверх и обратно.
Теперь такие клиенты считает `unknown_clients`, и журнал называет адрес и
причину. Их две:
- MTProto-init не разбирается под текущим секретом. Почти всегда это ссылка
`tg://proxy` от прошлого запуска: секрет — её половина, и клиент со
сохранённой старой ссылкой попадает ровно сюда. Со стороны Telegram это и
есть «прокси настроен неверно и будет отключён» (#37).
- SOCKS5-приветствие не разбирается. Сюда же попадает MTProto-соединение,
ушедшее в SOCKS5-ветку по неоднозначному первому байту, если полный init не
успел прийти за `PROTOCOL_PROBE_TIMEOUT`.
Счётчик `ws_failures` от них отличается тем, что растёт после успешного
рукопожатия с клиентом: там договорились с клиентом, но не смогли с Telegram.
### Почему не поднялся туннель
`ws_failures` говорит, что каскад маршрутов упал целиком, и молчит о причине.
Текст с перечислением попыток собирался в `TransportEngine::connect` и там же
пропадал: наверх уходил `Err`, который выбрасывался в `serve`. При `туннелей 0`
и растущих сбоях отличить «провайдер режет закреплённые адреса» от «воркер
отвечает отказом» было нечем — ровно та стена, в которую упёрся репортёр #50.
Теперь причина попадает в журнал одной строкой на каждый набор отказов:
```
Не поднялся туннель до DC2: 149.154.167.51 — не отвечает (таймаут TCP);
kws2.web.telegram.org — таймаут TLS/WebSocket; my.workers.dev — рукопожатие
WebSocket: HTTP error: 403 Forbidden
```
Дедупликация журнала делает эту строку разовой: маршруты у DC стабильны, и
повтор той же комбинации отказов не пишется.
Домены Cloudflare Worker отчитываются так же. Строка, не похожая на имя хоста,
раньше отбрасывалась молча — `https://name.workers.dev/` со схемой или слэшем не
проходит `valid_domain`, маршрут не появлялся, и «воркер настроен» ничем не
отличалось от «воркера нет». Теперь отвергнутая строка называется вместе с
причиной, а принятая подтверждается: `Cloudflare Worker в списке маршрутов:
name.workers.dev`.
## Туннель: два независимых направления
Каждое клиентское соединение получает свой WebSocket-туннель, и внутри него
данные идут в обе стороны сразу. До 2.0.0-beta.12 оба направления обслуживал
один `select!` с пометкой `biased`, и это давало два дефекта, снаружи
выглядевших одинаково: «Подключено», а ничего не идёт.
`biased` опрашивает ветки строго по порядку. Пока в первой — «Telegram →
клиент» — есть данные, до второй очередь не доходит вообще. То есть при
непрерывном потоке вниз (первичная синхронизация телефона, загрузка медиа)
исходящие пакеты клиента не читались.
Второй дефект — одна задача на оба направления. `tcp_w.write_all` ждёт, пока
клиент разберёт присланное, и всё это время не опрашивается чтение от клиента.
Телефон по Wi-Fi разбирает поток медленнее, чем Telegram Desktop на той же
машине через loopback, — отсюда асимметрия «на компьютере работает, на телефоне
нет» из #42.
Для MTProto это фатально: клиент обязан слать подтверждения, а за каждым
следующим куском файла — свой `upload.getFile`. Первый запрос уходит, дальше
идёт поток вниз, и следующие запросы наверх не попадают. Загрузка встаёт при
живом туннеле, нулевых сбоях и нулевых отклонениях — ровно картина из #32.
Теперь это две независимые половины: `ws.split()` плюс `CryptoContext::split()`,
потому что шифры направлений независимы — два потока AES-CTR со своими ключами.
Ping приходит в читающую половину, а отвечает на него пишущая, через канал на
четыре слота: владелец отправляющей половины должен оставаться ровно один.
Оба дефекта закрыты тестами, которые падают на beta.11. Первый: за пять секунд
непрерывной загрузки наверх не уходит ни одного байта. Второй: клиент, не
успевающий читать, замораживает собственную отправку.
## Учёт состояния
`Stats::ws` считает **установленные** туннели: счётчик поднимается после
успешного `transport.connect`, а не перед попыткой. Иначе интерфейс
рапортовал бы «Telegram на связи», пока рукопожатие ещё перебирает маршруты по
несколько секунд каждый. Состояния «порт открыт», «идёт перебор маршрутов» и
«туннель установлен» различимы и в GUI, и в выводе CLI.
Дата-центр и маршрут пишутся **одним значением**, в момент, когда туннель
поднялся. Пока это были два независимых поля, номер писало соединение при
разборе init, а маршрут — другое соединение после рукопожатия, и при десятках
одновременных соединений в строку статуса попадала пара из разных из них.
Читалась она как «до этого DC шли этим маршрутом», хотя означала другое: в
диагностике #42 встречались строки `DC5 · Запасной Telegram IP`, а у DC5
закреплённый адрес всего один и запасного у него не бывает вовсе.
Секрет прокси — половина ссылки `tg://proxy`. Для сервиса его нужно закрепить
файлом (`--secret-file`): под `DynamicUser` и `ProtectHome` домашней папки нет,
путь по умолчанию не определяется, и секрет генерировался бы заново при каждом
старте, отключая всех уже настроенных клиентов.
## Transport cascade
Для каждого DC и отдельно для media-соединений строится список маршрутов:
1. сохранённый успешный маршрут;
2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
3. явно настроенный пользователем Cloudflare Worker;
4. дополнительные Telegram IP и варианты хоста;
5. системный DNS (кроме CDN DC203).
Поддерживаются DC1–5 и CDN DC203. Для DC203 первым используется обычный MTProto
TCP к закреплённому `91.105.192.100:443`; этот сервер может принимать MTProto,
не принимая TLS/WebSocket. Резерв через Worker соединяется с тем же IP.
WebSocket-попытки также сохраняют закреплённый адрес. DNS fallback на DC2 для DC203 запрещён:
успешный WebSocket handshake с другим DC не доставляет CDN-запрос в нужный
датацентр. Worker также получает именно IP DC203.
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
Успешный маршрут становится первым для следующего соединения того же DC и
типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
одновременно на соединение; остальные отменяются после первого успеха.
Если все маршруты на паузе, возвращается причина и время до повторной
попытки. Новое подключение клиента не обходит cooldown. Ошибка upstream
после handshake также снимает предпочтение маршрута и добавляет cooldown.
## TLS policy
Проверка сертификатов и hostname никогда не отключается. При подключении к
заданному Telegram IP TCP destination отделён от URI host: TLS продолжает
проверять сертификат настоящего `kws*.web.telegram.org`. Используется rustls
с провайдером ring и встроенными WebPKI roots; системный OpenSSL для ядра
и CLI не нужен. Собственные корневые сертификаты ОС автоматически не
подхватываются. Тесты проверяют доверенный сертификат, SNI, неверное имя
хоста и недоверенного издателя.
Это относится к TLS-маршрутам. Прямой CDN TCP — отдельный транспорт MTProto,
а не TLS с отключённой проверкой. Он доступен только для точного назначения
DC203. Для произвольных адресов такой обход политики не добавляется.
Cloudflare Worker принимается только как пользовательская настройка. TGLock
не загружает и не скрывает публичные списки чужих доменов.
## Cloudflare Worker contract
Worker должен принимать WebSocket на:
```text
/apiws?dst=<telegram-ip>&dc=<dc-id>
```
и проксировать бинарные frames в TCP `<telegram-ip>:443`. Ответ 101 отправляется
после открытия upstream TCP; таймаут/отказ — HTTP 504/502. Список назначений
фиксирован, текстовые сообщения отклоняются. Очередь входящих записей ограничена
1 МиБ и 256 сообщениями. Авторизация токеном требует собственного клиента:
штатный TGLock токен Worker пока не передаёт.
Запись в сокет Telegram обязана быть последовательной: следующий чанк уходит
после того, как записан предыдущий, и только когда писатель к этому готов
(`writer.ready`). В `worker/tglock-worker.js` этого не было — `write()`
вызывался поверх незавершённого, без backpressure. Пока в клиенте голодала
отправка, настоящего потока вверх через воркер не возникало и это не
проявлялось; после того как голодание починили, поток появился. Кто разворачивал
воркер раньше — обновите скрипт.
## Current limitations
- SNI camouflage не включена: небезопасное отключение hostname verification
из референсной реализации не переносится.
- Голосовые звонки по UDP не поддерживаются.
- **GUI зависит от WebView, а тот — от 3D-ускорения.** Начиная с
2.0.0-beta.2 приложение перед стартом Tauri само просит программный рендер:
на Windows через `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, на Linux через
`WEBKIT_DISABLE_COMPOSITING_MODE` и `WEBKIT_DISABLE_DMABUF_RENDERER`. Уже
заданные оператором значения не перезаписываются, а `TGLOCK_FORCE_GPU=1`
возвращает аппаратное ускорение. Для машин без монитора остаётся
`tglock-cli`, которому WebView не нужен вовсе (issues #10, #17).
- Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована, и
подписывать их не планируется: сертификат — ежегодный платёж, а проект
бесплатный. Часть антивирусов будет реагировать на неподписанный установщик,
который открывает локальный сокет и прописывается прокси-сервером — это тот
же профиль, по которому ищут прокси-трояны. Вместо доверия предлагаются
проверяемые пути: `sha256` каждого артефакта публикуется GitHub на странице
релиза, сборка идёт в GitHub Actions из публичного коммита с открытым логом,
CLI собирается одной командой. Подробно — в разделе README про антивирус.
- Работоспособность медиа зависит от конкретного DC аккаунта и доступности
Telegram/Cloudflare у провайдера.
- Соединение, открытое клиентом и молчащее дольше `IO_TIMEOUT` (10 секунд),
закрывается. Для клиента, открывающего соединения про запас, это норма; счётчик
«промолчали» показывает, как часто это происходит, — раньше такие соединения
не попадали никуда.
- Пулы заранее открытых WebSocket-соединений будут добавлены после измерения,
что они не создают лишнюю нагрузку и не ухудшают стабильность.
+93
View File
@@ -0,0 +1,93 @@
# Резервный маршрут через свой Cloudflare Worker
## Когда это нужно
Только в одном случае: провайдер заблокировал саму веб-инфраструктуру Telegram, и **все** обычные маршруты TGLock перестали отвечать.
Как это выглядит в приложении:
| Что видно | Что это значит | Нужен ли Worker |
|---|---|---|
| «Telegram на связи» | туннель работает | нет |
| «Ищем новый маршрут» и не проходит | маршруты перебираются и все падают | **да** |
| «Защита включена», DC не определяется | Telegram ещё не подключался | нет, открой Telegram |
| В диагностике счётчик сбоев растёт, туннелей 0 | ни один маршрут не отвечает | **да** |
В CLI то же самое видно в строке статуса: `туннелей 0 · DC не определён · сбоев 14`.
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
## Что понадобится
- аккаунт Cloudflare (бесплатного тарифа достаточно);
- 10 минут.
Ни своего сервера, ни домена, ни карты не нужно — воркер получит адрес вида `имя.твой-логин.workers.dev`.
## Установка через веб-интерфейс
1. Зайди на [dash.cloudflare.com](https://dash.cloudflare.com) → **Workers & Pages****Create application****Create Worker**.
2. Дай имя, например `tglock`. Нажми **Deploy** — сначала задеплоится заготовка, это нормально.
3. Нажми **Edit code**.
4. Удали всё содержимое редактора и вставь файл [`worker/tglock-worker.js`](../worker/tglock-worker.js) из этого репозитория целиком.
5. **Deploy**.
6. Скопируй адрес воркера. Он показан сверху и выглядит как `tglock.имя.workers.dev`**без** `https://` и без пути.
### Проверка, что воркер жив
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426). Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
> **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений.
Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют.
## Подключение в TGLock
**В приложении:** Настройки → поле **Cloudflare Worker** → вставь `tglock.имя.workers.dev` → Сохранить. Настройки меняются только при выключенной защите.
**В CLI:** флаг `--worker`, можно повторять:
```bash
tglock-cli --worker tglock.имя.workers.dev
tglock-cli --worker первый.workers.dev --worker второй.workers.dev
```
Настроенный Worker участвует в ограниченном параллельном переборе маршрутов: его проверка начинается вслед за первым Telegram-маршрутом, без ожидания всех таймаутов. Успешный маршрут запоминается. При быстром ответе Telegram Worker не нужен; при медленном прямом маршруте может победить Worker. Поэтому добавляй только свой домен или домен доверенного оператора.
## Ограничение доступа
Адрес Worker не является механизмом авторизации. Для своего клиента можно задать секрет `TGLOCK_TOKEN` в настройках Cloudflare и передавать его параметром `token`.
Штатный клиент TGLock параметр `token` пока не отправляет. **Не задавай TGLOCK_TOKEN для штатного клиента:** это приведёт к HTTP 403. При отсутствии переменной любой, кто знает домен, может использовать Worker для разрешённых адресов Telegram и расходовать лимиты твоего аккаунта.
Независимо от токена воркер соединяется **только** с семью адресами Telegram, которые запрашивает TGLock. Любой другой `dst` получает `403`, так что открытым TCP-прокси он не станет.
## Контракт
Если захочешь написать свою реализацию — вот что именно делает клиент (`src/transport.rs`):
```text
wss://<домен>/apiws?dst=<telegram-ip>&dc=<номер-dc>
Sec-WebSocket-Protocol: binary
```
- `dst` — адрес Telegram, к которому нужно подключиться по TCP на порт 443;
- `dc` — номер датацентра, для логов;
- **подпротокол `binary` обязательно нужно подтвердить в ответе** — без этого клиент разорвёт рукопожатие;
- ответ 101 означает, что TCP к разрешённому Telegram IP уже открыт; HTTP 502/504 означает отказ/таймаут этого подключения;
- дальше бинарные frames пересылаются в обе стороны без изменений;
- TLS до самого воркера обеспечивает Cloudflare.
Список допустимых `dst` совпадает с `transport::worker_allowed_destinations()`.
## Честно про проверку
Скрипт написан по контракту, вычитанному из исходников клиента, и путь с параметрами закреплён тестом `connects_through_the_documented_worker_contract` — он поднимает локальный сервер, который ведёт себя ровно так, как описано выше, и проверяет, что туннель через него поднимается и данные доходят в обе стороны.
`npm run test:worker` дополнительно исполняет настоящий файл Worker с заменой платформенных API: проверяет готовность TCP, отказ, таймаут, обе стороны передачи, порядок записи, переполнение и закрытие сокета. API `opened` и `close()` описаны в [документации Cloudflare](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/).
Эти тесты не проверяют развёрнутый Worker в Cloudflare и доступность Telegram из конкретного региона. Если ошибка остаётся, приложи версию клиента, диагностическую строку и HTTP/close-код Worker. Секрет прокси и приватные адреса Worker перед публикацией убери.
+78
View File
@@ -0,0 +1,78 @@
# Открытые issues: исправления и проверка, 19 сентября 2026
База: `8617d25`, версия `2.0.0-beta.14`. Проверены все семь открытых issues
и существующие PR #36 (Android), #49 (секрет GUI). Эта работа готовится как PR,
без слияния, публикации релиза и автоматического закрытия жалоб.
## Что изменено
| Issue | Подтверждённая проблема / выполненная работа | Проверка и предел вывода |
|---|---|---|
| #59 — 11 тысяч неудачных соединений | Все маршруты в cooldown раньше приводили к немедленной новой попытке. Теперь пауза соблюдается; зависшие соединения не блокируют резервные | Тесты времени, числа параллельных попыток, отмены проигравших. Доступность Telegram у автора issue не проверена |
| #58 — Worker, туннели 0/1 | Worker отвечал 101 до открытия TCP; ошибки после handshake терялись | Worker 502/504 и диагностические WebSocket close-коды, Rust-тесты раннего закрытия и reset. HTTP 426 проверяет только публикацию Worker |
| #50 — ни один маршрут не работает | Последовательные TCP/TLS таймауты задерживали резерв; кратковременные туннели ошибочно сохраняли предпочтение маршрута | Ограниченный параллелизм, cooldown после upstream failure, ошибки Worker. Нельзя обещать обход, когда недоступны и Telegram, и собственный Worker |
| #42 — Android через LAN | Прежние duplex-исправления уже в main; потеря ошибок upstream скрывала дальнейший отказ | Независимый Android-compatible криптографический вектор, фрагментированные init и одновременная передача 128/256 КиБ, диагностика разрывов. Нужна проверка Play Market-клиента в сети репортёра |
| #32 — медиа | DC203 fallback через DNS попадал в DC2, хотя CDN имеет свой DC и ключи; на закреплённом CDN IP WebSocket не отвечает, но обычный MTProto TCP работает | DC203 использует точный CDN IP по TCP с преобразованием transport-шифрования; Worker остаётся резервом. Проверка живым req_pq/resPQ и тесты обеих сторон. Реальные фотографии аккаунта репортёра не проверены |
| #53 — статическая aarch64 | Системный OpenSSL мешал самостоятельной musl-сборке | rustls + ring, ARM64 CI с проверкой ELF и запуском CLI, артефакт и OpenWrt-инструкция. Физический NanoPi R4S не проверен |
| #9 — Android | Старый PR отстал от main, сборка требовала ручной настройки; жизненный цикл сервиса мог останавливать прокси или оставлять ложное уведомление | Перенос актуального ядра, foreground service по состоянию прокси, APK CI, постоянный секрет в каталоге приложения. HyperOS и длительная фоновая работа требуют устройства |
Дополнительно: журнал больше не замолкает после первых 64 разных событий;
Unicode-секрет больше не вызывает panic; GUI сохраняет секрет
в каталоге приложения с миграцией прежнего файла; npm lock обновляет уязвимый
транзитивный `nanoid` без смены версии приложения.
## Протокол и доверие
- TLS проверяет имя из WebSocket URI даже при подключении к закреплённому IP.
Переход на rustls использует встроенные WebPKI roots; системные пользовательские
корневые сертификаты автоматически не импортируются.
- Прямой маршрут CDN203 использует обычный MTProto TCP к одному закреплённому
адресу, без TLS-обёртки. Ключи и шифрование содержимого Telegram не меняются;
снимается только transport-obfuscation локального прокси и накладывается
transport-obfuscation сервера. Если провайдер блокирует CDN IP, нужен Worker.
- [Telegram WebSocket](https://core.telegram.org/mtproto/transports#websocket)
является потоком байтов. Границы TCP read и WS messages не обязаны совпадать
с MTProto-пакетами; дополнительный парсер пакетов не добавлялся.
- [CDN DC](https://core.telegram.org/cdn) требует соответствующего назначения;
успешный handshake другого DC не доказывает работоспособность медиа.
- Worker остаётся опциональной инфраструктурой пользователя. Домен не является
авторизацией; `TGLOCK_TOKEN` не поддерживается штатным клиентом.
## Воспроизводимые проверки
```text
npm ci
npm run test:worker
npm run build
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked --all-targets
cargo clippy --locked --no-default-features --features cli --all-targets -- -D warnings
cargo test --locked --no-default-features --features cli --lib --bins
```
CI дополнительно проверяет MSRV 1.88, состав macOS GUI bundle, статическую
ARM64 musl-сборку, подпись/содержимое Android debug APK и запуск на Android 15
эмуляторе. Пропуск UI lifecycle отмечается отдельно от успешного запуска. Конкретные результаты
и ссылки на прогоны фиксируются в описании PR после завершения CI.
Worker: `npm run test:worker` исполняет настоящий deployment-файл с подменой
только Cloudflare API; `wrangler deploy --dry-run` проверяет сборку без публикации.
Это не тест живого Cloudflare-аккаунта. Отдельный тест Rust поднимает локальный
WebSocket и проверяет совместимость клиента с контрактом Worker.
Для проверки реального бинаря без Telegram-аккаунта добавлен
[независимый Node.js probe](LIVE_PROBE.md). Он проверяет `req_pq_multi → resPQ`,
nonce и целостность ответа; это ещё не авторизация и не скачивание медиа.
## Что нужно проверить на устройствах
Android: установка APK, старт/стоп, ссылка в Telegram, сохранение секрета после
перезапуска, передача текста/медиа, работа в фоне 15 минут и политика батареи.
OpenWrt: запуск CLI на NanoPi R4S, подключение нескольких устройств по LAN,
перезапуск сервиса с прежним секретом. Инструкции: [Android](ANDROID.md),
[OpenWrt](OPENWRT.md).
Issues о пользовательских симптомах остаются открыты до этой проверки.
Наличие исправленного дефекта и зелёных регрессий не доказывает, что в конкретной
сети отсутствует дополнительная блокировка.
+130
View File
@@ -0,0 +1,130 @@
# TGLock 2.0 issue audit
Актуальный разбор семи открытых issues и границ проверки:
[19 сентября 2026](ISSUES_2026-09-19.md). Ниже сохранены исторические записи.
Проверено 29 июля 2026 года: все 15 issues и 5 pull requests, существовавшие
в репозитории на момент аудита.
> **Статус на 30 июля 2026.** Аудит ниже оставлен как есть — это фиксация
> состояния на дату проверки. Что с тех пор сделано:
>
> - Разобраны все issues и pull requests. Открытых PR не осталось.
> - Закрыты #1#5, #8, #11, #13, #14, #19, #23 и #3с техническими
> объяснениями в самих issues.
> - #15 реализован заново поверх архитектуры 2.0 в #25: смерджить исходный PR
> было нельзя, он патчил `bypass.rs`, `network.rs` и `ws_proxy.rs`, которых
> больше нет, и правил системный DNS. Взято разделение GUI/CLI и произвольный
> bind-адрес; DNS-менеджмент и проверка root отброшены как ненужные.
> - #12 закрыт: относился к шрифту старого egui-интерфейса.
> - #10 и #17 закрыты выпуском `v2.0.0-beta.2`: GUI перед стартом просит у
> WebView программный рендер. Проверить это на машине без 3D-ускорения
> возможности не было, поэтому закрыто как «исправление выпущено», а не
> «исправлено» — репортерам предложено переоткрыть, если проблема осталась.
> Независимо от WebView работает `tglock-cli`.
> - #21 закрыт: репорт относился к сборке macOS, которой больше нет, в
> `v2.0.0-beta.2` она пересобрана универсальным `.dmg`.
> - #9 (Android) остаётся единственным открытым — backlog без сроков.
>
> Претензия из публичного обсуждения, которую нельзя закрыть кодом: инсталлятор
> не подписан, из-за чего часть антивирусов на него реагирует. Решение принято
> и зафиксировано: подписи не будет, сертификат — ежегодный платёж, а проект
> бесплатный. Вместо неё в README описан механизм срабатывания и три
> проверяемых пути — сверка `sha256` с публикуемым GitHub digest, открытый лог
> сборки в Actions и сборка из исходников одной командой.
>
> Дополнительно исправлено то, чего в issues не было: коллизия MTProto-init с
> байтом `0x05` (одно соединение из 256 уходило в SOCKS5-ветку и умирало),
> подсчёт туннеля до успешного рукопожатия, неверные подписи маршрутов в
> интерфейсе и генерация нового секрета при каждом старте сервиса. Подробности —
> в [ARCHITECTURE_V2.md](ARCHITECTURE_V2.md).
>
> Из списка «не подтверждённых обещаний» в конце документа закрыты все четыре
> пункта: формулировки про звонки и про «Подключено» приведены в соответствие с
> кодом, LAN-режим ограничен адресами Telegram на уровне типа, Cloudflare Worker
> остаётся исключительно пользовательской настройкой.
> **Статус на 19 августа 2026.** С прошлой отметки вышли beta.6, beta.7,
> beta.8 и beta.9. Что закрыто и что осталось:
>
> - **#32 (медиа).** Файл настроек `tglock.toml` сделан в beta.6 — это была
> отдельная просьба из того же issue. Заодно исправлена паника `println!` при
> закрытом stdout. Причина проблем с медиа не подтверждена: репортёр не
> прислал строку статуса в момент, когда фото не грузится. Слабое место
> названо в самом issue — у DC203 закреплён один IP, резерва по адресам для
> медиа нет.
> - **#37 («прокси настроен неверно»).** В beta.7 перестала проглатываться
> ошибка записи секрета: раньше при неудачной записи в `%APPDATA%\TGLock`
> секрет молча генерировался заново при каждом запуске, и ссылка `tg://proxy`
> переставала совпадать с сохранённой в Telegram. Это гипотеза, а не
> подтверждённый диагноз — воспроизвести случай репортёра не удалось, но
> теперь видно, тот это случай или нет.
> - **#39 (не работает).** По скриншоту: соединения есть, DC определяется,
> туннелей ноль, 678 падений маршрутов за пять минут. Наружу не отвечает ни
> один маршрут — у провайдера заблокирована веб-инфраструктура Telegram
> целиком. Кодом это не лечится, остаётся свой Cloudflare Worker. Счётчик
> падений маршрутов, по которому это стало видно, появился в beta.7.
> - **#40, #41 (свои).** В LAN-режиме окно показывает готовый адрес для других
> устройств — люди искали его в интерфейсе и вписывали `127.0.0.1`. И страж
> тега в CI, который ловит тег, поставленный не на HEAD.
> - **#42 (LAN не работает с телефона).** Нашлось в коде. Принадлежность адреса
> Telegram проверялась по двум первым октетам, то есть «телеграмом» считались
> целиком четыре `/16`, а IPv6 не распознавался вовсе. На loopback это не
> проявлялось: неопознанный адрес там всё равно релеится напрямую. На сетевом
> слушателе тот же адрес получал отказ — отсюда ровно то, что описал
> репортёр: на компьютере работает, с телефона нет. В beta.9 список сетей
> взят опубликованный Telegram, добавлены IPv6 и имена веб-инфраструктуры.
> Отказ перестал быть молчаливым: счётчик «Отклонено» и адрес в журнале.
> - **#9 (Android)** остаётся открытым. PR #36 собирает APK, но приложение
> никто ни разу не запускал — нужен человек с телефоном.
>
> Общее по трём разобранным issue: во всех трёх диагноз упирался в то, что
> программа не рассказывала о себе достаточно. Каждый выпуск с beta.7 добавлял
> не функцию, а показание прибора.
## Выводы
Главная причина жалоб «прокси подключён, но Telegram не работает» — приложение
считало успешный запуск локального SOCKS5-сервера успешным подключением к
Telegram. Единственный upstream `kws{dc}.web.telegram.org` может резолвиться в
недоступный IP или блокироваться провайдером.
TGLock 2.0 разделяет эти состояния и использует адаптивный список Telegram IP,
`kwsN`/`kwsN-1`, системный DNS и опциональный пользовательский Cloudflare
Worker. TLS SNI и WebSocket Host проверяются. Системный DNS и файл `hosts`
не изменяются.
## Классификация
| Issue | Наблюдение | Решение для 2.0 |
|---|---|---|
| #1 | Порт 1080 занят | Уже есть выбор порта; добавить автоматический подбор |
| #2, #11 | Неверный сетевой адаптер | В переписанном Rust-ядре привязка исходящего адаптера отсутствует; добавить только как расширенную настройку |
| #3 | Rust 1.75 не собирает зависимости | `Cargo.lock` зафиксирован; MSRV 1.88 документирована и проверяется в CI |
| #4 | Linux/серверный режим | Добавить headless CLI и systemd/Docker-примеры |
| #5 | macOS | Публиковать universal `.app`, затем подписанный и notarized DMG |
| #8, #19, #21, #23 | Нет подключения | Резервные маршруты, live-probe, понятная диагностика вместо ложного «Подключено» |
| #9 | Android | Не входит в desktop 2.0; LAN остаётся отдельным сценарием |
| #10, #17 | GUI не запускается без GPU/монитора | Headless CLI; отдельно проверить software rendering |
| #13 | Discord/YouTube | Вне области проекта; не смешивать с Telegram-транспортом |
| #14 | Медиа, звонки, LAN | Медиа тестировать отдельно; звонки не обещать без UDP; LAN ограничить Telegram-адресами |
## Pull requests
- #6 относится к старой Windows-реализации выбора адаптера.
- #12 относился к шрифту старого GUI. В v2 интерфейс перенесён на Tauri 2 и
использует системную типографику каждой платформы.
- #15 содержит полезное направление разделения GUI/CLI, но основан на старой
архитектуре и меняет DNS системы.
- #18 — экспериментальный Linux GUI без подтверждённого мобильного сценария.
- #7 не содержит продуктового изменения.
## Не подтверждённые обещания
- Голосовые и видеозвонки нельзя заявлять рабочими: SOCKS5 UDP Associate не
реализован, а Telegram может обходить proxy для части звонков.
- «Подключено» допустимо показывать только после успешного WebSocket handshake,
а не после открытия локального порта.
- LAN-режим не должен становиться открытым универсальным SOCKS5-прокси.
- Резерв через чужую Cloudflare-инфраструктуру нельзя включать без ясной модели
доверия, владельца, мониторинга и политики обновления endpoint-ов.
+61
View File
@@ -0,0 +1,61 @@
# Manual protocol probe
`scripts/probe_proxy.mjs` uses only Node.js built-ins and is independent of the
Rust transport helpers. It connects to an already running TGLock listener at
`127.0.0.1`, sends one unauthenticated `req_pq_multi` through a secret-protected
obfuscated2 padded-intermediate stream, and validates the returned `resPQ`, its
request nonce, and the lengths of its TL fields. It stops before creating an
authorization key. No Telegram account, API ID, API hash or login is needed.
The secret is read from an explicitly supplied file and is never printed. Use
the same secret file as the running CLI. Do not paste proxy links or secret
values into public logs.
```sh
# Offline validation first: no network access.
node scripts/probe_proxy.mjs --self-test
# Start a local CLI separately, using a persistent secret file.
tglock-cli --port 18080 --secret-file /private/path/tglock-secret
# Ordinary DC, media route, and CDN route; run each explicitly.
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc 2
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc -4 --fragment-size 7
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc 203
```
On Windows, supply the downloaded CLI executable and a Windows file path in the
same commands. Supported DC values are `1` through `5` and `203`; a negative
value requests a media route. `--timeout-ms` defaults to 15000 and is bounded at
120000. The response is bounded at 2 MiB. `--fragment-size 7` sends small writes
with 2 ms gaps to exercise stream fragmentation; TCP can still combine writes.
A successful JSON report contains `response: "resPQ"`, `nonceMatches: true`, the
requested DC, the public RSA fingerprint count, and elapsed time. Exit status 1
means connection, timeout, decryption/framing or response validation failed.
The ordinary CI suite does not run this live probe.
Success demonstrates a correctly relayed protocol exchange. It does **not**
authenticate the responding server, prove its DC identity, log into an account,
or verify message sending, media downloads or Android lifecycle. In particular,
`requestedDc` describes the request, not an independently confirmed backend.
## Isolate CDN transport failures
To distinguish an unavailable CDN WebSocket endpoint from an unavailable CDN
TCP connection, explicitly run:
```sh
node scripts/probe_proxy.mjs --direct-cdn --dc 203
```
This optional mode bypasses local TGLock and connects **only** to the pinned
Telegram CDN address `91.105.192.100:443`, using raw obfuscated2 TCP without a
proxy secret or TLS. It supports no arbitrary host. It performs the same single
unauthenticated exchange and still does not prove account or media operation.
The parser accepts trailing bytes after the complete `resPQ` TL object because
live CDN replies can include random padding in the declared message length.
Protocol references: [handshake initiation](https://core.telegram.org/mtproto/auth_key),
[obfuscated transports](https://core.telegram.org/mtproto/mtproto-transports),
and [unencrypted messages](https://core.telegram.org/mtproto/description#unencrypted-message).
+93
View File
@@ -0,0 +1,93 @@
# CLI для ARM64 / OpenWrt
Цель сборки — `aarch64-unknown-linux-musl`: ARM64 Linux, статический бинарь без
зависимости от glibc, OpenSSL или WebView. Это подходит для 64-битной прошивки
NanoPi R4S и других ARM64-роутеров. MIPS и 32-битный ARM требуют другой сборки.
Проверьте архитектуру прошивки командой `uname -m`: ожидается `aarch64`.
Workflow **Static ARM64 CLI** собирает и запускает тесты на ARM64 runner,
проверяет ELF (нет `INTERP` и `NEEDED`), запуск CLI, завершение по SIGTERM и
сохранение секрета между запусками. Артефакт содержит бинарь и SHA-256.
Release workflow прикладывает такой же проверенный артефакт к будущим релизам.
Наличие сборки в PR не означает, что уже опубликован новый релиз.
Это проверка ARM64 Linux, а не испытание конкретной прошивки OpenWrt или
доступности Telegram через вашего провайдера.
## Установка
Скачайте артефакт успешного запуска workflow нужного PR либо файл
`tglock-cli-aarch64-unknown-linux-musl` из релиза, если он там опубликован.
Сверьте SHA-256, скопируйте бинарь на роутер и выполните:
```sh
chmod 755 /usr/bin/tglock-cli
/usr/bin/tglock-cli --version
mkdir -p /etc/tglock
chmod 700 /etc/tglock
```
Создайте `/etc/tglock/tglock.toml`:
```toml
port = 1080
lan = true
secret_file = "/etc/tglock/secret"
# worker = ["your-name.workers.dev"]
```
`lan = true` нужен для телефонов и компьютеров в домашней сети. По умолчанию
CLI слушает только loopback. Секрет создаётся при первом запуске и сохраняется
в указанном файле; он не должен теряться при перезагрузке или обновлении.
```sh
chmod 600 /etc/tglock/tglock.toml
/usr/bin/tglock-cli --config /etc/tglock/tglock.toml
```
В Telegram выберите MTProto и используйте LAN-адрес роутера, порт `1080` и
секрет из напечатанной ссылки. `127.0.0.1` на телефоне означает сам телефон.
Разрешайте входящий TCP `1080` только из доверенной LAN; не публикуйте порт
в WAN. Звонки через UDP эта сборка не реализует.
## Сервис procd
После проверки ручного запуска сохраните `/etc/init.d/tglock`:
```sh
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /usr/bin/tglock-cli --config /etc/tglock/tglock.toml
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
```
```sh
chmod 755 /etc/init.d/tglock
/etc/init.d/tglock enable
/etc/init.d/tglock start
logread -e tglock
```
Обновление: остановите сервис, замените бинарь после проверки контрольной суммы,
сохраните `/etc/tglock`, запустите сервис снова. Для отмены автозапуска используйте
`/etc/init.d/tglock stop` и `/etc/init.d/tglock disable`.
## TLS и маршруты
Встроенный набор доверенных корневых сертификатов webpki обновляется вместе с
бинарём. Проверка сертификата и имени включена: при подключении к закреплённому
IP имя Telegram по-прежнему используется для SNI и проверки сертификата.
На роутере должно быть установлено правильное время.
Если все Telegram IP недоступны, нужен доступный маршрут через собственный
[Cloudflare Worker](CLOUDFLARE_WORKER.md). Статическая сборка сама по себе не
устраняет блокировку всех внешних маршрутов.
+100
View File
@@ -0,0 +1,100 @@
# Как выпускать релиз
## Подготовка через PR
1. Поднять версию согласованно в шести файлах:
- `Cargo.toml` — версия пакета `tglock`;
- `Cargo.lock` — версия только пакета `tglock`, без обновления зависимостей;
- `tauri.conf.json` — версия приложения и имён установщиков;
- `package.json` — версия frontend-пакета;
- `package-lock.json` — верхняя версия и `packages[""].version`;
- `ui/main.ts` — версия копируемого диагностического отчёта.
2. Подготовить описание изменений и проверок, отдельно указав экспериментальные
платформы и непроверенные сценарии. Android получает `versionName`/`versionCode`
из конфигурации Tauri при сборке; сгенерированный `tauri.properties` не коммитится.
3. Проверить финальные изменения PR и слить его в `main` с соблюдением обязательных
проверок ветки. Дождаться зелёных проверок и Android workflow на итоговом HEAD
`main` до создания тега. APK предыдущего PR-коммита не заменяет артефакт этого
коммита: `headSha` Android run должен совпадать с коммитом будущего тега.
## Тег и сборка
Release workflow этого репозитория допускает тег только на текущем HEAD ветки
по умолчанию. Guard сохраняет прежнюю защиту от выпуска другого коммита:
ранее расхождение тега и `main` сопровождалось ошибкой публикации
`Resource not accessible by integration`.
1. Сверить `git ls-remote origin refs/heads/main` и локальный
`git rev-parse HEAD` после перехода на финальный коммит `main`.
2. Убедиться, что выбранная версия и тег ещё не существуют, затем поставить
аннотированный тег на этот коммит:
```bash
git tag -a v2.0.0-beta.N -m "TGLock 2.0.0-beta.N"
git push origin v2.0.0-beta.N
```
3. Пока релиз собирается, не добавлять коммиты в `main`. Guard проверяет HEAD
в начале работы и не устраняет гонку после проверки.
4. Дождаться **всех** jobs Release. Workflow создаёт **draft** и сохраняет его
черновиком при загрузке GUI, CLI и ARM64. Частично загруженный выпуск не должен
становиться общедоступным до проверок.
Ручной `workflow_dispatch` запускайте на релизном теге, а не на ветке: часть
загрузчиков использует `github.ref_name` как имя релиза.
## Проверка артефактов и публикация
Зелёный workflow сам по себе недостаточен. В бетах 2 и 3 в GUI-бандл попадал
headless-бинарь, поэтому проверяются содержимое и происхождение:
- Windows: GUI `.exe` и `tglock-cli-x86_64-pc-windows-msvc.exe`.
- macOS: универсальные `.dmg`, `.app.tar.gz` и CLI.
- Linux x64: `.AppImage`, `.deb` и CLI.
- ARM64 Linux: `tglock-cli-aarch64-unknown-linux-musl` и его `.sha256`;
workflow проверяет ELF без динамических зависимостей и запуск бинаря.
- Android: скачать ARM64 debug APK из успешного Android run с `headSha`,
совпадающим с коммитом тега. Сохранить имя с версией и явной пометкой
`android-arm64-debug`, прикрепить APK к тому же draft. Указать экспериментальный
статус и ограничения debug-подписи из [ANDROID.md](ANDROID.md).
Пример проверки скачанного macOS-бандла:
```bash
gh release download vX.Y.Z --repo by-sonic/tglock -p 'TGLock_universal.app.tar.gz' -D /tmp/check
tar -xzf /tmp/check/TGLock_universal.app.tar.gz -C /tmp/check
python scripts/verify_bundle_binary.py /tmp/check/TGLock.app/Contents/MacOS/tglock
```
Проверьте версию скачанного CLI и хотя бы один реальный протокольный обмен
через него по [LIVE_PROBE.md](LIVE_PROBE.md). Зафиксируйте, какие платформы
исполнены локально, а какие проверены CI. Секрет локального прокси в заметки и
публичные артефакты не включается.
Когда набор файлов полон, подписи/контрольные суммы и версии сверены, а release
notes готовы, опубликуйте draft. Например:
```bash
gh release edit vX.Y.Z --repo by-sonic/tglock --draft=false --notes-file release-notes.md
```
Исторически `v2.0.0-beta.*` в этом репозитории публикуются с `prerelease=false`;
workflow сохраняет эту настройку. После публикации проверьте публичную страницу
релиза, ссылки скачивания и список файлов. Краткий пост об обновлении должен
ссылаться на опубликованный релиз и отделять проверенные исправления от
экспериментальных платформ.
## Если сборка или публикация упала
Сначала прочитайте ошибку и сверяйте коммит тега, `main`, workflow run и версии.
Не делайте вывод о причине только из текста `Resource not accessible by
integration`: он может относиться и к правам токена.
```bash
git ls-remote origin refs/heads/main 'refs/tags/vX.Y.Z^{}'
```
Если это ошибка инфраструктуры, повторите упавшие jobs на том же коммите.
Если нужна правка исходников, внесите её через PR и выберите новую версию для
нового тега. Опубликованные теги и бинарные артефакты не заменяйте: пользователи
должны иметь возможность воспроизвести уже выпущенную версию.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
+76
View File
@@ -0,0 +1,76 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
ndkVersion = "28.0.13004108"
namespace = "com.bysonic.tglock"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.bysonic.tglock"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging {
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+24
View File
@@ -0,0 +1,24 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Registered by class name from Rust, so no Java call site keeps it alive.
-keep class com.bysonic.tglock.TunnelPlugin { *; }
-keep class com.bysonic.tglock.TunnelArgs { *; }
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Держит процесс живым, пока приложение свёрнуто: прокси — поток в этом
же процессе, и без сервиса система выгрузит его через минуты. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Только для показа уведомления сервиса; отказ не ломает туннель. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.tglock"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TunnelService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the local Telegram proxy process alive while the app is in use" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,34 @@
package com.bysonic.tglock
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
class MainActivity : TauriActivity() {
private val requestNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// The tunnel works either way: without the permission the system simply
// hides the notification, while the foreground service still keeps the
// process alive.
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
askForNotificationPermission()
}
private fun askForNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@@ -0,0 +1,28 @@
package com.bysonic.tglock
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.Plugin
@InvokeArg
class TunnelArgs {
var enabled: Boolean = false
}
/** Called by Rust only; no WebView JavaScript interface or exported component. */
@TauriPlugin
class TunnelPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun setEnabled(invoke: Invoke) {
try {
val args = invoke.parseArgs(TunnelArgs::class.java)
if (args.enabled) TunnelService.start(activity) else TunnelService.stop(activity)
invoke.resolve()
} catch (error: Exception) {
invoke.reject(error.message ?: "Cannot change tunnel foreground service")
}
}
}
@@ -0,0 +1,102 @@
package com.bysonic.tglock
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Gives the running proxy Android foreground-service priority.
*
* The proxy itself runs on a Rust thread inside this process, so nothing here
* touches networking. This reduces background eviction while Telegram is in
* use; force-stop and vendor battery management can still end the process.
*/
class TunnelService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
// The Rust engine belongs to this process. A sticky service restart
// would display a working notification without restarting the engine.
// After process death the user explicitly starts protection again.
return START_NOT_STICKY
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.tunnel_channel_name),
// Low importance: the notification is required by the platform, not
// something the user needs to be interrupted by.
NotificationManager.IMPORTANCE_LOW,
).apply {
setShowBadge(false)
description = getString(R.string.tunnel_channel_description)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.tunnel_notification_title))
.setContentText(getString(R.string.tunnel_notification_text))
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(open)
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
private const val CHANNEL_ID = "tglock-tunnel"
private const val NOTIFICATION_ID = 1
fun start(context: Context) {
val intent = Intent(context, TunnelService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, TunnelService::class.java))
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">"TGLock"</string>
<string name="main_activity_title">"TGLock"</string>
<string name="tunnel_channel_name">Туннель</string>
<string name="tunnel_channel_description">Уведомление, которое не даёт системе выгрузить туннель, пока приложение открыто</string>
<string name="tunnel_notification_title">TGLock активен</string>
<string name="tunnel_notification_text">Пока это уведомление на месте, система не выгрузит туннель</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="background" x1="150" y1="100" x2="850" y2="930" gradientUnits="userSpaceOnUse">
<stop stop-color="#263247"/>
<stop offset="0.45" stop-color="#0C1422"/>
<stop offset="1" stop-color="#06090F"/>
</linearGradient>
<linearGradient id="orb" x1="320" y1="260" x2="710" y2="760" gradientUnits="userSpaceOnUse">
<stop stop-color="#55BBFF"/>
<stop offset="0.48" stop-color="#168EFA"/>
<stop offset="1" stop-color="#0756B0"/>
</linearGradient>
<radialGradient id="glow">
<stop stop-color="#38A9FF" stop-opacity=".5"/>
<stop offset="1" stop-color="#147AD6" stop-opacity="0"/>
</radialGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="28"/>
</filter>
</defs>
<rect x="28" y="28" width="968" height="968" rx="224" fill="url(#background)"/>
<rect x="42" y="42" width="940" height="940" rx="210" fill="none" stroke="#FFFFFF" stroke-opacity=".08" stroke-width="4"/>
<circle cx="512" cy="512" r="330" fill="url(#glow)" filter="url(#shadow)"/>
<circle cx="512" cy="512" r="268" fill="none" stroke="#4AAFFF" stroke-opacity=".15" stroke-width="5"/>
<circle cx="512" cy="512" r="220" fill="url(#orb)"/>
<circle cx="512" cy="512" r="218" fill="none" stroke="#A5D9FF" stroke-opacity=".58" stroke-width="5"/>
<path fill="#fff" d="M690 358 338 494c-24 10-24 22-5 28l90 28 35 107c4 12 3 17 15 17 9 0 13-4 19-10l44-42 92 68c17 10 29 5 33-16l61-285c6-25-10-36-32-31ZM454 544l176-111c9-6 17-2 10 3L496 568l-6 59-36-83Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070a10" />
<title>TGLock</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/ui/main.ts"></script>
</body>
</html>
+1350
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "tglock-ui",
"private": true,
"version": "2.0.0-beta.15",
"type": "module",
"scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
"dev": "vite --port 1420",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.8.0",
"typescript": "^5.8.3",
"vite": "^6.2.6"
}
}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Bounded installed-APK smoke; no Telegram account or external network needed."""
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence")
EVIDENCE.mkdir(exist_ok=True)
RESULT = {"launch": "not_run", "lifecycle": "not_run"}
def adb(*args, check=True, timeout=20):
return subprocess.run(
["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
).stdout.strip()
def wait_for(description, predicate, seconds=20):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
time.sleep(1)
raise AssertionError(f"Timed out: {description}")
def ui_dump(label):
# Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text)
def label_node(tree, label):
for node in tree.iter("node"):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
if any(name in (label, f"{label}") for name in names):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds:
x1, y1, x2, y2 = map(int, bounds.groups())
if x2 > x1 and y2 > y1:
return (x1 + x2) // 2, (y1 + y2) // 2
return None
def tap_label(label, stage):
for attempt in range(3):
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
return
time.sleep(1)
raise AssertionError(f"Visible action not found: {label}")
def service_running():
text = adb("shell", "dumpsys", "activity", "services", f"{PACKAGE}/.TunnelService")
(EVIDENCE / "services-last.txt").write_text(text, encoding="utf-8")
return "isForeground=true" in text
def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed.
try:
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer:
peer.sendall(bytes([5, 1, 0]))
return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError):
return False
def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT)
wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
))
assert adb("shell", "pidof", PACKAGE), "App process is absent"
def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
adb("install", "-r", str(apks[0]), timeout=60)
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
adb("logcat", "-c")
adb("forward", "tcp:11080", "tcp:1080")
launch()
RESULT["launch"] = "passed"
deadline = time.monotonic() + 20
tree = None
start_point = None
attempt = 0
while time.monotonic() < deadline:
try:
tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту")
if start_point is not None:
break
except (subprocess.SubprocessError, ET.ParseError):
pass
attempt += 1
time.sleep(1)
assert not service_running(), "Foreground service started without user action"
assert not proxy_ready(), "Proxy started without user action"
if tree is not None:
assert any(
node.get("class") == "android.webkit.WebView"
for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent"
if start_point is None:
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
tap_label("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5)
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
launch()
tap_label("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the
# user-stop contract, not Android's automatic low-memory process eviction.
tap_label("Включить защиту", "before-restart")
wait_for("Rust listener after restart", proxy_ready)
adb("shell", "am", "force-stop", PACKAGE)
launch()
assert not service_running() and not proxy_ready(), "Proxy silently restarted after force-stop"
RESULT["lifecycle"] = "passed: Start, background 5s, Stop, restart, force-stop"
try:
main()
except Exception as error:
RESULT["failure"] = str(error)
raise
finally:
try:
logs = adb("logcat", "-d", "-v", "threadtime")
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
if PACKAGE in crashes or f"ANR in {PACKAGE}" in logs or not adb("shell", "pidof", PACKAGE):
RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy")
finally:
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
print(json.dumps(RESULT))
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Manual, account-free probe of an ALREADY RUNNING local TGLock instance.
// Protocol sources (this does not import the Rust implementation):
// https://core.telegram.org/mtproto/auth_key
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
// https://core.telegram.org/mtproto/description#unencrypted-message
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import assert from "node:assert/strict";
const MAX_RESPONSE = 2 * 1024 * 1024;
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0]
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
DC: 1..5 or 203; negative values request the media route.
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
Reads the local proxy secret from the explicit file; never prints it.
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
This proves a protocol response, not DC identity, account operation or media downloads.
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
function options(args) {
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0 };
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
for (let i = 0; i < args.length; i++) {
const name = args[i];
if (name === "--direct-cdn") { result.directCdn = true; continue; }
const value = args[++i];
if (value === undefined) throw new Error(`Missing value for ${name}`);
if (name === "--secret-file") result.secretFile = value;
else if (numeric[name] && /^-?\d+$/.test(value)) result[numeric[name]] = Number(value);
else throw new Error(`Invalid option ${name}`);
}
if (!result.secretFile && !result.directCdn) throw new Error("--secret-file is required");
if (result.directCdn && result.dc !== 203) throw new Error("Direct CDN probe requires --dc 203");
if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) throw new Error("Invalid port");
if (![1, 2, 3, 4, 5, 203].includes(Math.abs(result.dc))) throw new Error("Unsupported DC");
if (result.timeoutMs < 100 || result.timeoutMs > 120000) throw new Error("Timeout must be 100..120000ms");
if (result.fragmentSize < 0 || result.fragmentSize > 65536) throw new Error("Fragment size must be 0..65536");
return result;
}
function makeRequest(secret, dc) {
let header;
do {
header = randomBytes(64);
} while (header[0] === 0xef || ["HEAD", "POST", "GET ", "OPTI"].includes(header.toString("ascii", 0, 4))
|| [0xeeeeeeee, 0xdddddddd, 0x02010316].includes(header.readUInt32LE()) || header.readUInt32LE(4) === 0);
header.fill(0xdd, 56, 60); // padded intermediate transport
header.writeInt16LE(dc, 60);
const salted = (key) => secret ? createHash("sha256").update(key).update(secret).digest() : Buffer.from(key);
const encrypt = createCipheriv("aes-256-ctr", salted(header.subarray(8, 40)), header.subarray(40, 56));
const reversed = Buffer.from(header.subarray(8, 56)).reverse();
const decrypt = createDecipheriv("aes-256-ctr", salted(reversed.subarray(0, 32)), reversed.subarray(32));
const wireHeader = Buffer.from(header);
encrypt.update(header).copy(wireHeader, 56, 56); // advances outgoing CTR by all 64 bytes
const nonce = randomBytes(16);
const payload = Buffer.alloc(40);
// auth_key_id = 0, client message ID divisible by four, TL payload length = 20.
const now = BigInt(Date.now());
const messageId = ((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n;
payload.writeBigUInt64LE(messageId, 8);
payload.writeUInt32LE(20, 16);
payload.writeUInt32LE(0xbe7e8ef1, 20);
nonce.copy(payload, 24);
const padded = Buffer.concat([payload, randomBytes(7)]);
const length = Buffer.alloc(4);
length.writeUInt32LE(padded.length);
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), decrypt, nonce };
}
function parseResPQ(frame, nonce) {
if (frame.length === 4) throw new Error(`MTProto transport error ${frame.readInt32LE()}`);
if (frame.length < 20 || frame.readBigUInt64LE() !== 0n) throw new Error("Expected an unencrypted MTProto response");
const length = frame.readUInt32LE(16);
if (length < 48 || length % 4 !== 0 || length > frame.length - 20) throw new Error("Invalid MTProto message length");
const body = frame.subarray(20, 20 + length);
if (body.readUInt32LE() !== 0x05162463) throw new Error("Response is not resPQ");
if (!body.subarray(4, 20).equals(nonce)) throw new Error("resPQ nonce does not match request");
// pq is at most eight bytes for this handshake, so its TL string uses the
// one-byte length encoding followed by padding to a four-byte boundary.
const pqLength = body[36];
if (pqLength < 1 || pqLength > 8) throw new Error("Invalid resPQ pq length");
const vectorOffset = 36 + Math.ceil((1 + pqLength) / 4) * 4;
if (vectorOffset + 8 > body.length || body.readUInt32LE(vectorOffset) !== 0x1cb5c415) throw new Error("Invalid RSA fingerprint vector");
const count = body.readUInt32LE(vectorOffset + 4);
// A CDN may include trailing random bytes in its declared message length.
// Validate the complete TL object fits; do not mistake padding for corruption.
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
// Deliberately do not infer DC identity from these public fingerprints.
return { response: "resPQ", nonceMatches: true, rsaFingerprintCount: count };
}
async function probe(config) {
let secret = null;
if (!config.directCdn) {
let hex = (await readFile(config.secretFile, "utf8")).trim();
if (/^dd[0-9a-f]{32}$/i.test(hex)) hex = hex.slice(2);
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
secret = Buffer.from(hex, "hex");
}
const { wire, decrypt, nonce } = makeRequest(secret, config.dc);
const started = Date.now();
return new Promise((resolve, reject) => {
const socket = createConnection(config.directCdn
? { host: "91.105.192.100", port: 443 }
: { host: "127.0.0.1", port: config.port });
let pending = Buffer.alloc(0);
let received = 0;
let finished = false;
const finish = (error, result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
socket.destroy();
if (error) reject(error);
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
};
const timer = setTimeout(() => finish(new Error("Timed out waiting for resPQ")), config.timeoutMs);
socket.on("error", (error) => finish(error));
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
socket.on("data", (chunk) => {
try {
received += chunk.length;
if (received > MAX_RESPONSE) throw new Error("Response exceeded the 2MiB limit");
pending = Buffer.concat([pending, decrypt.update(chunk)]);
while (pending.length >= 4) {
const length = pending.readUInt32LE();
if (length & 0x80000000) { // optional intermediate quick acknowledgment
pending = pending.subarray(4);
continue;
}
if (length < 4 || length > MAX_RESPONSE - 4) throw new Error("Invalid intermediate frame length");
if (pending.length < 4 + length) return;
if (length >= 8 && length <= 16 && pending.readUInt32LE(4) === 0xffffffff) {
pending = pending.subarray(4 + length); // padded intermediate quick ACK
continue;
}
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
return;
}
} catch (error) { finish(error); }
});
socket.on("connect", async () => {
try {
socket.setNoDelay(true);
const size = config.fragmentSize || wire.length;
for (let offset = 0; offset < wire.length && !finished; offset += size) {
socket.write(wire.subarray(offset, offset + size));
if (config.fragmentSize) await sleep(2);
}
} catch (error) { finish(error); }
});
});
}
function selfTest() {
// Fixed TL fixture: resPQ, request nonce 00..0f, server nonce 10..1f,
// eight-byte pq, a one-element vector of public RSA fingerprints.
const body = Buffer.from("63241605000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f08112233445566778800000015c4b51c010000008877665544332211", "hex");
const envelope = Buffer.alloc(20);
envelope.writeUInt32LE(body.length, 16);
const frame = Buffer.concat([envelope, body, Buffer.from([1, 2, 3])]);
const nonce = Buffer.from("000102030405060708090a0b0c0d0e0f", "hex");
assert.equal(parseResPQ(frame, nonce).rsaFingerprintCount, 1);
const paddedEnvelope = Buffer.from(envelope);
paddedEnvelope.writeUInt32LE(body.length + 128, 16);
assert.equal(parseResPQ(Buffer.concat([paddedEnvelope, body, Buffer.alloc(128, 0x42)]), nonce).rsaFingerprintCount, 1);
assert.throws(() => parseResPQ(frame, Buffer.alloc(16)), /nonce/);
assert.throws(() => parseResPQ(frame.subarray(0, 30), nonce), /length/);
const wrongConstructor = Buffer.from(frame);
wrongConstructor[20] = 0;
assert.throws(() => parseResPQ(wrongConstructor, nonce), /not resPQ/);
const oversizedVector = Buffer.from(frame);
oversizedVector.writeUInt32LE(65, 72);
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
console.log("Offline parser checks passed; no network connection made.");
}
try {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === "--self-test") selfTest();
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) console.log(HELP);
else console.log(JSON.stringify(await probe(options(args)), null, 2));
} catch (error) {
console.error(`Probe failed: ${error.message}`);
process.exitCode = 1;
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Проверяет, что в бандл приложения попал GUI-бинарь, а не headless CLI.
Зачем это существует. В v2.0.0-beta.2 и v2.0.0-beta.3 бандлер Tauri упаковал
`tglock-cli` как исполняемый файл приложения, и оно не запускалось ни на одной
платформе. В beta.3 имя файла было уже правильным бандлер переименовал CLI
поэтому проверка имени или `CFBundleExecutable` ничего не заметила. Отличить
можно только по содержимому.
Почему на Python, а не grep. BSD grep на macOS в UTF-8-локали молча не находит
строки в бинарных данных там, где GNU grep находит: проверка проходила на
Linux и давала ложное «не GUI» на macOS. Один скрипт для всех платформ и для
всех трёх мест, где проверка вызывается, исключает подобные расхождения.
Использование:
python3 scripts/verify_bundle_binary.py <путь-к-бинарю>
"""
import sys
# Присутствуют только в GUI: строка CSP из tauri.conf.json и имя движка WebView.
GUI_MARKERS = (b"ipc.localhost", b"wry")
# Присутствуют только в CLI: имена флагов clap.
CLI_MARKERS = (b"allow-direct", b"secret-file")
def main(argv: list[str]) -> int:
if len(argv) != 2:
print(f"использование: {argv[0]} <путь-к-бинарю>", file=sys.stderr)
return 2
path = argv[1]
try:
with open(path, "rb") as handle:
data = handle.read()
except OSError as error:
print(f"не удалось прочитать {path}: {error}", file=sys.stderr)
return 1
found_gui = [marker.decode() for marker in GUI_MARKERS if marker in data]
found_cli = [marker.decode() for marker in CLI_MARKERS if marker in data]
print(f"файл: {path}")
print(f"размер: {len(data) / 1048576:.1f} МБ")
print(f"GUI: {found_gui or 'признаков нет'}")
print(f"CLI: {found_cli or 'признаков нет'}")
if found_cli:
print("ОШИБКА: в бандле headless CLI вместо приложения", file=sys.stderr)
return 1
if not found_gui:
print("ОШИБКА: это не GUI-бинарь, признаков GUI нет", file=sys.stderr)
return 1
print("OK: в бандле GUI-бинарь")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+402
View File
@@ -0,0 +1,402 @@
//! TGLock without a graphical interface.
//!
//! Built with `--no-default-features` this binary links neither Tauri nor a
//! system WebView, so it runs on servers, in containers and on machines with no
//! GPU or monitor — the cases that make the GUI fail to start at all
//! (by-sonic/tglock#10, by-sonic/tglock#17).
use clap::Parser;
use std::net::IpAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tglock_lib::cli_settings as config;
use tglock_lib::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1);
/// Напечатать строку, вернув `false`, если stdout больше не принимает данные.
///
/// `println!` при ошибке записи паникует, а у демона stdout исчезает штатно: его
/// пускают в `head`, закрывают терминал, перезапускают сборщик логов. Падать из
/// за этого туннель не должен — он продолжает работать молча.
fn say(text: &str) -> bool {
use std::io::Write;
let mut out = std::io::stdout().lock();
writeln!(out, "{text}").and_then(|()| out.flush()).is_ok()
}
#[derive(Debug, Parser)]
#[command(
name = "tglock-cli",
version,
about = "TGLock без графического интерфейса: локальный MTProto-прокси через WebSocket"
)]
struct Args {
/// Файл настроек. Если не задан, ищется tglock.toml рядом с бинарём
#[arg(short, long, value_name = "PATH")]
config: Option<PathBuf>,
/// Адрес для прослушивания. По умолчанию 127.0.0.1 — только этот компьютер
#[arg(short, long, value_name = "IP")]
bind: Option<IpAddr>,
/// Порт локального прокси. По умолчанию 1080
#[arg(short, long, value_name = "PORT")]
port: Option<u16>,
/// То же, что --bind 0.0.0.0: доступ с других устройств в локальной сети
#[arg(long, conflicts_with = "bind")]
lan: bool,
/// Домен своего Cloudflare Worker как резервный маршрут. Можно повторять
#[arg(long, value_name = "DOMAIN")]
worker: Vec<String>,
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
#[arg(long)]
allow_direct: bool,
/// Файл с секретом прокси. Для сервиса нужен он или secret в настройках:
/// иначе после перезапуска секрет будет новым и настроенные клиенты отвалятся
#[arg(long, value_name = "PATH")]
secret_file: Option<PathBuf>,
/// Печатать только ошибки
#[arg(short, long)]
quiet: bool,
}
impl Args {
fn overrides(&self) -> config::Overrides {
config::Overrides {
bind: self.bind,
port: self.port,
lan: self.lan,
allow_direct: self.allow_direct,
worker: self.worker.clone(),
secret_file: self.secret_file.clone(),
quiet: self.quiet,
}
}
/// Файл настроек и путь, по которому он найден.
///
/// Явный `--config` обязателен к существованию: если человек указал путь и
/// опечатался, молча стартовать с настройками по умолчанию — худшее из
/// возможных поведений.
fn load_file(&self) -> Result<(config::FileConfig, Option<PathBuf>), String> {
if let Some(path) = &self.config {
return Ok((config::FileConfig::load(path)?, Some(path.clone())));
}
match config::path_next_to_executable() {
Some(path) if path.is_file() => Ok((config::FileConfig::load(&path)?, Some(path))),
_ => Ok((config::FileConfig::default(), None)),
}
}
}
fn main() -> ExitCode {
let args = Args::parse();
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(error) => {
eprintln!("tglock-cli: не удалось запустить среду выполнения: {error}");
return ExitCode::FAILURE;
}
};
match runtime.block_on(serve(args)) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("tglock-cli: {error}");
ExitCode::FAILURE
}
}
}
async fn serve(args: Args) -> Result<(), String> {
let (file, file_path) = args.load_file()?;
let settings = config::resolve(file, args.overrides())?;
let listen = settings.listen;
let stats = settings.stats();
let quiet = settings.quiet;
// Bind before printing anything: a busy port must be an error, not a
// daemon that reports success and silently does nothing.
let listener = proxy::bind(listen).await?;
if !quiet {
let intro = match &file_path {
Some(path) => format!("Настройки: {}", path.display()),
None => format!(
"Настройки: только флаги ({} рядом с бинарём не найден)",
config::DEFAULT_FILE_NAME
),
};
say(&intro);
say(&format!("Слушаю {}", listen.addr));
say(&format!(
"Ссылка для Telegram: {}",
listen.telegram_link(&stats.telegram_secret())
));
// Запись секрета могла провалиться — тогда после перезапуска ссылка
// изменится и Telegram скажет «прокси настроен неверно». Раньше это
// происходило молча (by-sonic/tglock#37).
if let Some(error) = stats.secret_write_error() {
say(&format!(
"Внимание: секрет НЕ сохранён ({error}). После перезапуска ссылка \
изменится, и Telegram откажется подключаться к старой"
));
}
if matches!(settings.secret, config::SecretSource::Ephemeral) {
say(
"Внимание: секрет не закреплён и будет новым после перезапуска — \
задайте secret в настройках или --secret-file",
);
}
if listen.allow_direct && !listen.addr.ip().is_loopback() {
say(&format!(
"Внимание: allow_direct на адресе {} превращает TGLock в открытый SOCKS5-прокси",
listen.addr.ip()
));
} else if !listen.allow_direct {
say("Пропускаю только адреса Telegram");
}
if !settings.workers.is_empty() {
say(&format!("Резервные Worker-домены: {}", settings.workers));
}
}
let server_stats = stats.clone();
let mut server =
tokio::spawn(
async move { proxy::serve(server_stats, listener, listen.allow_direct).await },
);
let watcher = (!quiet).then(|| tokio::spawn(watch_status(stats.clone())));
let outcome = tokio::select! {
joined = &mut server => joined.map_err(|error| format!("рабочая задача упала: {error}"))?,
signal = shutdown_signal() => {
signal.map_err(|error| format!("обработчик сигналов: {error}"))?;
if !quiet {
say("Получен сигнал остановки, закрываю соединения…");
}
stats.stop();
server
.await
.map_err(|error| format!("рабочая задача упала: {error}"))?
}
};
if let Some(watcher) = watcher {
watcher.abort();
}
outcome
}
/// Print a line whenever the tunnel state changes.
///
/// This is the text equivalent of the GUI diagnostics tab: without it a daemon
/// gives journald nothing to show when Telegram stops working.
async fn watch_status(stats: Arc<proxy::Stats>) {
let mut previous = None;
loop {
tokio::time::sleep(STATUS_POLL).await;
// Отдельные события — кто подключился и какой адрес отклонён. Без них
// journalctl показывает только счётчики, по которым нельзя отличить
// «телефон не дошёл» от «дошёл и получил отказ» (by-sonic/tglock#42).
for event in stats.drain_events() {
if !say(&event) {
return;
}
}
let current = (
stats.active.load(Ordering::Relaxed),
stats.ws.load(Ordering::Relaxed),
stats.last_dc(),
stats.last_route(),
stats.ws_failures.load(Ordering::Relaxed),
stats.route_failures(),
stats.blocked.load(Ordering::Relaxed),
stats.unknown_clients.load(Ordering::Relaxed),
stats.silent_clients.load(Ordering::Relaxed),
);
if previous.as_ref() == Some(&current) {
continue;
}
let (active, tunnels, dc, route, failures, route_failures, blocked, unknown, silent) =
current;
let line = format!(
"соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures} · \
падений маршрутов {route_failures} · отклонено {blocked} · не опознано {unknown} · промолчали {silent}",
if dc > 0 {
format!("DC{dc}")
} else {
"DC не определён".to_owned()
},
transport::route_label(route)
);
// Закрытый stdout — не ошибка: печатать больше некому, туннель работает
// дальше без наблюдателя.
if !say(&line) {
return;
}
previous = Some(current);
}
}
/// Ctrl+C everywhere, plus SIGTERM on unix so `systemctl stop` shuts the
/// tunnel down cleanly instead of killing it.
#[cfg(unix)]
async fn shutdown_signal() -> std::io::Result<()> {
use tokio::signal::unix::{signal, SignalKind};
let mut terminate = signal(SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result,
_ = terminate.recv() => Ok(()),
}
}
#[cfg(not(unix))]
async fn shutdown_signal() -> std::io::Result<()> {
tokio::signal::ctrl_c().await
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn parse(args: &[&str]) -> Args {
Args::try_parse_from(std::iter::once("tglock-cli").chain(args.iter().copied())).unwrap()
}
/// Итоговые настройки только из флагов, без файла.
fn from_flags(args: &[&str]) -> config::Resolved {
config::resolve(config::FileConfig::default(), parse(args).overrides()).unwrap()
}
#[test]
fn command_definition_is_valid() {
Args::command().debug_assert();
}
#[test]
fn defaults_to_loopback_on_the_default_port() {
let listen = from_flags(&[]).listen;
assert_eq!(listen.addr.to_string(), "127.0.0.1:1080");
assert!(listen.allow_direct);
}
#[test]
fn lan_flag_matches_explicit_wildcard_bind() {
assert_eq!(
from_flags(&["--lan"]).listen,
from_flags(&["-b", "0.0.0.0"]).listen
);
}
#[test]
fn lan_does_not_relay_non_telegram_traffic() {
let listen = from_flags(&["--lan"]).listen;
assert_eq!(listen.addr.to_string(), "0.0.0.0:1080");
assert!(!listen.allow_direct);
}
#[test]
fn allow_direct_is_the_only_way_to_open_a_network_listener() {
assert!(!from_flags(&["-b", "192.168.1.10"]).listen.allow_direct);
assert!(
from_flags(&["-b", "192.168.1.10", "--allow-direct"])
.listen
.allow_direct
);
}
#[test]
fn bind_and_port_are_honoured() {
let listen = from_flags(&["--bind", "10.0.0.7", "--port", "1443"]).listen;
assert_eq!(listen.addr.to_string(), "10.0.0.7:1443");
}
#[test]
fn ipv6_bind_is_accepted() {
let listen = from_flags(&["-b", "::1", "-p", "2080"]).listen;
assert_eq!(listen.addr.to_string(), "[::1]:2080");
assert!(listen.allow_direct);
}
#[test]
fn repeated_worker_flags_collapse_into_one_list() {
let settings = from_flags(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]);
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
}
#[test]
fn no_worker_flag_means_no_domains() {
assert!(from_flags(&[]).workers.is_empty());
}
#[test]
fn lan_and_explicit_bind_cannot_be_combined() {
assert!(Args::try_parse_from(["tglock-cli", "--lan", "-b", "127.0.0.1"]).is_err());
}
#[test]
fn a_pinned_secret_file_survives_a_restart() {
let path = std::env::temp_dir().join(format!(
"tglock-cli-secret-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_file(&path);
let first = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats()
.telegram_secret();
let second = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats()
.telegram_secret();
assert_eq!(
first, second,
"a restart must advertise the same tg:// secret"
);
assert!(first.starts_with("dd"));
// A corrupted file must not wedge the daemon: it is replaced.
std::fs::write(&path, "garbage").unwrap();
let third = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats()
.telegram_secret();
assert_ne!(third, first);
let fourth = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats()
.telegram_secret();
assert_eq!(third, fourth, "the replacement must be persisted in turn");
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_malformed_values() {
for bad in [
vec!["-b", "not-an-ip"],
vec!["-p", "70000"],
vec!["-p", "-1"],
vec!["--unknown"],
] {
assert!(
Args::try_parse_from(std::iter::once("tglock-cli").chain(bad.iter().copied()))
.is_err(),
"{bad:?} must be rejected"
);
}
}
}
-201
View File
@@ -1,201 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn check_admin() -> bool {
let output = Command::new("net")
.args(["session"])
.output();
matches!(output, Ok(o) if o.status.success())
}
pub fn set_dns(adapter: &str, primary: &str, secondary: &str) -> Result<(), String> {
let out1 = Command::new("netsh")
.args([
"interface", "ipv4", "set", "dnsservers",
adapter, "static", primary, "primary", "validate=no",
])
.output()
.map_err(|e| format!("netsh error: {}", e))?;
if !out1.status.success() {
let stderr = String::from_utf8_lossy(&out1.stderr);
return Err(format!("Failed to set primary DNS: {}", stderr));
}
let out2 = Command::new("netsh")
.args([
"interface", "ipv4", "add", "dnsservers",
adapter, secondary, "index=2", "validate=no",
])
.output()
.map_err(|e| format!("netsh error: {}", e))?;
if !out2.status.success() {
// Non-critical: secondary DNS may already exist
}
Ok(())
}
pub fn reset_dns(adapter: &str) -> Result<(), String> {
let out = Command::new("netsh")
.args([
"interface", "ipv4", "set", "dnsservers",
adapter, "dhcp",
])
.output()
.map_err(|e| format!("netsh error: {}", e))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!("Failed to reset DNS: {}", stderr));
}
Ok(())
}
pub fn flush_dns() {
let _ = Command::new("ipconfig")
.args(["/flushdns"])
.output();
}
pub fn find_goodbyedpi() -> Option<String> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."));
let search_dirs = vec![
exe_dir.join("tools"),
exe_dir.join("tools").join("goodbyedpi"),
exe_dir.clone(),
PathBuf::from("tools"),
PathBuf::from("tools").join("goodbyedpi"),
PathBuf::from("."),
];
for dir in &search_dirs {
// Check common locations
for sub in &["x86_64", "x86", ""] {
let candidate = if sub.is_empty() {
dir.join("goodbyedpi.exe")
} else {
dir.join(sub).join("goodbyedpi.exe")
};
if candidate.exists() {
return Some(candidate.to_string_lossy().to_string());
}
}
}
// Recursive search in tools/
if let Ok(entries) = find_file_recursive(Path::new("tools"), "goodbyedpi.exe") {
if !entries.is_empty() {
return Some(entries[0].to_string_lossy().to_string());
}
}
None
}
fn find_file_recursive(dir: &Path, filename: &str) -> Result<Vec<PathBuf>, std::io::Error> {
let mut results = Vec::new();
if !dir.exists() {
return Ok(results);
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.file_name().map(|n| n == filename).unwrap_or(false) {
results.push(path);
} else if path.is_dir() {
results.extend(find_file_recursive(&path, filename)?);
}
}
Ok(results)
}
pub fn get_blacklist_path() -> Option<String> {
let candidates = vec![
PathBuf::from("tg_blacklist.txt"),
PathBuf::from("tools").join("tg_blacklist.txt"),
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("tg_blacklist.txt")))
.unwrap_or_default(),
];
for path in candidates {
if path.exists() {
return Some(path.to_string_lossy().to_string());
}
}
None
}
pub fn start_goodbyedpi(exe_path: &str, args: &[&str], blacklist: Option<&str>) -> Result<(), String> {
let mut cmd = Command::new(exe_path);
cmd.args(args);
if let Some(bl) = blacklist {
cmd.args(["--blacklist", bl]);
}
cmd.spawn().map_err(|e| format!("Failed to start GoodbyeDPI: {}", e))?;
Ok(())
}
pub fn kill_goodbyedpi() {
let _ = Command::new("taskkill")
.args(["/f", "/im", "goodbyedpi.exe"])
.output();
}
pub fn download_goodbyedpi() -> Result<String, String> {
let tools_dir = PathBuf::from("tools");
std::fs::create_dir_all(&tools_dir)
.map_err(|e| format!("Cannot create tools dir: {}", e))?;
let zip_path = tools_dir.join("goodbyedpi.zip");
let url = "https://github.com/ValdikSS/GoodbyeDPI/releases/download/0.2.3rc3/goodbyedpi-0.2.3rc3-2.zip";
// Download using powershell
let dl_script = format!(
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing",
url,
zip_path.to_string_lossy()
);
let output = Command::new("powershell")
.args(["-Command", &dl_script])
.output()
.map_err(|e| format!("Download failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Download failed: {}", stderr));
}
// Extract
let extract_script = format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
zip_path.to_string_lossy(),
tools_dir.to_string_lossy()
);
let output = Command::new("powershell")
.args(["-Command", &extract_script])
.output()
.map_err(|e| format!("Extraction failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Extraction failed: {}", stderr));
}
// Clean up zip
let _ = std::fs::remove_file(&zip_path);
// Find the exe
find_goodbyedpi().ok_or_else(|| "goodbyedpi.exe not found after extraction".to_string())
}
+360
View File
@@ -0,0 +1,360 @@
//! Файл конфигурации для `tglock-cli`.
//!
//! Живёт в библиотеке, а не рядом с бинарём: каталожная форма `src/bin/cli/`
//! ломает перечисление бинарей в Tauri — он выводит имя приложения из имени
//! каталога и пытается упаковать несуществующий `cli`.
//!
//! Запрошен в by-sonic/tglock#32: держать все параметры и секрет в одном месте,
//! чтобы не собирать батник с ключами при каждом запуске.
//!
//! Порядок приоритетов: значения по умолчанию → файл → флаги командной строки.
//! Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только
//! включать: их отсутствие означает «взять из файла», а не «выключить».
use crate::config::ListenConfig;
use crate::{mtproto, proxy};
use serde::Deserialize;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
/// Имя файла, который ищется рядом с бинарём, если `--config` не задан.
pub const DEFAULT_FILE_NAME: &str = "tglock.toml";
/// Содержимое файла конфигурации.
///
/// `deny_unknown_fields` намеренно: опечатка вроде `porrt = 1080` должна быть
/// ошибкой при старте, а не молча проигнорированной строкой, из-за которой
/// сервис слушает не тот порт.
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub bind: Option<IpAddr>,
pub port: Option<u16>,
pub lan: Option<bool>,
pub allow_direct: Option<bool>,
pub worker: Option<Vec<String>>,
pub secret: Option<String>,
pub secret_file: Option<PathBuf>,
pub quiet: Option<bool>,
}
impl FileConfig {
pub fn parse(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|error| format!("конфиг разобрать не удалось: {error}"))
}
pub fn load(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("не удалось прочитать {}: {error}", path.display()))?;
Self::parse(&text)
}
}
/// Значения, пришедшие из командной строки.
#[derive(Debug, Default)]
pub struct Overrides {
pub bind: Option<IpAddr>,
pub port: Option<u16>,
pub lan: bool,
pub allow_direct: bool,
pub worker: Vec<String>,
pub secret_file: Option<PathBuf>,
pub quiet: bool,
}
/// Откуда взять секрет прокси.
#[derive(Debug, PartialEq)]
pub enum SecretSource {
/// Записан прямо в конфиге.
Inline([u8; 16]),
/// Лежит в отдельном файле, создаётся при первом запуске.
File(PathBuf),
/// Ни того, ни другого: секрет будет новым при каждом старте.
Ephemeral,
}
/// Итоговые настройки запуска.
#[derive(Debug, PartialEq)]
pub struct Resolved {
pub listen: ListenConfig,
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
pub workers: String,
pub secret: SecretSource,
pub quiet: bool,
}
impl Resolved {
pub fn stats(&self) -> std::sync::Arc<proxy::Stats> {
let stats = match &self.secret {
SecretSource::Inline(secret) => proxy::Stats::with_secret(*secret),
SecretSource::File(path) => {
proxy::Stats::with_stored_secret(mtproto::load_or_create_secret_at(path))
}
SecretSource::Ephemeral => proxy::Stats::new(),
};
stats.set_worker_domain(&self.workers);
stats
}
}
/// Свести файл и флаги в одни настройки.
pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
let port = cli.port.or(file.port).unwrap_or(proxy::DEFAULT_PORT);
if port == 0 {
return Err("порт должен быть от 1 до 65535".to_owned());
}
let lan = cli.lan || file.lan.unwrap_or(false);
let bind = cli.bind.or(file.bind);
if lan && bind.is_some() {
return Err("нельзя задать одновременно lan и bind: выберите одно".to_owned());
}
let listen = match (lan, bind) {
(true, _) => ListenConfig::lan(port),
(false, Some(ip)) => ListenConfig::new(ip, port),
(false, None) => ListenConfig::loopback(port),
};
let allow_direct = cli.allow_direct || file.allow_direct.unwrap_or(false);
let listen = if allow_direct {
listen.with_allow_direct(true)
} else {
listen
};
let workers = if cli.worker.is_empty() {
file.worker.unwrap_or_default()
} else {
cli.worker
};
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал
// его сюда, значит хотел держать всё в одном месте.
let secret = match (&file.secret, cli.secret_file.or(file.secret_file)) {
(Some(value), _) => SecretSource::Inline(
mtproto::parse_secret(value)
.ok_or("секрет в конфиге неверный: нужны 32 hex-символа, можно с префиксом dd")?,
),
(None, Some(path)) => SecretSource::File(path),
(None, None) => SecretSource::Ephemeral,
};
Ok(Resolved {
listen,
workers: workers.join(","),
secret,
quiet: cli.quiet || file.quiet.unwrap_or(false),
})
}
/// Путь к конфигу рядом с исполняемым файлом.
///
/// Именно этого просили в #32: «размещение рядом с бинарником частично решает
/// вопрос». Текущий каталог не используется, чтобы сервис не зависел от того,
/// откуда его запустили.
pub fn path_next_to_executable() -> Option<PathBuf> {
let executable = std::env::current_exe().ok()?;
Some(executable.parent()?.join(DEFAULT_FILE_NAME))
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "00112233445566778899aabbccddeeff";
fn cli() -> Overrides {
Overrides::default()
}
#[test]
fn empty_config_and_no_flags_give_the_documented_defaults() {
let resolved = resolve(FileConfig::default(), cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "127.0.0.1:1080");
assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "");
assert_eq!(resolved.secret, SecretSource::Ephemeral);
assert!(!resolved.quiet);
}
#[test]
fn file_values_are_used_when_no_flags_are_given() {
let file = FileConfig::parse(
r#"
bind = "10.0.0.5"
port = 1443
allow_direct = true
worker = ["a.workers.dev", "b.workers.dev"]
quiet = true
"#,
)
.unwrap();
let resolved = resolve(file, cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
assert!(resolved.quiet);
}
#[test]
fn flags_win_over_the_file() {
let file = FileConfig::parse(
r#"
bind = "10.0.0.5"
port = 1443
worker = ["from-file.workers.dev"]
"#,
)
.unwrap();
let resolved = resolve(
file,
Overrides {
bind: Some("192.168.1.7".parse().unwrap()),
port: Some(2080),
worker: vec!["from-flag.workers.dev".to_owned()],
..Overrides::default()
},
)
.unwrap();
assert_eq!(resolved.listen.addr.to_string(), "192.168.1.7:2080");
assert_eq!(resolved.workers, "from-flag.workers.dev");
}
#[test]
fn a_switch_flag_can_only_turn_things_on() {
// Отсутствие --quiet не должно отменять quiet = true из файла: иначе
// файл нельзя было бы использовать для включения ничего.
let file = FileConfig::parse("quiet = true\nallow_direct = true").unwrap();
let resolved = resolve(file, cli()).unwrap();
assert!(resolved.quiet);
assert!(resolved.listen.allow_direct);
}
#[test]
fn lan_from_the_file_restricts_to_telegram() {
let resolved = resolve(FileConfig::parse("lan = true").unwrap(), cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "0.0.0.0:1080");
assert!(
!resolved.listen.allow_direct,
"сетевой слушатель не должен релеить произвольные адреса без явного разрешения"
);
}
#[test]
fn lan_and_bind_together_are_rejected_wherever_they_come_from() {
let both_in_file = FileConfig::parse("lan = true\nbind = \"10.0.0.5\"").unwrap();
assert!(resolve(both_in_file, cli()).is_err());
let file = FileConfig::parse("bind = \"10.0.0.5\"").unwrap();
let flag_lan = Overrides {
lan: true,
..Overrides::default()
};
assert!(
resolve(file, flag_lan).is_err(),
"конфликт должен ловиться и когда стороны пришли из разных мест"
);
}
#[test]
fn inline_secret_is_accepted_in_both_written_forms() {
for value in [SECRET.to_owned(), format!("dd{SECRET}")] {
let file = FileConfig::parse(&format!("secret = \"{value}\"")).unwrap();
let resolved = resolve(file, cli()).unwrap();
assert_eq!(
resolved.secret,
SecretSource::Inline([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
0xdd, 0xee, 0xff,
]),
"форма {value} должна приниматься"
);
}
}
#[test]
fn a_broken_inline_secret_stops_the_start() {
let file = FileConfig::parse("secret = \"явно не секрет\"").unwrap();
let error = resolve(file, cli()).unwrap_err();
assert!(
error.contains("32"),
"ошибка должна объяснять формат, получено: {error}"
);
}
#[test]
fn inline_secret_wins_over_a_secret_file() {
let file =
FileConfig::parse(&format!("secret = \"{SECRET}\"\nsecret_file = \"s.bin\"")).unwrap();
let resolved = resolve(file, cli()).unwrap();
assert!(matches!(resolved.secret, SecretSource::Inline(_)));
}
#[test]
fn secret_file_from_the_flag_wins_over_the_file() {
let file = FileConfig::parse("secret_file = \"from-file.bin\"").unwrap();
let resolved = resolve(
file,
Overrides {
secret_file: Some(PathBuf::from("from-flag.bin")),
..Overrides::default()
},
)
.unwrap();
assert_eq!(
resolved.secret,
SecretSource::File(PathBuf::from("from-flag.bin"))
);
}
#[test]
fn a_typo_in_the_config_is_an_error_not_a_silent_default() {
// Самая опасная поломка конфига — та, которую не видно. Сервис не должен
// слушать 1080, если человек написал porrt = 1443.
let error = FileConfig::parse("porrt = 1443").unwrap_err();
assert!(
error.contains("porrt"),
"ошибка должна называть неизвестное поле, получено: {error}"
);
}
#[test]
fn malformed_values_are_rejected() {
for text in [
"bind = \"не адрес\"",
"port = \"1080\"",
"port = 70000",
"worker = \"строка вместо списка\"",
"lan = \"да\"",
] {
assert!(
FileConfig::parse(text).is_err(),
"{text:?} должен быть отвергнут"
);
}
}
#[test]
fn zero_port_is_rejected() {
assert!(resolve(FileConfig::parse("port = 0").unwrap(), cli()).is_err());
}
#[test]
fn comments_and_blank_lines_are_fine() {
let file = FileConfig::parse(
r#"
# порт для второго экземпляра
port = 1081
# свой воркер как резерв
worker = ["backup.workers.dev"]
"#,
)
.unwrap();
assert_eq!(file.port, Some(1081));
assert_eq!(
file.worker.as_deref(),
Some(&["backup.workers.dev".to_owned()][..])
);
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Listener configuration shared by the GUI and the CLI.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Where the local proxy listens and whether it is allowed to relay anything
/// other than Telegram.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ListenConfig {
pub addr: SocketAddr,
/// Relay non-Telegram destinations as a plain SOCKS5 proxy.
///
/// Loopback listeners get this for free because only local processes can
/// reach them. A listener the network can reach must opt in explicitly, so
/// that sharing TGLock across a flat never silently turns the machine into
/// an open SOCKS5 relay.
pub allow_direct: bool,
}
impl ListenConfig {
/// Listener with the default policy for the given address.
pub fn new(ip: IpAddr, port: u16) -> Self {
Self {
addr: SocketAddr::new(ip, port),
allow_direct: ip.is_loopback(),
}
}
/// `127.0.0.1` — only this machine, non-Telegram traffic relayed.
pub fn loopback(port: u16) -> Self {
Self::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
}
/// `0.0.0.0` — reachable from the local network, Telegram destinations only.
pub fn lan(port: u16) -> Self {
Self::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port)
}
/// Override the direct-relay policy. Used by `--allow-direct`.
pub fn with_allow_direct(mut self, allow_direct: bool) -> Self {
self.allow_direct = allow_direct;
self
}
/// Host to advertise in a `tg://proxy` link for this listener.
///
/// A wildcard bind is not a usable destination, so it is resolved to the
/// address this machine uses to reach the network.
pub fn advertised_host(&self) -> String {
let ip = self.addr.ip();
if ip.is_unspecified() {
outbound_ip().unwrap_or_else(|| Ipv4Addr::LOCALHOST.to_string())
} else {
ip.to_string()
}
}
/// `tg://proxy` link that points Telegram at this listener.
pub fn telegram_link(&self, secret: &str) -> String {
format!(
"tg://proxy?server={}&port={}&secret={}",
self.advertised_host(),
self.addr.port(),
secret
)
}
}
/// Local address of the interface that reaches the default route.
///
/// No packet is sent: connecting a UDP socket only makes the OS pick a route.
fn outbound_ip() -> Option<String> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
Some(socket.local_addr().ok()?.ip().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_relays_direct_traffic() {
let config = ListenConfig::loopback(1080);
assert_eq!(config.addr.to_string(), "127.0.0.1:1080");
assert!(config.allow_direct);
}
#[test]
fn lan_restricts_to_telegram_by_default() {
let config = ListenConfig::lan(1080);
assert_eq!(config.addr.to_string(), "0.0.0.0:1080");
assert!(!config.allow_direct);
}
#[test]
fn any_routable_address_restricts_to_telegram() {
for ip in ["192.168.1.10", "10.0.0.5", "::"] {
let config = ListenConfig::new(ip.parse().unwrap(), 1080);
assert!(
!config.allow_direct,
"{ip} must not relay non-Telegram traffic without an explicit opt-in"
);
}
}
#[test]
fn ipv6_loopback_is_treated_as_local() {
let config = ListenConfig::new("::1".parse().unwrap(), 1080);
assert!(config.allow_direct);
assert_eq!(config.addr.to_string(), "[::1]:1080");
}
#[test]
fn allow_direct_override_is_explicit_in_both_directions() {
assert!(ListenConfig::lan(1080).with_allow_direct(true).allow_direct);
assert!(
!ListenConfig::loopback(1080)
.with_allow_direct(false)
.allow_direct
);
}
#[test]
fn link_uses_concrete_host_and_port() {
let link = ListenConfig::new("192.168.1.10".parse().unwrap(), 1443).telegram_link("ddaa");
assert_eq!(link, "tg://proxy?server=192.168.1.10&port=1443&secret=ddaa");
}
#[test]
fn wildcard_bind_never_advertises_itself() {
let host = ListenConfig::lan(1080).advertised_host();
assert_ne!(host, "0.0.0.0");
assert!(!host.is_empty());
}
}
+519
View File
@@ -0,0 +1,519 @@
use crate::config::ListenConfig;
use crate::{proxy, transport};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
use tauri_plugin_opener::OpenerExt;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl AppState {
fn new(settings_path: PathBuf, stats: Arc<proxy::Stats>) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats,
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
task: tokio::sync::Mutex::new(None),
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
async fn start_proxy(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<StatusSnapshot, String> {
// Serialize transitions, including shutdown, so a rapid double click cannot
// create two listeners or stop a newly started Android service.
let mut task = state.task.lock().await;
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
if let Some(previous) = task.take() {
let _ = previous.await;
}
let settings = state.settings.lock().unwrap().clone();
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
let listener = proxy::bind(listen).await?;
// Called while the user's Activity is visible, before opening Telegram.
// A rejected foreground-service start must not leave a hidden proxy running.
set_foreground_service(&app, true)?;
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.active_listen.lock().unwrap() = Some(listen);
*state.started_at.lock().unwrap() = Some(Instant::now());
let stats = state.stats.clone();
let logs = state.logs.clone();
let worker_app = app.clone();
let worker = tokio::spawn(async move {
if let Err(error) = proxy::serve(stats.clone(), listener, listen.allow_direct).await {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
stats.running.store(false, Ordering::SeqCst);
if let Err(error) = set_foreground_service(&worker_app, false) {
push_log(&logs, error, true);
}
});
while !state.stats.running.load(Ordering::SeqCst) && !worker.is_finished() {
tokio::task::yield_now().await;
}
*task = Some(worker);
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err("Не удалось запустить прокси; подробности в диагностике".into());
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
if let Err(error) = open_telegram_link(&app, &state) {
// The working proxy remains available through the copy-link control.
state.log(error, true);
}
Ok(state.snapshot())
}
#[tauri::command]
async fn stop_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
let mut task = state.task.lock().await;
state.stats.stop();
if let Some(worker) = task.take() {
let _ = worker.await;
}
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
Ok(state.snapshot())
}
fn telegram_link(state: &AppState) -> Result<String, String> {
if !state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала включите защиту".into());
}
state
.active_listen
.lock()
.unwrap()
.map(|listen| listen.telegram_link(&state.stats.telegram_secret()))
.ok_or_else(|| "Сначала включите защиту".into())
}
#[tauri::command]
fn get_telegram_link(state: State<'_, AppState>) -> Result<String, String> {
telegram_link(&state)
}
fn open_telegram_link(app: &tauri::AppHandle, state: &AppState) -> Result<(), String> {
app.opener()
.open_url(telegram_link(state)?, None::<&str>)
.map_err(|error| {
format!("Не удалось открыть Telegram: {error}. Скопируйте ссылку подключения.")
})
}
#[tauri::command]
async fn open_telegram(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
open_telegram_link(&app, &state)
}
#[cfg(target_os = "android")]
struct TunnelPlugin(tauri::plugin::PluginHandle<tauri::Wry>);
#[cfg(target_os = "android")]
fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> {
app.state::<TunnelPlugin>()
.0
.run_mobile_plugin::<serde_json::Value>(
"setEnabled",
serde_json::json!({ "enabled": enabled }),
)
.map(|_| ())
.map_err(|error| format!("Фоновый сервис Android: {error}"))
}
#[cfg(not(target_os = "android"))]
fn set_foreground_service(_app: &tauri::AppHandle, _enabled: bool) -> Result<(), String> {
Ok(())
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
prefer_software_rendering();
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("tunnel")
.setup(|app, api| {
let handle = api.register_android_plugin("com.bysonic.tglock", "TunnelPlugin")?;
app.manage(TunnelPlugin(handle));
Ok(())
})
.build(),
);
builder
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let secret_path = settings_path.with_file_name("secret");
let stored = crate::mtproto::load_or_create_secret_at_with_migration(
&secret_path,
crate::mtproto::legacy_secret_path().as_deref(),
);
let state = AppState::new(settings_path, proxy::Stats::with_stored_secret(stored));
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не записан ({error}). Проверьте ссылку после перезапуска"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy,
get_telegram_link,
open_telegram
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and
//! the headless CLI.
//!
//! With the `gui` feature disabled this crate does not depend on Tauri, so the
//! `tglock-cli` binary can be built with `--no-default-features` on a server
//! that has neither a GPU nor a monitor.
pub mod config;
pub mod mtproto;
pub mod proxy;
pub mod telegram_net;
pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")]
pub mod cli_settings;
/// Desktop and Android graphical application; absent from headless builds.
#[cfg(feature = "gui")]
pub mod gui;
+2 -353
View File
@@ -1,356 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod bypass;
mod network;
mod ws_proxy;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use eframe::egui;
const PROXY_PORT: u16 = 1080;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([680.0, 560.0])
.with_min_inner_size([580.0, 460.0])
.with_title("TG Unblock"),
..Default::default()
};
eframe::run_native(
"TG Unblock",
options,
Box::new(|cc| {
setup_fonts(&cc.egui_ctx);
Ok(Box::new(App::new()))
}),
)
}
fn setup_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"system".to_owned(),
std::sync::Arc::new(egui::FontData::from_static(include_bytes!(
"C:\\Windows\\Fonts\\segoeui.ttf"
))),
);
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "system".to_owned());
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "system".to_owned());
ctx.set_fonts(fonts);
}
#[derive(Clone)]
struct LogEntry {
text: String,
is_error: bool,
ts: String,
}
struct App {
log: Arc<Mutex<Vec<LogEntry>>>,
proxy_stats: Arc<ws_proxy::ProxyStats>,
is_admin: bool,
adapter_name: Arc<Mutex<Option<String>>>,
dns_set: Arc<Mutex<bool>>,
}
impl App {
fn new() -> Self {
let is_admin = bypass::check_admin();
let app = Self {
log: Arc::new(Mutex::new(Vec::new())),
proxy_stats: ws_proxy::ProxyStats::new(),
is_admin,
adapter_name: Arc::new(Mutex::new(None)),
dns_set: Arc::new(Mutex::new(false)),
};
log_msg(&app.log, "Запущено", false);
if !is_admin {
log_msg(&app.log, "Нет прав администратора — DNS менять не получится", true);
}
{
let adapter = app.adapter_name.clone();
let log = app.log.clone();
std::thread::spawn(move || {
if let Some(name) = network::detect_adapter() {
log_msg(&log, &format!("Адаптер: {}", name), false);
*adapter.lock().unwrap() = Some(name);
}
});
}
app
}
fn proxy_running(&self) -> bool {
self.proxy_stats.running.load(Ordering::SeqCst)
}
fn start_proxy(&self) {
if self.proxy_running() {
return;
}
let stats = self.proxy_stats.clone();
let log = self.log.clone();
let adapter = self.adapter_name.clone();
let dns_set = self.dns_set.clone();
let is_admin = self.is_admin;
std::thread::spawn(move || {
// DNS
if is_admin {
let aname = adapter.lock().unwrap().clone().or_else(network::detect_adapter);
if let Some(ref name) = aname {
if bypass::set_dns(name, "1.1.1.1", "1.0.0.1").is_ok() {
bypass::flush_dns();
log_msg(&log, "DNS → Cloudflare 1.1.1.1", false);
*dns_set.lock().unwrap() = true;
}
}
}
log_msg(&log, &format!("Запускаю WS-прокси на 127.0.0.1:{}...", PROXY_PORT), false);
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(ws_proxy::run_proxy(PROXY_PORT, stats));
if let Err(e) = result {
log_msg(&log, &format!("Прокси остановлен: {}", e), true);
}
});
std::thread::sleep(std::time::Duration::from_millis(300));
if self.proxy_running() {
log_msg(&self.log, "Прокси запущен! Настройте Telegram.", false);
}
}
fn stop_proxy(&self) {
self.proxy_stats.running.store(false, Ordering::SeqCst);
log_msg(&self.log, "Прокси остановлен", false);
if *self.dns_set.lock().unwrap() {
let adapter = self.adapter_name.clone();
let log = self.log.clone();
let dns_set = self.dns_set.clone();
std::thread::spawn(move || {
let aname = adapter.lock().unwrap().clone().or_else(network::detect_adapter);
if let Some(ref name) = aname {
let _ = bypass::reset_dns(name);
bypass::flush_dns();
*dns_set.lock().unwrap() = false;
log_msg(&log, "DNS сброшен", false);
}
});
}
}
fn open_tg_proxy_link(&self) {
let url = format!("tg://socks?server=127.0.0.1&port={}", PROXY_PORT);
log_msg(&self.log, "Открываю настройку прокси в Telegram...", false);
let _ = open::that(&url);
}
}
fn log_msg(log: &Arc<Mutex<Vec<LogEntry>>>, text: &str, err: bool) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let ts = format!("{:02}:{:02}:{:02}", (now % 86400) / 3600, (now % 3600) / 60, now % 60);
log.lock().unwrap().push(LogEntry {
text: text.to_string(),
is_error: err,
ts,
});
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.request_repaint_after(std::time::Duration::from_millis(400));
let running = self.proxy_running();
let active = self.proxy_stats.active_conn.load(Ordering::Relaxed);
let total = self.proxy_stats.total_conn.load(Ordering::Relaxed);
let ws = self.proxy_stats.ws_active.load(Ordering::Relaxed);
// --- Top bar ---
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("TG Unblock");
ui.separator();
if running {
ui.colored_label(
egui::Color32::from_rgb(80, 220, 120),
egui::RichText::new("ПРОКСИ РАБОТАЕТ").strong(),
);
ui.separator();
ui.label(format!("Соединений: {} (WS: {}) | Всего: {}", active, ws, total));
} else {
ui.label("Прокси не запущен");
}
});
});
// --- Log panel ---
egui::TopBottomPanel::bottom("log")
.min_height(130.0)
.show(ctx, |ui| {
ui.label(egui::RichText::new("Лог").strong());
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
let logs = self.log.lock().unwrap();
for e in logs.iter() {
let color = if e.is_error {
egui::Color32::from_rgb(255, 100, 100)
} else {
egui::Color32::from_rgb(170, 215, 170)
};
ui.colored_label(color, format!("[{}] {}", e.ts, e.text));
}
});
});
// --- Main panel ---
egui::CentralPanel::default().show(ctx, |ui| {
ui.add_space(10.0);
// --- VPN ad (top) ---
ui.vertical_centered(|ui| {
egui::Frame::new()
.fill(egui::Color32::from_rgb(25, 30, 42))
.corner_radius(8.0)
.inner_margin(egui::Margin::symmetric(14, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.colored_label(
egui::Color32::from_rgb(100, 180, 255),
egui::RichText::new("by sonic VPN").size(13.0).strong(),
);
ui.label(
egui::RichText::new("Полный обход для всех приложений")
.size(12.0)
.color(egui::Color32::from_rgb(160, 165, 180)),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(
egui::Button::new(
egui::RichText::new("@bysonicvpn_bot")
.size(12.0)
.strong()
.color(egui::Color32::from_rgb(100, 200, 255)),
)
.frame(false),
).clicked() {
let _ = open::that("https://t.me/bysonicvpn_bot");
}
});
});
});
});
ui.add_space(12.0);
ui.vertical_centered(|ui| {
if !running {
ui.label(egui::RichText::new("Обход блокировки Telegram через WebSocket-прокси").size(15.0));
ui.add_space(5.0);
ui.label("Трафик идёт через web.telegram.org — провайдер видит обычный HTTPS");
ui.add_space(15.0);
let btn = ui.add_sized(
[340.0, 55.0],
egui::Button::new(egui::RichText::new("Запустить обход").size(20.0).strong()),
);
if btn.clicked() {
self.start_proxy();
}
} else {
ui.colored_label(
egui::Color32::from_rgb(80, 220, 120),
egui::RichText::new("Обход работает").size(22.0).strong(),
);
ui.add_space(5.0);
ui.label(format!("SOCKS5 прокси на 127.0.0.1:{}", PROXY_PORT));
ui.label(format!("WebSocket-туннелей: {} | Соединений: {}", ws, active));
ui.add_space(12.0);
// Stop button
let stop = ui.add_sized(
[340.0, 42.0],
egui::Button::new(egui::RichText::new("Остановить").size(17.0)),
);
if stop.clicked() {
self.stop_proxy();
}
}
});
ui.add_space(20.0);
ui.separator();
ui.add_space(8.0);
// --- Telegram setup ---
ui.heading("Настройка Telegram Desktop");
ui.add_space(6.0);
if running {
ui.horizontal(|ui| {
if ui.button(" Настроить автоматически ").clicked() {
self.open_tg_proxy_link();
}
ui.label("(откроет Telegram, нажмите \"Подключить\")");
});
ui.add_space(8.0);
}
ui.label("Или вручную: Настройки → Продвинутые → Тип соединения → SOCKS5");
ui.add_space(4.0);
egui::Grid::new("manual_setup")
.num_columns(2)
.spacing([15.0, 4.0])
.show(ui, |ui| {
ui.label("Сервер:");
ui.monospace("127.0.0.1");
ui.end_row();
ui.label("Порт:");
ui.monospace(format!("{}", PROXY_PORT));
ui.end_row();
ui.label("Логин/Пароль:");
ui.label("оставить пустыми");
ui.end_row();
});
ui.add_space(15.0);
ui.separator();
ui.add_space(5.0);
// --- How it works ---
ui.heading("Как это работает");
ui.add_space(4.0);
ui.label("1. Локальный SOCKS5-прокси принимает соединения от Telegram");
ui.label("2. Трафик к серверам Telegram заворачивается в WebSocket (WSS)");
ui.label("3. Подключение идёт через web.telegram.org — обычный HTTPS");
ui.label("4. Провайдер/DPI не видит MTProto, не может замедлить");
ui.add_space(4.0);
ui.colored_label(
egui::Color32::from_rgb(170, 170, 170),
"Не-Telegram трафик проходит напрямую без изменений",
);
});
}
fn main() {
tglock_lib::gui::run();
}
+813
View File
@@ -0,0 +1,813 @@
use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher};
use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
type AesCtr = ctr::Ctr128BE<Aes256>;
const INIT_LEN: usize = 64;
const KEY_START: usize = 8;
const KEY_END: usize = 40;
const IV_END: usize = 56;
const TAG_START: usize = 56;
const DC_START: usize = 60;
const ABRIDGED: [u8; 4] = [0xef; 4];
const INTERMEDIATE: [u8; 4] = [0xee; 4];
const PADDED_INTERMEDIATE: [u8; 4] = [0xdd; 4];
pub struct ClientInit {
pub dc: u16,
pub media: bool,
pub relay_init: [u8; INIT_LEN],
pub crypto: CryptoContext,
}
pub struct CryptoContext {
client_decrypt: AesCtr,
client_encrypt: AesCtr,
telegram_encrypt: AesCtr,
telegram_decrypt: AesCtr,
}
impl CryptoContext {
pub fn client_to_telegram(&mut self, data: &mut [u8]) {
self.client_decrypt.apply_keystream(data);
self.telegram_encrypt.apply_keystream(data);
}
pub fn telegram_to_client(&mut self, data: &mut [u8]) {
self.telegram_decrypt.apply_keystream(data);
self.client_encrypt.apply_keystream(data);
}
/// Разделить шифры по направлениям, чтобы туннель шёл в обе стороны сразу.
///
/// Направления независимы: это два потока AES-CTR со своими ключами, и ни
/// один байт одного не влияет на другой.
pub fn split(self) -> (Upstream, Downstream) {
(
Upstream {
client_decrypt: self.client_decrypt,
telegram_encrypt: self.telegram_encrypt,
},
Downstream {
telegram_decrypt: self.telegram_decrypt,
client_encrypt: self.client_encrypt,
},
)
}
}
/// Шифры направления «клиент -> Telegram».
pub struct Upstream {
client_decrypt: AesCtr,
telegram_encrypt: AesCtr,
}
impl Upstream {
pub fn apply(&mut self, data: &mut [u8]) {
self.client_decrypt.apply_keystream(data);
self.telegram_encrypt.apply_keystream(data);
}
}
/// Шифры направления «Telegram -> клиент».
pub struct Downstream {
telegram_decrypt: AesCtr,
client_encrypt: AesCtr,
}
impl Downstream {
pub fn apply(&mut self, data: &mut [u8]) {
self.telegram_decrypt.apply_keystream(data);
self.client_encrypt.apply_keystream(data);
}
}
pub fn generate_secret() -> [u8; 16] {
let mut secret = [0; 16];
OsRng.fill_bytes(&mut secret);
secret
}
/// Секрет прокси и то, лежит ли он на диске.
pub struct StoredSecret {
pub value: [u8; 16],
/// Ошибка, из-за которой секрет не удалось сохранить.
///
/// Если она есть, при следующем запуске секрет будет другим, ссылка
/// `tg://proxy` перестанет совпадать с сохранённой в Telegram, и Telegram
/// скажет «прокси настроен неверно и будет отключён». Раньше запись
/// провалившись молчала, и понять причину было невозможно
/// (by-sonic/tglock#37).
pub write_error: Option<String>,
}
impl StoredSecret {
/// Секрет действительно переживёт перезапуск.
pub fn is_persistent(&self) -> bool {
self.write_error.is_none()
}
}
/// Взять секрет из файла, создав его, если файла нет или он испорчен.
///
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
/// при каждом старте, отключает всех уже настроенных клиентов.
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
load_or_create_secret_at_with_migration(path, None)
}
/// Preserve desktop proxy links when the GUI moves to Tauri's app directory.
/// The legacy file remains intact, including when copying it fails.
pub fn load_or_create_secret_at_with_migration(path: &Path, legacy: Option<&Path>) -> StoredSecret {
if let Ok(value) = std::fs::read_to_string(path) {
if let Some(value) = parse_secret_hex(value.trim()) {
return StoredSecret {
value,
write_error: None,
};
}
}
let value = legacy
.and_then(|legacy| std::fs::read_to_string(legacy).ok())
.and_then(|value| parse_secret_hex(value.trim()))
.unwrap_or_else(generate_secret);
let write_error = store_secret(path, &secret_hex(&value))
.err()
.map(|error| format!("{}: {error}", path.display()));
StoredSecret { value, write_error }
}
fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
write_secret_file(path, value)
}
#[cfg(not(test))]
pub fn load_or_create_secret() -> StoredSecret {
match legacy_secret_path() {
Some(path) => load_or_create_secret_at(&path),
None => StoredSecret {
value: generate_secret(),
write_error: Some("не удалось определить папку для секрета в этой системе".to_owned()),
},
}
}
pub fn legacy_secret_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("APPDATA")
.map(PathBuf::from)
.map(|path| path.join("TGLock").join("secret"))
}
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|path| path.join("Library/Application Support/TGLock/secret"))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(path).join("tglock").join("secret"));
}
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|path| path.join(".config/tglock/secret"))
}
}
#[cfg(unix)]
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)?;
file.write_all(value.as_bytes())
}
#[cfg(not(unix))]
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
std::fs::write(path, value)
}
pub fn secret_hex(secret: &[u8; 16]) -> String {
let mut output = String::with_capacity(32);
for byte in secret {
use std::fmt::Write;
let _ = write!(output, "{:02x}", byte);
}
output
}
/// Разобрать секрет, записанный человеком.
///
/// Принимает и 32 hex-символа, и форму с префиксом `dd` — именно так секрет
/// выглядит в ссылке `tg://proxy`, откуда его и копируют.
pub fn parse_secret(value: &str) -> Option<[u8; 16]> {
let trimmed = value.trim();
let hex = trimmed
.strip_prefix("dd")
.filter(|rest| rest.len() == 32)
.unwrap_or(trimmed);
parse_secret_hex(hex)
}
fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 || !value.is_ascii() {
return None;
}
let mut secret = [0; 16];
for (index, byte) in secret.iter_mut().enumerate() {
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?;
}
Some(secret)
}
pub fn telegram_secret(secret: &[u8; 16]) -> String {
format!("dd{}", secret_hex(secret))
}
pub fn parse_client_init(init: &[u8; INIT_LEN], secret: &[u8; 16]) -> Option<ClientInit> {
let client_dec_key = secret_key(&init[KEY_START..KEY_END], secret);
let client_dec_iv: [u8; 16] = init[KEY_END..IV_END].try_into().ok()?;
let mut client_decrypt = AesCtr::new((&client_dec_key).into(), (&client_dec_iv).into());
let mut decrypted = *init;
client_decrypt.apply_keystream(&mut decrypted);
let protocol_tag: [u8; 4] = decrypted[TAG_START..DC_START].try_into().ok()?;
if !matches!(protocol_tag, ABRIDGED | INTERMEDIATE | PADDED_INTERMEDIATE) {
return None;
}
let dc_index = i16::from_le_bytes([decrypted[DC_START], decrypted[DC_START + 1]]);
let dc = dc_index.unsigned_abs();
if !matches!(dc, 1..=5 | 203) {
return None;
}
let relay_init = generate_relay_init(protocol_tag, dc_index);
let crypto = build_crypto_context(init, secret, &relay_init)?;
Some(ClientInit {
dc,
media: dc_index < 0,
relay_init,
crypto,
})
}
fn secret_key(prekey: &[u8], secret: &[u8; 16]) -> [u8; 32] {
let mut hash = Sha256::new();
hash.update(prekey);
hash.update(secret);
hash.finalize().into()
}
fn generate_relay_init(protocol_tag: [u8; 4], dc_index: i16) -> [u8; INIT_LEN] {
loop {
let mut init = [0; INIT_LEN];
OsRng.fill_bytes(&mut init);
if is_reserved_init(&init) {
continue;
}
let key: [u8; 32] = init[KEY_START..KEY_END].try_into().unwrap();
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
let mut encrypted = init;
cipher.apply_keystream(&mut encrypted);
let mut tail = [0; 8];
tail[..4].copy_from_slice(&protocol_tag);
tail[4..6].copy_from_slice(&dc_index.to_le_bytes());
OsRng.fill_bytes(&mut tail[6..]);
for index in 0..8 {
init[TAG_START + index] ^= tail[index] ^ encrypted[TAG_START + index];
}
return init;
}
}
fn is_reserved_init(init: &[u8; INIT_LEN]) -> bool {
init[0] == 0xef
|| &init[..4] == b"HEAD"
|| &init[..4] == b"POST"
|| &init[..4] == b"GET "
|| &init[..4] == b"OPTI"
|| init[..4] == [0xee; 4]
|| init[..4] == [0xdd; 4]
|| init[..4] == [0x16, 0x03, 0x01, 0x02]
|| init[4..8] == [0; 4]
}
fn build_crypto_context(
client_init: &[u8; INIT_LEN],
secret: &[u8; 16],
relay_init: &[u8; INIT_LEN],
) -> Option<CryptoContext> {
let client_dec_key = secret_key(&client_init[KEY_START..KEY_END], secret);
let client_dec_iv: [u8; 16] = client_init[KEY_END..IV_END].try_into().ok()?;
let mut client_decrypt = AesCtr::new((&client_dec_key).into(), (&client_dec_iv).into());
client_decrypt.apply_keystream(&mut [0; INIT_LEN]);
let reversed_client: Vec<_> = client_init[KEY_START..IV_END]
.iter()
.rev()
.copied()
.collect();
let client_enc_key = secret_key(&reversed_client[..32], secret);
let client_enc_iv: [u8; 16] = reversed_client[32..].try_into().ok()?;
let client_encrypt = AesCtr::new((&client_enc_key).into(), (&client_enc_iv).into());
let relay_enc_key: [u8; 32] = relay_init[KEY_START..KEY_END].try_into().ok()?;
let relay_enc_iv: [u8; 16] = relay_init[KEY_END..IV_END].try_into().ok()?;
let mut telegram_encrypt = AesCtr::new((&relay_enc_key).into(), (&relay_enc_iv).into());
telegram_encrypt.apply_keystream(&mut [0; INIT_LEN]);
let reversed_relay: Vec<_> = relay_init[KEY_START..IV_END]
.iter()
.rev()
.copied()
.collect();
let relay_dec_key: [u8; 32] = reversed_relay[..32].try_into().ok()?;
let relay_dec_iv: [u8; 16] = reversed_relay[32..].try_into().ok()?;
let telegram_decrypt = AesCtr::new((&relay_dec_key).into(), (&relay_dec_iv).into());
Some(CryptoContext {
client_decrypt,
client_encrypt,
telegram_encrypt,
telegram_decrypt,
})
}
#[cfg(test)]
pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] {
tests::generate_client_init(secret, PADDED_INTERMEDIATE, dc_index)
}
/// One end of an obfuscated2 stream, built the way the real peer builds it.
///
/// Lets tests assert on the bytes the peer actually observes rather than on the
/// proxy's own view of them, so a mistake that is symmetric inside
/// [`CryptoContext`] still fails the test.
#[cfg(test)]
pub(crate) struct TestPeer {
encrypt: AesCtr,
decrypt: AesCtr,
}
#[cfg(test)]
impl TestPeer {
pub(crate) fn encrypt(&mut self, data: &mut [u8]) {
self.encrypt.apply_keystream(data);
}
pub(crate) fn decrypt(&mut self, data: &mut [u8]) {
self.decrypt.apply_keystream(data);
}
}
/// The Telegram client: its keys come from the init it sent, salted with the
/// shared secret.
#[cfg(test)]
pub(crate) fn test_client_peer(init: &[u8; INIT_LEN], secret: &[u8; 16]) -> TestPeer {
let key = secret_key(&init[KEY_START..KEY_END], secret);
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
let mut encrypt = AesCtr::new((&key).into(), (&iv).into());
encrypt.apply_keystream(&mut [0; INIT_LEN]);
let reversed: Vec<u8> = init[KEY_START..IV_END].iter().rev().copied().collect();
let decrypt_key = secret_key(&reversed[..32], secret);
let decrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap();
let decrypt = AesCtr::new((&decrypt_key).into(), (&decrypt_iv).into());
TestPeer { encrypt, decrypt }
}
/// The Telegram relay: no shared secret, keys come straight from the init the
/// proxy generated for it.
#[cfg(test)]
pub(crate) fn test_relay_peer(relay_init: &[u8; INIT_LEN]) -> TestPeer {
let key: [u8; 32] = relay_init[KEY_START..KEY_END].try_into().unwrap();
let iv: [u8; 16] = relay_init[KEY_END..IV_END].try_into().unwrap();
let mut decrypt = AesCtr::new((&key).into(), (&iv).into());
decrypt.apply_keystream(&mut [0; INIT_LEN]);
let reversed: Vec<u8> = relay_init[KEY_START..IV_END]
.iter()
.rev()
.copied()
.collect();
let encrypt_key: [u8; 32] = reversed[..32].try_into().unwrap();
let encrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap();
let encrypt = AesCtr::new((&encrypt_key).into(), (&encrypt_iv).into());
TestPeer { encrypt, decrypt }
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn generate_client_init(
secret: &[u8; 16],
protocol_tag: [u8; 4],
dc_index: i16,
) -> [u8; INIT_LEN] {
let mut init = generate_relay_init(protocol_tag, dc_index);
let key = secret_key(&init[KEY_START..KEY_END], secret);
let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap();
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
let mut encrypted = init;
cipher.apply_keystream(&mut encrypted);
let mut tail = [0; 8];
tail[..4].copy_from_slice(&protocol_tag);
tail[4..6].copy_from_slice(&dc_index.to_le_bytes());
tail[6..].copy_from_slice(&[17, 23]);
for index in 0..8 {
init[TAG_START + index] ^= tail[index] ^ encrypted[TAG_START + index];
}
init
}
#[test]
fn parses_secret_protected_media_init() {
let secret = [42; 16];
let init = generate_client_init(&secret, PADDED_INTERMEDIATE, -4);
let parsed = parse_client_init(&init, &secret).unwrap();
assert_eq!(parsed.dc, 4);
assert!(parsed.media);
}
#[test]
fn rejects_wrong_secret() {
let init = generate_client_init(&[42; 16], INTERMEDIATE, 2);
assert!(parse_client_init(&init, &[7; 16]).is_none());
}
#[test]
fn telegram_link_secret_has_padded_intermediate_prefix() {
assert_eq!(
telegram_secret(&[0xab; 16]),
"ddabababababababababababababababab"
);
}
#[test]
fn accepts_every_supported_protocol_tag() {
let secret = [7; 16];
for tag in [ABRIDGED, INTERMEDIATE, PADDED_INTERMEDIATE] {
let init = generate_client_init(&secret, tag, 2);
let parsed = parse_client_init(&init, &secret)
.unwrap_or_else(|| panic!("tag {tag:02x?} must be accepted"));
assert_eq!(parsed.dc, 2);
assert!(!parsed.media);
}
}
#[test]
fn relay_init_carries_the_clients_protocol_tag_and_dc() {
let secret = [3; 16];
for (tag, dc_index) in [
(ABRIDGED, 1_i16),
(INTERMEDIATE, -5),
(PADDED_INTERMEDIATE, 203),
] {
let init = generate_client_init(&secret, tag, dc_index);
let parsed = parse_client_init(&init, &secret).unwrap();
// The relay init is freshly generated, never the client's bytes.
assert_ne!(parsed.relay_init, init);
// Decoding the relay init the way Telegram does must recover the
// same protocol and data centre the client asked for.
let key: [u8; 32] = parsed.relay_init[KEY_START..KEY_END].try_into().unwrap();
let iv: [u8; 16] = parsed.relay_init[KEY_END..IV_END].try_into().unwrap();
let mut cipher = AesCtr::new((&key).into(), (&iv).into());
let mut decoded = parsed.relay_init;
cipher.apply_keystream(&mut decoded);
assert_eq!(decoded[TAG_START..DC_START], tag);
assert_eq!(
i16::from_le_bytes([decoded[DC_START], decoded[DC_START + 1]]),
dc_index
);
}
}
#[test]
fn rejects_data_centers_outside_the_known_range() {
let secret = [11; 16];
for dc_index in [0_i16, 6, -6, 204, -204, 1000] {
let init = generate_client_init(&secret, INTERMEDIATE, dc_index);
assert!(
parse_client_init(&init, &secret).is_none(),
"DC index {dc_index} must be rejected"
);
}
}
#[test]
fn negative_index_marks_media_and_keeps_the_data_center() {
let secret = [13; 16];
for dc in [1_u16, 2, 3, 4, 5, 203] {
let index = -(dc as i16);
let parsed =
parse_client_init(&generate_client_init(&secret, ABRIDGED, index), &secret)
.unwrap();
assert_eq!(parsed.dc, dc);
assert!(parsed.media);
let parsed =
parse_client_init(&generate_client_init(&secret, ABRIDGED, dc as i16), &secret)
.unwrap();
assert_eq!(parsed.dc, dc);
assert!(!parsed.media);
}
}
#[test]
fn plaintext_survives_the_trip_to_the_relay_and_back() {
let secret = [42; 16];
let init = generate_client_init(&secret, ABRIDGED, 2);
let mut parsed = parse_client_init(&init, &secret).unwrap();
let mut client = test_client_peer(&init, &secret);
let mut relay = test_relay_peer(&parsed.relay_init);
let request = b"exactly what Telegram must receive".to_vec();
let mut wire = request.clone();
client.encrypt(&mut wire);
assert_ne!(wire, request, "the wire must not carry plaintext");
parsed.crypto.client_to_telegram(&mut wire);
assert_ne!(wire, request, "the upstream wire must not carry plaintext");
relay.decrypt(&mut wire);
assert_eq!(wire, request);
let response = b"exactly what the client must receive".to_vec();
let mut wire = response.clone();
relay.encrypt(&mut wire);
parsed.crypto.telegram_to_client(&mut wire);
client.decrypt(&mut wire);
assert_eq!(wire, response);
}
#[test]
fn keystream_advances_across_chunks() {
let secret = [5; 16];
let init = generate_client_init(&secret, INTERMEDIATE, 3);
let mut parsed = parse_client_init(&init, &secret).unwrap();
let mut client = test_client_peer(&init, &secret);
let mut relay = test_relay_peer(&parsed.relay_init);
// A stream cipher is only correct if both ends stay in lockstep across
// arbitrary chunk boundaries, which is how TCP actually delivers data.
let chunks: [&[u8]; 4] = [b"one", b"", b"the third chunk is longer", b"4"];
for chunk in chunks {
let mut wire = chunk.to_vec();
client.encrypt(&mut wire);
parsed.crypto.client_to_telegram(&mut wire);
relay.decrypt(&mut wire);
assert_eq!(wire, chunk);
}
}
#[test]
fn reserved_prefixes_never_leave_the_generator() {
// A relay init that starts with an HTTP verb or a protocol tag would be
// misread by Telegram's frontend.
for _ in 0..2_000 {
assert!(!is_reserved_init(&generate_relay_init(ABRIDGED, 2)));
}
}
#[test]
fn a_failed_write_is_reported_instead_of_swallowed() {
// Раньше ошибка записи выбрасывалась, секрет генерировался заново при
// каждом запуске, и Telegram говорил «прокси настроен неверно» без
// единой подсказки почему (by-sonic/tglock#37).
let blocker = std::env::temp_dir().join(format!(
"tglock-not-a-dir-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::write(&blocker, "я файл, а не папка").unwrap();
// Родитель пути — обычный файл, поэтому создать каталог невозможно.
let stored = load_or_create_secret_at(&blocker.join("secret"));
assert!(
!stored.is_persistent(),
"неудачная запись обязана быть видна"
);
let error = stored.write_error.expect("должно быть сообщение об ошибке");
assert!(
error.contains("secret"),
"в сообщении должен быть путь, получено: {error}"
);
// Секрет всё равно выдан: прокси работает, просто до перезапуска.
assert_ne!(stored.value, [0; 16]);
let _ = std::fs::remove_file(&blocker);
}
#[test]
fn a_successful_write_reports_no_error() {
let path = std::env::temp_dir().join(format!(
"tglock-secret-ok-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_file(&path);
let first = load_or_create_secret_at(&path);
assert!(first.is_persistent(), "запись в temp должна удаваться");
// Второй запуск читает готовый файл и тоже не жалуется.
let second = load_or_create_secret_at(&path);
assert!(second.is_persistent());
assert_eq!(
first.value, second.value,
"секрет должен переживать перезапуск"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn accepts_a_secret_copied_from_a_tg_link() {
let expected = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
// Обе формы: как в файле и как в ссылке tg://proxy.
assert_eq!(
parse_secret("00112233445566778899aabbccddeeff"),
Some(expected)
);
assert_eq!(
parse_secret("dd00112233445566778899aabbccddeeff"),
Some(expected)
);
// Пробелы по краям — обычное дело при копировании.
assert_eq!(
parse_secret(" dd00112233445566778899aabbccddeeff\n"),
Some(expected)
);
// Секрет, который сам начинается с dd и уже имеет полную длину, не
// должен потерять первый байт: префикс снимается только если после него
// остаётся ровно 32 символа.
assert_eq!(
parse_secret("dd112233445566778899aabbccddeeff"),
Some([
0xdd, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
])
);
}
#[test]
fn rejects_malformed_secrets() {
for bad in [
"",
"dd",
"слишком коротко",
"00112233445566778899aabbccddee", // 30 символов
"00112233445566778899aabbccddeeffff", // 34 символа
"zz112233445566778899aabbccddeeff", // не hex
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
] {
assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут");
}
}
#[test]
fn android_obfuscated2_vector_survives_fragmented_translation() {
// Independently generated with Node/OpenSSL AES-256-CTR and SHA-256,
// following Telegram Android Connection.cpp sendData/encryptKeyWithSecret.
// Fixed wire bytes avoid a symmetric mistake in the test peer helpers.
fn bytes(hex: &str) -> Vec<u8> {
hex.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
let init: [u8; 64] = bytes(concat!(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223",
"2425262728292a2b2c2d2e2f303132333435363738043de6c25808afa3"
))
.try_into()
.unwrap();
let parsed = parse_client_init(&init, &[42; 16]).unwrap();
assert_eq!(parsed.dc, 4);
assert!(parsed.media);
let mut relay = test_relay_peer(&parsed.relay_init);
let (mut upstream, mut downstream) = parsed.crypto.split();
let mut request = bytes("31fc48bfc21f9210a57fd63ac344ae50a3a23620");
for chunk in request.chunks_mut(3) {
upstream.apply(chunk);
relay.decrypt(chunk);
}
assert_eq!(request, bytes("10000000112233445566778899aabbccddeeff00"));
let mut reply = bytes("04000000ecfeffff");
relay.encrypt(&mut reply);
for chunk in reply.chunks_mut(1) {
downstream.apply(chunk);
}
assert_eq!(reply, bytes("d5a69e839ec08ebf"));
}
#[test]
fn parses_persisted_secret() {
assert_eq!(
parse_secret_hex("00112233445566778899aabbccddeeff"),
Some([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
])
);
assert_eq!(parse_secret_hex("not-a-secret"), None);
}
#[test]
fn secret_migration_keeps_legacy_links_and_prefers_existing_destination() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let destination = root.join("secret");
std::fs::write(&legacy, secret_hex(&[17; 16])).unwrap();
let first = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(first.value, [17; 16]);
assert!(first.is_persistent());
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[17; 16])
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&destination)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
std::fs::write(&legacy, secret_hex(&[23; 16])).unwrap();
let second = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(
second.value, [17; 16],
"an existing destination wins on restart"
);
std::fs::remove_file(destination).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
#[test]
fn failed_migration_reports_error_but_does_not_rotate_the_legacy_secret() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-fail-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let blocker = root.join("blocker");
std::fs::write(&legacy, secret_hex(&[29; 16])).unwrap();
std::fs::write(&blocker, "not a directory").unwrap();
let destination = blocker.join("secret");
for _ in 0..2 {
let stored = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(stored.value, [29; 16]);
assert!(stored.write_error.is_some());
}
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[29; 16])
);
std::fs::remove_file(blocker).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
}
-176
View File
@@ -1,176 +0,0 @@
use std::net::{TcpStream, SocketAddr};
use std::process::Command;
use std::time::{Duration, Instant};
pub fn detect_adapter() -> Option<String> {
let output = Command::new("powershell")
.args([
"-Command",
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1).Name",
])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub fn get_current_dns() -> Option<String> {
let output = Command::new("powershell")
.args([
"-Command",
"Get-DnsClientServerAddress -AddressFamily IPv4 | Where-Object {$_.ServerAddresses.Count -gt 0} | Select-Object -First 1 -ExpandProperty ServerAddresses | Out-String",
])
.output()
.ok()?;
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
if result.is_empty() {
Some("Не определено".to_string())
} else {
Some(result.replace('\n', ", ").replace('\r', ""))
}
}
pub fn ping_host(ip: &str) -> (bool, Option<u64>) {
let start = Instant::now();
let output = Command::new("ping")
.args(["-n", "1", "-w", "3000", ip])
.output();
match output {
Ok(out) => {
let elapsed = start.elapsed().as_millis() as u64;
let stdout = String::from_utf8_lossy(&out.stdout);
let ok = out.status.success() && (stdout.contains("TTL=") || stdout.contains("ttl="));
if ok {
// Try to extract actual time from ping output
if let Some(time_str) = extract_ping_time(&stdout) {
(true, Some(time_str))
} else {
(true, Some(elapsed))
}
} else {
(false, None)
}
}
Err(_) => (false, None),
}
}
fn extract_ping_time(output: &str) -> Option<u64> {
// Match patterns like "time=46ms" or "time<1ms" or "время=46мс"
for line in output.lines() {
let lower = line.to_lowercase();
if let Some(pos) = lower.find("time=").or_else(|| lower.find("time<")) {
let after = &lower[pos + 5..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(ms) = num.parse::<u64>() {
return Some(ms);
}
}
// Russian locale
if let Some(pos) = lower.find("=").filter(|_| lower.contains("ms") || lower.contains("мс")) {
let after = &lower[pos + 1..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(ms) = num.parse::<u64>() {
if ms < 10000 {
return Some(ms);
}
}
}
}
None
}
pub fn tcp_check(ip: &str, port: u16) -> (bool, Option<u64>) {
let addr: SocketAddr = format!("{}:{}", ip, port).parse().unwrap();
let start = Instant::now();
match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
Ok(_stream) => {
let elapsed = start.elapsed().as_millis() as u64;
(true, Some(elapsed))
}
Err(_) => (false, None),
}
}
pub fn https_check(url: &str) -> (bool, Option<u64>) {
let start = Instant::now();
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build();
match client {
Ok(c) => match c.get(url).send() {
Ok(resp) => {
let elapsed = start.elapsed().as_millis() as u64;
(resp.status().is_success(), Some(elapsed))
}
Err(_) => (false, None),
},
Err(_) => (false, None),
}
}
/// Benchmarks Telegram connectivity: runs multiple TCP+HTTPS checks,
/// returns (works: bool, score: u64) where lower score = faster connection.
/// Score is average latency across all successful checks. u64::MAX if nothing works.
pub fn benchmark_telegram() -> (bool, u64) {
let tcp_targets = [
("149.154.167.51", 443u16),
("149.154.175.50", 443),
("149.154.167.91", 443),
("91.108.56.100", 443),
];
let mut total_ms: u64 = 0;
let mut ok_count: u64 = 0;
let mut fail_count: u64 = 0;
// TCP checks (x2 rounds for stability)
for _ in 0..2 {
for (ip, port) in &tcp_targets {
let (ok, latency) = tcp_check(ip, *port);
if ok {
total_ms += latency.unwrap_or(5000);
ok_count += 1;
} else {
fail_count += 1;
}
}
}
// HTTPS check — the real indicator of usable speed
let https_urls = [
"https://web.telegram.org",
"https://t.me",
];
for url in &https_urls {
let (ok, latency) = https_check(url);
if ok {
// Weight HTTPS 3x heavier since it's closer to real usage
let ms = latency.unwrap_or(10000);
total_ms += ms * 3;
ok_count += 3;
} else {
fail_count += 3;
}
}
if ok_count == 0 {
return (false, u64::MAX);
}
// Penalize failures: each fail adds 2000ms to the score
let penalty = fail_count * 2000;
let avg = (total_ms + penalty) / (ok_count + fail_count);
(true, avg)
}
+2383
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
//! Какие адреса и имена принадлежат Telegram.
//!
//! От этого ответа зависит поведение LAN-режима: слушатель на сетевом адресе
//! пропускает только Telegram, всё остальное отклоняет. Значит ошибка в любую
//! сторону видна пользователю.
//!
//! Раньше проверка сравнивала два первых октета, то есть считала «телеграмом»
//! целиком `149.154.0.0/16`, `91.108.0.0/16`, `91.105.0.0/16` и `185.76.0.0/16`,
//! а IPv6 не знала вовсе. Отсюда два разных дефекта: чужие адреса внутри этих
//! сетей уходили в MTProto-туннель и умирали, а настоящие адреса Telegram по
//! IPv6 отклонялись как посторонние. Второе и выглядит как «на компьютере
//! работает, с телефона нет» (by-sonic/tglock#42): на loopback не-Telegram
//! адреса всё равно релеятся напрямую, поэтому там ошибка не проявляется.
//!
//! Список сетей — официальный, <https://core.telegram.org/resources/cidr.txt>,
//! сверен 19 августа 2026 года.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// IPv4-сети Telegram: адрес сети и длина префикса.
const V4: &[(Ipv4Addr, u32)] = &[
(Ipv4Addr::new(91, 105, 192, 0), 23),
(Ipv4Addr::new(91, 108, 4, 0), 22),
(Ipv4Addr::new(91, 108, 8, 0), 22),
(Ipv4Addr::new(91, 108, 12, 0), 22),
(Ipv4Addr::new(91, 108, 16, 0), 22),
(Ipv4Addr::new(91, 108, 20, 0), 22),
(Ipv4Addr::new(91, 108, 56, 0), 22),
(Ipv4Addr::new(149, 154, 160, 0), 20),
(Ipv4Addr::new(185, 76, 151, 0), 24),
];
/// IPv6-сети Telegram.
const V6: &[(Ipv6Addr, u32)] = &[
(Ipv6Addr::new(0x2001, 0x67c, 0x4e8, 0, 0, 0, 0, 0), 48),
(Ipv6Addr::new(0x2001, 0xb28, 0xf23c, 0, 0, 0, 0, 0), 48),
(Ipv6Addr::new(0x2001, 0xb28, 0xf23d, 0, 0, 0, 0, 0), 48),
(Ipv6Addr::new(0x2001, 0xb28, 0xf23f, 0, 0, 0, 0, 0), 48),
(Ipv6Addr::new(0x2a0a, 0xf280, 0, 0, 0, 0, 0, 0), 32),
];
/// Домены Telegram, к которым SOCKS5-клиент может попроситься по имени.
///
/// Это веб-инфраструктура, а не дата-центры: обычный HTTPS, MTProto в нём нет.
/// Telegram ходит сюда за конфигурацией, превью ссылок и файлами CDN, и на
/// телефоне такие запросы идут через тот же прокси.
const HOSTS: &[&str] = &[
"telegram.org",
"t.me",
"telegram.me",
"telesco.pe",
"cdn-telegram.org",
];
/// Принадлежит ли адрес Telegram.
pub fn is_telegram(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => in_v4(ip),
// Клиент может прислать `::ffff:149.154.167.51` вместо IPv4-формы, и
// это тот же самый адрес.
IpAddr::V6(ip) => match ip.to_ipv4_mapped() {
Some(ip) => in_v4(ip),
None => in_v6(ip),
},
}
}
/// Принадлежит ли имя Telegram.
///
/// Совпадение только по границе метки: `telegram.org.example.com` — чужой
/// домен, и разрешать его нельзя.
pub fn is_telegram_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
HOSTS.iter().any(|suffix| {
host == *suffix
|| (host.len() > suffix.len()
&& host.ends_with(suffix)
&& host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
})
}
/// Номер дата-центра по адресу — запасной вариант, когда его не удалось
/// достать из init-пакета.
///
/// Это догадка, а не факт: одна и та же подсеть обслуживает несколько DC
/// (`149.154.175.x` — и DC1, и DC3). Настоящий номер приходит из init, и сюда
/// попадают только соединения, у которых init разобрать не вышло.
pub fn dc_from_ip(ip: IpAddr) -> Option<u16> {
match ip {
IpAddr::V4(ip) => dc_from_ipv4(ip),
IpAddr::V6(ip) => match ip.to_ipv4_mapped() {
Some(ip) => dc_from_ipv4(ip),
None => dc_from_ipv6(ip),
},
}
}
fn dc_from_ipv4(ip: Ipv4Addr) -> Option<u16> {
if !in_v4(ip) {
return None;
}
let octets = ip.octets();
Some(match (octets[0], octets[1], octets[2]) {
(149, 154, 160..=163) => 1,
(149, 154, 164..=167) => 2,
(149, 154, 168..=171) => 3,
(149, 154, 172..=175) => 1,
(91, 108, 56..=59) => 5,
(91, 108, 8..=11) => 3,
(91, 108, 12..=15) => 4,
(91, 105, 192..=193) => 203,
_ => 2,
})
}
/// Адреса дата-центров имеют вид `2001:b28:f23d:f002::a`, где `f00N` —
/// номер DC. Для `2a0a:f280::/32` такого правила нет, и выдумывать его не
/// нужно: номер придёт из init.
fn dc_from_ipv6(ip: Ipv6Addr) -> Option<u16> {
if !in_v6(ip) {
return None;
}
let dc = ip.segments()[3].checked_sub(0xf000)?;
matches!(dc, 1..=5).then_some(dc)
}
fn in_v4(ip: Ipv4Addr) -> bool {
let value = u32::from(ip);
V4.iter().any(|&(network, prefix)| {
let mask = u32::MAX.checked_shl(32 - prefix).unwrap_or(0);
value & mask == u32::from(network) & mask
})
}
fn in_v6(ip: Ipv6Addr) -> bool {
let value = u128::from(ip);
V6.iter().any(|&(network, prefix)| {
let mask = u128::MAX.checked_shl(128 - prefix).unwrap_or(0);
value & mask == u128::from(network) & mask
})
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(value: &str) -> IpAddr {
value.parse().unwrap()
}
#[test]
fn known_data_centre_addresses_are_telegram() {
for address in [
"149.154.175.50", // DC1
"149.154.167.51", // DC2
"149.154.175.100",
"149.154.167.91",
"91.108.56.130", // DC5
"91.105.192.100",
"185.76.151.1",
] {
assert!(is_telegram(ip(address)), "{address} принадлежит Telegram");
}
}
/// Раньше сюда попадал весь `/16`, то есть десятки тысяч чужих адресов.
#[test]
fn neighbours_outside_the_published_blocks_are_not_telegram() {
for address in [
"149.154.159.255", // на один адрес ниже 149.154.160.0/20
"149.154.176.0", // на один выше
"91.108.3.255",
"91.108.24.0",
"91.108.60.0",
"91.105.194.0",
"185.76.150.255",
"185.76.152.0",
"1.1.1.1",
] {
assert!(!is_telegram(ip(address)), "{address} — не Telegram");
}
}
#[test]
fn block_edges_belong_to_the_block() {
for address in [
"149.154.160.0",
"149.154.175.255",
"91.108.4.0",
"91.108.7.255",
"185.76.151.0",
"185.76.151.255",
] {
assert!(is_telegram(ip(address)), "{address} — край блока Telegram");
}
}
/// Из-за этого LAN-режим отклонял живой Telegram (by-sonic/tglock#42).
#[test]
fn ipv6_data_centres_are_telegram_too() {
for address in [
"2001:b28:f23d:f001::a",
"2001:67c:4e8:f002::a",
"2001:b28:f23d:f003::a",
"2001:67c:4e8:f004::a",
"2001:b28:f23f:f005::a",
"2001:b28:f23c::1",
"2a0a:f280:203:a::b",
] {
assert!(is_telegram(ip(address)), "{address} принадлежит Telegram");
}
assert!(!is_telegram(ip("2606:4700:4700::1111")), "это Cloudflare");
assert!(!is_telegram(ip("2001:b28:f23e::1")), "соседний префикс");
}
#[test]
fn ipv4_mapped_form_is_the_same_address() {
assert!(is_telegram(ip("::ffff:149.154.167.51")));
assert!(!is_telegram(ip("::ffff:1.1.1.1")));
}
/// Поведение, на которое опирался предыдущий тест `dc_from_ip`.
#[test]
fn data_centre_guess_survives_the_stricter_membership_check() {
assert_eq!(dc_from_ip(ip("149.154.160.1")), Some(1));
assert_eq!(dc_from_ip(ip("149.154.167.255")), Some(2));
assert_eq!(dc_from_ip(ip("91.108.58.1")), Some(5));
assert_eq!(dc_from_ip(ip("1.1.1.1")), None);
}
#[test]
fn data_centre_guess_reads_the_number_out_of_an_ipv6_address() {
assert_eq!(dc_from_ip(ip("2001:67c:4e8:f002::a")), Some(2));
assert_eq!(dc_from_ip(ip("2001:b28:f23f:f005::a")), Some(5));
assert_eq!(
dc_from_ip(ip("2a0a:f280:203:a::b")),
None,
"для этого префикса правила нет — лучше признаться, чем выдумать"
);
}
#[test]
fn telegram_hosts_are_matched_on_label_boundaries() {
for host in [
"telegram.org",
"web.telegram.org",
"core.telegram.org",
"venus.web.telegram.org",
"t.me",
"TELEGRAM.ORG",
"telegram.org.", // корневая точка в имени законна
"cdn4.cdn-telegram.org",
] {
assert!(is_telegram_host(host), "{host} — Telegram");
}
}
#[test]
fn lookalike_hosts_are_rejected() {
for host in [
"telegram.org.example.com",
"nottelegram.org",
"fakecdn-telegram.org",
"t.me.evil.net",
"example.com",
"",
] {
assert!(!is_telegram_host(host), "{host} — не Telegram");
}
}
}
+1299
View File
File diff suppressed because it is too large Load Diff

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