Пока мы ждём, что в Telegram наконец раскатится обновлённая реализация Fake-TLS, хочу рассказать о своей реализации MTProto-прокси 2018 года, которая снова становится актуальной, и об одной из её уникальных возможностей.
MTProto-прокси — решение для доступа к Telegram в странах с интернет-цензурой. Типичная схема выглядит так: оператор поднимает прокси с одним секретным ключем на всех, публикует одну ссылку, и тысячи людей ей пользуются, у них над списком чатов появляется рекламируемый владельцем прокси канал. Просто, но у этой модели есть реальные ограничения:
Нет контроля доступа. Любой, у кого есть ссылка, может пользоваться вашим сервером бесконечно. Отозвать доступ у конкретного пользователя без смены общего секрета — а значит, без поломки ссылок у всех остальных — невозможно.
Нет аналитики на пользователя. Видно общее число соединений и IP-адреса, но не понять, кто активен, кто раздал свою ссылку полусотне друзей и кто потребляет весь ваш трафик.
Ограниченная монетизация. Да, есть “спонсорские каналы”, но если хотите продавать доступ — не за что зацепиться: секрет один на всех, привязать подписку не к чему.
А что если у каждого пользователя будет свой уникальный секрет, дающий доступ только ему? Тогда можно продавать подписки как на VPN, отзывать доступ у отдельных пользователей, ограничивать использование и отслеживать уровень активности.
Справедливости ради: Python mtprotoproxy (популярная однофайловая реализация на Python) поддерживает несколько пользователей, каждый со своим секретом. Но этот подход не масштабируется: на каждом входящем соединении он последовательно O(n) пробует расшифровать хэндшейк всеми настроенными секретами, пока один не подойдёт. На практике больше ~100 пользователей — и CPU-нагрузка на соединение становится проблемой.
Erlang mtproto_proxy устроен иначе: один общий базовый секрет на порт, валидация каждого соединения за O(1), а контроль доступа на уровне пользователя вынесен в runtime-конфигурируемую систему полиси. В этой статье мы объясним, как она работает, покажем простейший способ использовать её из командной строки, а затем разберём полноценное демо-приложение — Personal MTProxy — которое объединяет mtproto_proxy, веб-сервер и персистентное хранилище в самостоятельный портал и API для регистрации персональных прокси.
Статья рассчитана на читателей, имеющих базовое представление о том, что такое MTProto-прокси и TLS/SSL. В последних разделах есть код на Erlang; знание языка поможет, но не обязательно.
Почему Erlang для прокси?
Прежде чем переходить к фиче, стоит разобраться, почему mtproto_proxy написан на Erlang и почему это идеальный выбор для данной задачи.
Конкурентность в ДНК. Erlang разработан в Ericsson в 1986 году для телефонных коммутаторов — та же предметная область, что и прокси: миллионы долгоживущих соединений, нулевой даунтайм, сбой в одном звонке не должен затрагивать другие. Модель акторов — это не библиотека поверх языка; это модель исполнения самой виртуальной машины.
Каждое соединение — изолированный процесс. Падение обработчика одного клиента не может испортить состояние другого. Деревья супервизоров OTP автоматически перезапускают упавшие компоненты. Система деградирует под нагрузкой мягко, а не рушится целиком. Добавим к этому почти уникальную фишку BEAM — вытесняющяя многозадачность: после выполнения определённого количества инструкций актор принудительно вытесняется VM, как процесс ядром Linux. Ни один клиент не может надолго занять целое ядро.
Масштабируется на все ядра CPU автоматически. Планировщик BEAM запускает по OS-потоку на ядро и распределяет лёгкие процессы (от ~2 КБ памяти каждый) между ними. Никаких thread pool, никакого ручного управления event loop.
Не CPU-bound. Настоящая работа прокси — это I/O и байтовая маршрутизация. Единственная значимая CPU-нагрузка — шифрование AES-CTR, которое выполняет нативная библиотека crypto (OpenSSL под капотом). Всё остальное — передача сообщений между процессами и сокетами.
Bit syntax делает парсинг бинарных протоколов тривиальным и безопасным. Двоичный pattern matching — встроен в синтаксис языка. Можно полностью распарсить целый TLS ClientHello буквально одной строкой, с шириной полей, big/little порядком байт и length-prefixed подполями — всё в одном выражении:
parse_client_hello( <<?TLS_REC_HANDSHAKE, ?TLS_10_VERSION, TlsFrameLen:16, ?TLS_TAG_CLIENT_HELLO, HelloLen:24, ?TLS_12_VERSION, Random:32/binary, SessIdLen, SessId:SessIdLen/binary, CipherSuitesLen:16, CipherSuites:CipherSuitesLen/binary, CompMethodsLen, CompMethods:CompMethodsLen/binary, ExtensionsLen:16, Extensions:ExtensionsLen/binary>> ) when TlsFrameLen >= 512, HelloLen >= 508 -> ...
Это реальный шаблон из mtp_fake_tls.erl. Некорректное чтение невозможно: паттерн либо совпадает со всем бинарем целиком, либо нет.
Интроспекция живой системы. Erlang поставляется со встроенным удалённым шеллом. Можно подключить удалённую консоль прямо к работающей виртуальной машине — без дополнительных отладочных интерфейсов, без debug-сборки — и инспектировать состояние процессов, вызывать функции, трейсить вызовы функций и отправки сообщений, манкипатчить и обновлять конфиги, пока прокси обслуживает живой трафик:
mtp_proxy remote_console # Теперь вы внутри живой VM: (mtp_proxy@host)> mtp_policy_table:table_size(personal_domains).
Горячее обновление кода. Коллбэк code_change/3 из OTP и перезагрузка sys.config позволяют обновлять работающий код бизнес-логики, состояние в памяти и конфигурацию без остановки приложения. Существующие соединения клиентов переживают апгрейд без разрыва — критично для продакшн-прокси с долгоживущими подключениями, где рестарт означает отключение тысяч пользователей.
Ну и не забываем, что серверная часть WhatsApp работает на Erlang.
SNI, Fake-TLS и как секреты кодируют домены
Что такое SNI?
Server Name Indication (SNI) — расширение TLS, которое позволяет клиенту сообщить серверу, к какому домену он обращается, ещё до завершения TLS-хэндшейка. Именно так один IP-адрес может обслуживать TLS-сертификаты для многих доменов: сервер читает поле SNI из ClientHello, выбирает соответствующий сертификат и продолжает.
В настоящем TLS 1.3 ClientHello передаётся в открытом виде (шифруются только последующие сообщения), поэтому SNI виден любому наблюдателю на пути — включая DPI-системы. Именно поэтому fake-TLS так эффективен (хотя есть нюансы): он имитирует TLS 1.3 ClientHello побайтово, включая валидное поле SNI, указывающее на легитимно выглядящий домен, — и при этом прячет данные MTProto-хэндшейка внутри полей, которые DPI не может распознать без знания секретного ключа.
Формат fake-TLS секрета
Секрет прокси для fake-TLS включает в себя и секретный ключ прокси, и SNI-домен:
0xEE | <16 случайных байт (базовый токен)> | <SNI-домен в UTF-8>
В виде hex-строки (для ссылок t.me/proxy):
ee<32 hex-символа 16-байтового токена><hex-encoded SNI-домен>
Существует и Base64-кодировка, но она менее распространена из-за багов в клиентах Telegram.
Прокси валидирует каждый входящий ClientHello, вычисляя HMAC пакета с использованием единого общего базового секрета (один на порт). Если дайджест сходится, то прокси считает клиента легтимным. Дальше наша реализация прокси извлекает SNI-домен из SNI-расширения ClientHello и передаёт в движок полиси как tls_domain. Все пользователи разделяют один базовый секрет; уникальность каждой персональной ссылки определяется SNI-доменом, «запечённым» в неё, а не отдельным 16-байтным секретом.
Чтобы создать или декодировать такой секрет без написания кода, используйте mtpgen.html (страница не делает запросов к серверу - считает всё в JS).
Система полиси
mtproto_proxy проверяет policy — упорядоченный список правил — для каждого входящего соединения перед пересылкой в Telegram. Если любое правило не выполняется, соединение отклоняется. Представьте это как правила iptables для соединений прокси: правила проверяются сверху вниз, первый сбой — соединение дропается.
Доступные типы правил:
Правило | Описание |
|---|---|
| Значение |
| Значение |
| Число подключений с одинаковыми значениями ключей не превышает |
Доступные ключи, извлекаемые из соединения:
Ключ | Значение |
|---|---|
| SNI-домен, закодированный в fake-TLS секрете |
| Имя слушателя сокета прокси (из конфига |
| IPv4-адрес клиента |
| IPv6-адрес клиента |
| IPv4-подсеть клиента, до |
| IPv6-подсеть клиента, до |
Таблицы — именованные in-memory хэш-таблицы. Они переживают перезагрузку конфигурации, но теряются при рестарте (если вы их не перезаполняете сторонними средствами). В аналогии с iptables это ipset-сеты.
Полиси для персонального прокси
Политика для персональных прокси крайне простая — два правила:
{policy, [ {in_table, tls_domain, personal_domains}, {max_connections, [tls_domain], 30} ]}
{in_table, tls_domain, personal_domains}— белый список для SNI-домена. Домен, закодированный в секрете клиента, должен существовать в таблицеpersonal_domains(можно назвать как угодно). Незарегистрированный домен → соединение отклоняется.{max_connections, [tls_domain], 30}— не более 30 одновременных соединений с одним SNI-доменом. Это ограничивает раздачу ссылки на прокси: даже если пользователь опубликует свою ссылку публично, не выйдет открыть более 30 соединений одновременно (обычно Telegram-клиент открывает до 8 соединений).
В сочетании с {allowed_protocols, [mtp_fake_tls]} (принимается только fake-TLS), каждый клиент обязан “предъявить” валидный зарегистрированный персональный SNI-домен — иначе отказ.
Если нужно ограничить число соединений по IP-адресу, добавьте ещё два правила (или одно, если не используете IPv6):
{max_connections, [client_ipv4], 30}, {max_connections, [client_ipv6], 30}
Если нужно забанить какие-то IP-адреса, можно добавить правило с черным списком:
{not_in_table, client_ipv4, ip4_blacklist},
Осталось понять как именно заполнять эти таблицы.
Подход 1: Стандартный персональный прокси через eval
Никаких изменений в коде не требуется. Установите mtproto_proxy, добавьте политику в sys.config, затем добавляйте SNI-домены в таблицу командой Erlang RPC из шелла.
sys.config
{mtproto_proxy, [ {ports, [ #{name => mtp_handler, listen_ip => "0.0.0.0", port => 443, secret => <<"d0d6e111bada5511fcce9584deadbeef">>, tag => <<"dcbe8f1493fa4cd9ab300891c0b5b326">>} ]}, {allowed_protocols, [mtp_fake_tls]}, {policy, [ {in_table, tls_domain, personal_domains}, {max_connections, [tls_domain], 100} ]} ]}
Регистрация пользователя
Используйте mtpgen.html для генерации персональной ссылки для домена alice42.example.com, затем добавьте SNI-домен в вайтлист:
mtp_proxy eval ' mtp_policy_table:add(personal_domains, tls_domain, "alice42.example.com").'
Всё — домен активен немедленно, рестарт не нужен.
Плюсы и минусы
✅ Никакого кода. Работает с любой существующей инсталляцией mtproto_proxy.
❌ Таблица теряется при рестарте. После каждого рестарта нужно заново добавлять все домены — как правило, через скрипт ExecStartPost в systemd, который заливает список.
❌ Нет удобного UI или API. Команда eval для добавления поддоменов в allow-list довольно медленная.
Подход 2: Встраиваем mtproto_proxy в собственное Erlang-приложение
Чтобы закрыть оба минуса, мы собрали небольшое демо-приложение, которое:
Оборачивает
mtproto_proxyкак библиотечную зависимость.Добавляет HTTPS-сервер Cowboy с UI регистрации и API.
Сохраняет зарегистрированные SNI-домены на диск и автоматически восстанавливает их в таблицу политик при каждом рестарте.
Полный исходный код: https://github.com/seriyps/personal_mtproxy
Демо-инстанс пока что работает здесь: https://demo.personal-mtp.online/admin.html

Архитектура и domain fronting
Internet (порт 443, IPv4 + IPv6) │ ▼ mtproto_proxy (socket listeners) │ ├── fake-TLS хэндшейк OK (валидный секрет, SNI-домен в whitelist) │ └── policy: in_table + max_connections → пересылка в Telegram │ └── fake-TLS хэндшейк FAILS (неверный дайджест — браузер или DPI-зонд) └── domain_fronting → 127.0.0.1:1443 │ ▼ Cowboy HTTPS (UI регистрации) GET / → index.html POST /api/proxies → JSON
Domain fronting — механизм, который делает прокси похожим на легитимный HTTPS-сайт длявайтлист браузеров и DPI-зондов, но при этом служит MTProto-прокси для тех, кто знает секрет.
Для этого демо мы раздаём UI регистрации на том же 443 порту, что и MTP-прокси — никакой дополнительной инфраструктуры, никаких зависимостей для деплоя и хороший пример того, как работает fronting. В реальном продакшене вы бы фронтировали не связанный с прокси, ни в чём не подозрительный публичный сайт (например, корпоративный лендинг или блог, или добавили бы прокси в round-robin DNS реального сайта, чтобы через него шёл реальный HTTPS трафик для сбивания DPI с толку), а UI регистрации и API обслуживались бы на отдельном внутреннем порту или вовсе на другой машине. Демо personal_mtproxy поддерживает оба режима через конфиг-опции web_listen_ip / web_listen_port.
При попытке подключиться, если fake-TLS хэндшейк не проходит — потому что клиент является настоящим браузером или DPI-зондом или не знает секрет — mtproto_proxy не закрывает соединение. Вместо этого он пробрасывает сырой TCP-поток на настроенный domain_fronting адрес, которым в демке является наш веб-сервер.
Чтобы фронтировать полностью посторонний сайт (например, microsoft.com) и обслуживать UI регистрации на отдельном внутреннем порту, конфиг по умолчанию нужно изменить:
%% Фронтируем microsoft.com; admin UI / API слушает на localhost:8443 {mtproto_proxy, [ ... {domain_fronting, "microsoft.com:443"}, ... ]}, {personal_mtproxy, [ ... {web_listen_ip, "127.0.0.1"}, %% admin UI только для localhost {web_listen_port, 8443}, ... ]}
Персистентность через DETS
Нам нужно хранить список зарегистрированных SNI-доменов так, чтобы он переживал рестарты. Простейшим решением был бы просто текстовый файл — один домен на строку — читаемый при старте и дополняемый при каждой регистрации. Но для этого демо мы выбрали DETS, потому что это встроенная в Erlang дисковая хэш-таблица: атомарные вставки, восстановление после краша и O(1) lookups без внешнего процесса или демона. Всё это часть стандартной библиотеки, никаких дополнительных зависимостей и количество кода сравнимое с текстовым файлом.
На практике хранилище — это деталь реализации. gen_server pm_registry владеет DETS-файлом; заменить его на PostgreSQL, Redis или plain-файл — значит изменить только этот модуль.
При старте registry загружает все сохранённые домены в таблицу политик:
ok = dets:foldl( fun({Subdomain, _Email, _Timestamp}, ok) -> mtp_policy_table:add(personal_domains, tls_domain, Subdomain) end, ok, DetsRef),
Теперь после рестарта белый список идентичен тому, что был до выключения — никакого ручного вмешательства.
API и процесс регистрации
Когда пользователь нажимает «Получить мой прокси», браузер отправляет POST /api/proxies с опциональным полем email. Веб-обработчик парсит форму и вызывает pm_registry:register(MailAddress).
Registry генерирует 5-символьный поддомен ([a-z]{5}, ~11.8 миллиона комбинаций), проверяет DETS на коллизию (до 5 попыток), пишет запись в DETS и добавляет SNI-домен в таблицу политик. Затем возвращает поддомен, порт и базовый секрет обработчику.
Веб-обработчик строит секрет прокси как "ee" ++ base_secret_hex ++ hex(subdomain), формирует ссылки t.me/proxy и tg://proxy и возвращает JSON.
Статические файлы (index.html) отдаются встроенным обработчиком Cowboy cowboy_static — никакого шаблонизатора и лишнего кода не требуется.
Полный sys.config
[ %% Настройки mtproto прокси {mtproto_proxy, [ {ports, [ %% прием коннектов IPv4 #{name => mtp_ipv4, listen_ip => "0.0.0.0", port => 443, secret => <<"d0d6e111bada5511fcce9584deadbeef">>, tag => <<"dcbe8f1493fa4cd9ab300891c0b5b326">>}, %% прием коннектов IPv6 #{name => mtp_ipv6, listen_ip => "::", port => 443, secret => <<"d0d6e111bada5511fcce9584deadbeef">>, tag => <<"dcbe8f1493fa4cd9ab300891c0b5b326">>} ]}, %% разрешаем только TLS/Fake-TLS {allowed_protocols, [mtp_fake_tls]}, {domain_fronting, "127.0.0.1:1443"}, {policy, [ {in_table, tls_domain, personal_domains}, {max_connections, [tls_domain], 100} ]} ]}, %% Настройки админки {personal_mtproxy, [ %% для демки пропускаем 'web_listen_ip/port' чтоб админка поднималась по %% адресу 'domain_fronting' прокси %% {web_listen_ip. "127.0.0.1"}, %% {web_listen_port, 2443}, {base_domain, "demo.personal-mtp.online"}, {dets_file, "/var/lib/personal_mtproxy/proxies.dets"}, {ssl_cert, "/etc/letsencrypt/live/demo.personal-mtp.online/fullchain.pem"}, {ssl_key, "/etc/letsencrypt/live/demo.personal-mtp.online/privkey.pem"} ]} ].
TLS-сертификат
В наших примерах UI обслуживается на домене 3-го уровня, а персональные секреты — на 4-м, но можно использовать любые уровни: например, UI на 2-м уровне personal-mtp.online, а персональные домены на 3-м alice42.personal-mtp.online.
Простая настройка (без wildcard)
UI регистрации обслуживается на demo.personal-mtp.online — базовый домен 3-го уровня. Достаточно стандартного DV-сертификата через certbot:
certbot certonly --standalone -d demo.personal-mtp.online
Персональные SNI-домены — 4-го уровня (alice42.demo.personal-mtp.online). Telegram-клиент только притворяется TLS, ему плевать на сертификаты. Fake-TLS ClientHello проверяется по секрету, а не по настоящему TLS-сертификату. Несовпадение поддомена невидимо для Telegram.
Тем не менее, оно видно двум вещам:
Настоящим браузерам — попытка открыть
https://alice42.demo.personal-mtp.onlineпокажет предупреждение о несоответствии сертификата. Для прокси это приемлемо: пользователи переходят по диплинкуtg://proxy, в браузере они этот URL не открывают.Активным DPI-зондам, которые МОГУТ попытаться повторить перехваченный запрос и завершить настоящий TLS-хэндшейк с проверкой сертификата. Несовпадение между SNI-хостнеймом и SAN сертификата — аномалия, которую продвинутый зонд мог бы зафиксировать.
Wildcard-сертификат
Можно конечно запрашивать у LetsEncrypt отдельный сертификат для каждого поддомена. Но в целом для решения такого рода задач существуют Wildcard-сертификаты. Правда для получения требуется DNS-01-challenge, поскольку CA должен убедиться, что вы контролируете *.demo.personal-mtp.online:
certbot certonly --manual --preferred-challenges dns \ -d "*.demo.personal-mtp.online" \ -d "demo.personal-mtp.online"
Certbot попросит создать TXT-запись _acme-challenge.demo.personal-mtp.online в вашем DNS. После добавления нажмите Enter, certbot проверит запись и выдаст сертификат. именно такой сертификат используется в нашей онлайн-демке.
Запуск демо локально
На Linux или macOS:
git clone https://github.com/seriyps/personal_mtproxy cd personal_mtproxy make dev # генерирует самоподписанный сертификат, обновляет /etc/hosts, запускает rebar3 shell
Откройте UI либо напрямую https://demo.personal-mtp.test:1443/ в браузере (примите предупреждение о самоподписанном сертификате) либо через прокси-фронтинг https://demo.personal-mtp.test:2443/ (порт mtproto proxy c фронтингом на UI). Введите необязательный email, нажмите Get my proxy — получите персональную ссылку t.me/proxy и диплинк tg://proxy. Их можно использовать сразу же, даже на localhost через Telegram Desktop!
Для очистки:
make clean # удаляет запись /etc/hosts, самоподписанный сертификат и скомпилированные файлы
Инструкции по деплою в прод — в README демо-приложения.
Идеи на будущее
Демо намеренно минималистично. Естественные следующие шаги:
Оплата и подписки. Реализуйте на Erlang или используйте API демо-приложения. При успешном платёжном событии вызовите
pm_registry:register/1илиPOST /api/proxiesи отправьте ссылку пользователю на email.Счётчики соединений на пользователя.
mtp_policy_counterотслеживает число соединений в памяти; можно выставить их через простой admin API endpoint для мониторинга.Отзыв доступа уже реализован:
DELETE /api/proxies?subdomain=<sub>удаляет SNI-домен из персистентного хранилища и из таблицы политик, немедленно отрезая доступ пользователю. В UI это кнопка «Revoke», появляющаяся сразу после регистрации.Срок действия. Временная метка регистрации хранится рядом с каждым поддоменом. Таймер в
pm_registryможет автоматически вытеснять SNI-домены старше N дней.Отдельная панель администратора. Как описано выше - вместо совмещения фронтируемого сайта и UI регистрации — запускаем admin-панель на локальном или защищённом порту, а на 443 фронтируем “легитимный” публичный сайт.
Независимые секреты на пользователя. Сейчас все пользователи разделяют один базовый 16-байтовый секрет на уровне порта — персональность каждой ссылки определяется SNI-доменом, а не криптографическим ключом. Естественное расширение для прокси, работающего только с Fake-TLS, — держать маппинг
SNI <-> personal_secret_keyи выдавать каждому зарегистрированному поддомену собственный 16-байтовый секрет. Это потребует небольшого изменения в самомmtproto_proxy: вместо валидации HMAC единственным известным секретом прокси должен сначала извлечь SNI-домен из ClientHello, найти соответствующий пользовательский секрет и только потом провалидировать. Это возможно, потому что SNI передаётся в открытом виде.
Заключение
Система политик mtproto_proxy делает его уникальным решением среди реализаций MTProto-прокси: это единственная реализация, позволяющая эффективно выдавать и отзывать персональные секреты каждому пользователю, применять per-user лимиты соединений.
Демо personal_mtproxy показывает, что собрать из этого self-hosted портал регистрации требует менее 250 строк Erlang. Языковые решения — OTP-фреймворк, gen_server для сериализованного доступа к состоянию, DETS для персистентности без зависимостей, Cowboy для HTTP(S) — всё из стандартного набора Erlang/OTP при минимуме внешних зависимостей.
Код самого прокси: https://github.com/seriyps/mtproto_proxy
Код демки: https://github.com/seriyps/personal_mtproxy
Запущенный через фронтинг пример демки: https://demo.personal-mtp.online/admin.html
