Что у нас технически сделано

Ansible — не форк и не сборка чужого сервера. Совместимость по семейству протокола нужна, чтобы работали существующие клиентские библиотеки, но сервер, транспорт, хранилище и вся продуктовая часть написаны заново. Ниже — что именно отличается от того, на что мы похожи снаружи.

Протокол MVSy 1.0

Транспортный и криптографический слой живёт отдельным OTP-приложением (bh-mvsy): TL-кодеки, хендшейк, сессии, шифрование. Сервер бизнес-логики (bh-backend) подключает его как зависимость и о проводе ничего не знает. Разделение не косметическое — оно позволяет менять транспорт, не трогая ~330 RPC-методов.

Шифрование — AES-IGE поверх сессионного ключа из хендшейка Диффи-Хеллмана. Проверено вживую: маркер, вставленный в плейнтекст на уровне L0, на проводе не находится ни в исходном виде, ни после снятия обфускации. Что всё равно утекает — метаданные: auth_key_id, длины пакетов, тайминги. Это свойство любой сети доставки, и мы предпочитаем сказать это прямо.

Четыре транспорта, один обработчик сессии

Клиент может подключиться четырьмя способами, и все четыре сходятся в один обработчик:

  • TCP — прямой, для нативных клиентов;
  • WebSocket на 443 — там, где нестандартные порты закрыты;
  • obfuscated2 — убирает узнаваемый отпечаток протокола;
  • Fake-TLS — на проводе выглядит обычным HTTPS-рукопожатием.

Обфускация не даёт приватности — её даёт шифрование. Она снимает признак, по которому соединение можно отсеять как «это мессенджер».

Топология и то, чему она нас научила

Один дата-центр, три master-узла Kubernetes за общим VIP. Мульти-DC заложен в роутинге (bh_config_dcs, экспорт/импорт авторизации, маршрутизация хранилища), но в проде пока один регион.

Дорогой урок: при разнесении PostgreSQL, Redis и MinIO по географии от бэкенда пол любой операции упирался в ~11 мс сетевого RTT — не в код. Никакая оптимизация запроса этого не отыгрывает. Лечится только совместным размещением, что и сделано.

Отдача медиа: почему появился /file/<token>

Исходный маршрут /progressive/photo<id>?h=<access_hash> работает, но поставить перед ним CDN нельзя, и по трём независимым причинам:

  • он отвечает 206 на любой запрос, с Range-заголовком и без, а частичные ответы разделяемые кеши не хранят вообще;
  • авторизация лежит в query-строке, а не в пути, и это тот же MTProto-хеш файла, которым пользуется протокол;
  • типа файла в URL нет, край не может ответить content-type сам.

Поэтому добавлен /file/<token>.<ext>. Токен — 20 байт: epoch, тип, file_id и HMAC-SHA256, усечённый до 80 бит. Обычный GET получает 200 и целое тело; Range по-прежнему работает.

Токен детерминированный, без метки времени — и это не недосмотр. Кеш имеет смысл только если у файла один URL навсегда; экспирация внутри токена минтила бы новый адрес на каждый рендер и попаданий было бы ноль. Массовый отзыв — байт epoch в конфиге.

Реестр приложений и выключатель

my.ansible.su выдаёт api_id/api_hash. Вход — код внутрь мессенджера, не по SMS: портал умеет выпускать credentials и удалять аккаунт, поэтому владения номером недостаточно — аккаунт должен быть достижим.

До этого api_id был значением, которое сервер только писал в кодированные авторизации и никогда не проверял, то есть отрубить абузивный клиент было нечем. Теперь проверка стоит в единственной точке входа RPC — все три транспорта сходятся туда, поэтому отказ не требует правок в каждом.

Отдельно: неизвестный api_id разрешён намеренно. В реестре только приложения, выпущенные порталом, а первые-партии клиенты носят id, появившиеся раньше него; «неизвестный = отказ» выкинул бы всех из сети в момент выкатки. Отказывает только явный бан.

Ссылки за пределами мессенджера

Ссылка на группу, канал или юзернейм, открытая в обычном браузере, раньше попадала в SPA сайта, у которого не было такого роута, и URL молча подменялся главной — то есть ссылка теряла то, на что указывала. Теперь на неё отвечает отдельный сервис с серверным рендером: OG-теги в первом ответе, переход в приложение до загрузки фреймворка.

Резолвер — на бэкенде, и он отвечает одинаково на «не существует», «отозвано» и «истекло». Различать их означало бы дать оракул для перебора хешей приглашений.

Единый баланс и подарки

Кошелёк — отдельный сервис со своей базой; мессенджер владеет коллекционными объектами (подарки, реальные номера и юзернеймы), кошелёк — балансом. Общение S2S. Атомарность покупки на P2P-маркете обеспечивается одним CTE, а не последовательностью запросов: иначе между списанием и передачей есть окно.

Секретные чаты

End-to-end, ключ выводится на устройствах. Сервер — слепой релей: он передаёт байты, не имея ключа и не участвуя в обмене Диффи-Хеллмана. Из этого следует и ограничение: историю секретного чата нельзя восстановить на новом устройстве, потому что восстанавливать её сервер не из чего.

Покрытие API

Схема TL покрыта на 98,2 % от опубликованной. Оставшееся — методы, которым не соответствует ни одна наша подсистема (часть паспортных, часть рекламных). Каждый реализованный метод проверяется парой e2e-тестов «наш клиент / референсный клиент» — совпадение байт в байт, а не «похоже работает».

Чего пока нет

Раздел, без которого остальное читалось бы как реклама.

  • Групповые звонки — 1:1 работает, SFU для конференций не поднят.
  • Удаление аккаунта из портала отдаёт 501: снос затрагивает сообщения, диалоги, владение каналами и хранилище в двух базах, и кнопка, привязанная к недостроенному сносу, потеряла бы данные необратимо.
  • CDN — origin готов и кешируем, самих узлов нет.
  • Мульти-DC — в коде есть, в проде один регион.
  • Регистрозависимость юзернеймов — в базе WHERE username = $1, а DNS-имя приводится к нижнему регистру, из-за чего @Support и @support могут существовать одновременно. Лечится уникальным индексом по lower(username), нужна миграция.