На чем написан telegram desktop

Критика протокола и оргподходов Telegram. Часть 1, техническая: опыт написания клиента с нуля — TL, MT

В последнее время на Хабре стали чаще появляться посты о том, как хорош Telegram, как гениальны и опытны братья Дуровы в построении сетевых систем, и т.п. В то же время, очень мало кто действительно погружался в техническое устройство — как максимум, используют достаточно простой (и весьма отличающийся от MTProto) Bot API на базе JSON, а обычно просто принимают на веру все те дифирамбы и пиар, что крутятся вокруг мессенджера. Почти полтора года назад мой коллега по НПО «Эшелон» Василий (к сожалению, его учетку на Хабре стёрли вместе с черновиком) начал писать свой собственный клиент Telegram с нуля на Perl, позже присоединился и автор этих строк. Почему на Perl, немедленно спросят некоторые? Потому что на других языках такие проекты уже есть На самом деле, суть не в этом, мог быть любой другой язык, где еще нет готовой библиотеки, и соответственно автор должен пройти весь путь с нуля. Тем более, криптография дело такое — доверяй, но проверяй. С продуктом, нацеленным на безопасность, вы не можете просто взять и положиться на готовую библиотеку от производителя, слепо ему поверив (впрочем, это тема более для второй части). На данный момент библиотека вполне работает на «среднем» уровне (позволяет делать любые API-запросы).

Тем не менее, в данной серии постов будет не так много криптографии и математики. Зато будет много других технических подробностей и архитектурных костылей (пригодится и тем, кто не будет писать с нуля, а будет пользоваться библиотекой на любом языке). Итак, главной целью было — попытаться реализовать клиент с нуля по официальной документации. То есть, предположим, что исходный код официальных клиентов закрыт (опять же во второй части подробнее раскроем тему того, что это и правда бывает так), но, как в старые времена, например, есть стандарт по типу RFC — возможно ли написать клиент по одной лишь спецификации, «не подглядывая» в исходники, хоть официальных (Telegram Desktop, мобильных), хоть неофициальных Telethon?

Оглавление:

Документация… она ведь есть? Правда.

Фрагменты заметок для этой статьи начали собираться еще прошлым летом. Всё это время на официальном сайте https://core.telegram.org документация была по состоянию на Layer 23, т.е. застряв где-то в 2014 году (помните, тогда даже каналов еще не было?). Конечно, по идее, это должно было позволять реализовать клиент с функциональностью на тот момент 2014 года. Но и в таком состоянии документация была, во-первых, неполна, во-вторых, местами противоречила сама себе. Чуть более месяца назад, в сентябре 2019, было случайно обнаружено, что на сайте большое обновление документации, на вполне свежий Layer 105, с пометкой, что теперь всю надо читать заново. Действительно, многие статьи были переработаны, но многие — так и остались без изменений. Поэтому, читая критику ниже по поводу документации, следует иметь в виду, что некоторые из этих вещей уже неактуальны, но некоторые — всё еще вполне. В конце концов, 5 лет в современном мире — это не просто много, а очень много. С тех времен (особенно если не учитывать выброшенные и заново возрожденные с тех пор геочаты) число API-методов в схеме выросло с сотни до более чем двухсот пятидесяти!

С чего начать молодому автору?

Неважно, пишете ли Вы с нуля или используете например готовые библиотеки типа Telethon для Python или Madeline для PHP, в любом случае Вам потребуется сначала зарегистрировать своё приложение — получить параметры api_id и api_hash (работавшие с API ВКонтакте сразу понимают), по которым сервер будет идентифицировать приложение. Это придется сделать и по юридическим соображениям, но подробнее о том, почему авторы библиотек не могут его публиковать, поговорим во второй части. Возможно, вас удовлетворят тестовые значения, хотя они сильно ограничены — дело в том, что сейчас на свой номер можно зарегистрировать только одно приложение, так что не кидайтесь сразу очертя голову.

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

И если писать с нуля, то до использования полученных параметров на самом деле еще далеко. Хотя https://core.telegram.org/ и говорит в Getting Started о них первым делом, на самом деле сначала придется реализовать протокол MTProto — но если Вы поверили раскладке по модели OSI в конце страницы общего описания протокола, то совершенно зря.

На самом деле, и до MTProto, и после, на нескольких уровнях сразу (как говорят зарубежные работающие в ядре ОС сетевики, layer violation) на пути встанет большая, больная и ужасная тема.

Бинарная сериализация: TL (Type Language) и его схема, и слои, и много других страшных слов

Эта тема, собственно, в проблемах Telegram — ключевая. И страшных слов, если Вы попытаетесь в неё вникнуть, будет много.

Итак, схема. Если на это слово Вам вспомнилась, скажем, JSON Schema, Вы подумали правильно. Цель та же: некоторый язык для описания возможного набора передаваемых данных. На этом, собственно, сходство и заканчивается. Если со страницы протокола MTProto, или из дерева исходных текстов официального клиента, мы попытаемся открыть какую-нибудь схему, то увидим нечто вроде:

Человек, видящий это впервые, интуитивно сможет распознать только часть написанного — ну, это видимо структуры (хотя где имя, слева или справа?), вот есть поля в них, после которых через двоеточие идёт тип… наверное. Вот в угловых скобках наверное шаблоны как в Си++ (на самом деле, не совсем). А что значат все остальные символы, знаки вопроса, восклицательные, проценты, решетки (причем явно ведь в разных местах значат разное), где-то присутствующие, а где-то нет, шестнадцатиричные циферки — и самое главное, как из этого получить правильный (который не будет отвергнут сервером) поток байт? Придется читать документацию (да, там рядом бывают ссылки на схему в JSON-версии — но понятнее от этого не становится).

Открываем страницу Binary Data Serialization и погружаемся в волшебный мир грибов и дискретной математики нечто похожее на матан на 4 курсе. Алфавит, тип, значение, комбинатор, функциональный комбинатор, нормальная форма, композитный тип, полиморфный тип… и всё это только первая страница! Дальше Вас ожидает TL Language, который хоть уже и содержит пример тривиального запроса и ответа, совершенно не дает ответа на более типичные случаи, а значит, придется продираться через пересказ математики в переводе с русского на английский еще на восьми вложенных страницах!

Читатели, знакомые с функциональными языками и автоматическим выводом типов, разумеется, увидели в этом языке описания, даже из примера, гораздо больше знакомого, и могут сказать, что это вообще-то в принципе неплохо. Возражения на это таковы:

Как сказал LeoNerd на канале #perl в IRC-сети FreeNode, пытавшийся реализовать гейт из Telegram в Matrix (перевод цитаты неточный по памяти):

Такое чувство, что кто-то впервые познакомился с теорией типов, пришел в восторг и начал пытаться играться с этим, не особенно заботясь, нужно ли это на практике.

Смотрите сами, если необходимость bare-типов (int, long и т.д.) как чего-то элементарного вопросов не вызывают — в конечном счете их надо реализовать вручную — для примера возьмем попытку вывести из них вектор. То есть, на самом деле, массив, если называть получившиеся вещи своими именами.

Краткое описание подмножества синтаксиса TL для тех, кто не… ет читать официальную документацию

Начинает определение всегда конструктор, после которого опционально (на практике — всегда) через символ # следует CRC32 от нормализованной строки описания данного типа. Дальше идет описание полей, если они есть — тип может быть и пустым. Заканчивает это всё через знак равенства имя типа, которому данный конструктор — то есть, фактически, подтип — принадлежит. Тот тип, что справа от знака равенства, он полиморфный — то есть ему может соответствовать несколько конкретных типов.

Почему «конструктор» и «полиморфный», если это не ООП? Ну, на самом деле, кому-то будет проще будет думать об этом именно в терминах ООП — полиморфный тип как абстрактный класс, а конструкторы — это его прямые классы-наследники, причем final в терминологии ряда языков. На самом деле, конечно, здесь лишь похожесть с реальными перегруженными методами конструкторов в ОО-языках программирования. Поскольку тут — всего лишь структуры данных, никаких методов нет (хотя описание функций и методов далее вполне способно создать путаницу в голове, что они есть, но то речь о другом) — то можно думать о конструкторе как о значении, из которого конструируется тип при чтении потока байт.

Вы можете подумать, что как шаблоны и generic’и в плюсах или Java. Но нет. Ну, почти. Это единственный случай применения угловых скобок в реальных схемах, и он используется ТОЛЬКО для Vector. В потоке байт это будут 4 байт CRC32 для самого типа Vector, всегда одинаковые, потом 4 байта — число элементов массива, и дальше сами эти элементы.

Добавьте к этому то, что сериализация всегда происходит словами по 4 байта, все типы ей кратны — к встроенным типам описаны еще bytes и string с ручной сериализацией длины и этого выравнивания по 4 — ну, вроде бы звучит нормально и даже сравнительно эффективно? Хотя TL заявляется как эффективная бинарная сериализация, но хрен уж с ними, с расширением чего попало, даже булевых значений и односимвольных строк до 4 байт, всё равно JSON будет куда толще? Вон, даже ненужные поля могут быть пропущены битовыми флагами, всё совсем хорошо, и даже расширяемо на будущее, взял да и досыпал новых опциональных полей в конструктор потом.

А вот нет, если читать не моё краткое описание, а полную документацию, и подумать над реализацией. Во-первых, CRC32 конструктора считается по нормализованной строке текстового описания схемы (убрать лишние whitespace и т.д.) — так что если добавляется новое поле, изменится строка описания типа, а значит и её CRC32 и, следовательно, сериализация. Да и что старый клиент делал бы, если бы ему пришло поле с новыми установленными флагами, а он не знает, что с ними делать дальше.

Кстати, а кто проверял, что там действительно CRC32? В одном из ранних исходников (еще до Вальтмана) была хэш-функция, умножавшая каждый символ на так любимое этими людьми число 239, ха-ха!

Наконец, ладно, мы поняли, что конструкторы с типом поля Vector и Vector

Это совсем не праздный теоретический вопрос — представьте, Вы получаете список пользователей группы, каждый из которых имеет id, имя, фамилию — разница в объеме передаваемых данных по мобильному соединению может быть значительной. Именно эффективность сериализации Telegram нам и рекламируют.

Vector, который так и не смогли вывести

Если Вы попытаетесь продраться через страницы описания комбинаторов и около, Вы увидите, что вектор (и даже матрицу) формально пытаются вывести через tuples несколько листов. Но в конечном итоге забивают, конечный шаг пропускается, и просто дается определение вектора, который еще и не привязан к типу. В чем тут дело? В языках программирования, особенно функциональных, вполне типично описать структуру рекурсивно — компилятор с его lazy evaluation сам всё поймёт и сделает. В языке сериализации данных же необходима ЭФФЕКТИВНОСТЬ: достаточно просто описать список, т.е. структуру из двух элементов — первым элемент данных, вторым — саму эту же структуру либо пустое место для хвоста (пачка (cons) в Lisp). Но это, очевидно, потребует для каждого элемента дополнительно тратить 4 байта (CRC32 в случае в TL) на описание его типа. Легко можно описать и массив фиксированного размера, но вот в случае массива заранее неизвестной длины — обламываемся.

Поэтому, поскольку TL не позволяет вывести вектор, его пришлось добавить сбоку. В конечном итоге документация сообщает:

Serialization always uses the same constructor “vector” (const 0x1cb5c415 = crc32(«vector t:Type # [ t ] = Vector t”) that is not dependent on the specific value of the variable of type t.

The value of the optional parameter t is not involved in the serialization since it is derived from the result type (always known prior to deserialization).

Присмотритесь: vector# [ t ] = Vector t — но нигде в самом этом определении не сказано, что первое число должно быть равным длине вектора! И ниоткуда это не следует. Это данность, которую нужно держать в голове и реализовывать руками. В других местах документация даже честно упоминает, что тип ненастоящий:

The Vector t polymorphic pseudotype is a “type” whose value is a sequence of values of any type t, either boxed or bare.

… но не акцентирует на этом внимание. Когда Вы, устав продираться через натягивание математики (может быть даже известной Вам из университетского курса), решаете забить и смотреть уже собственно как с этим работать на практике, в голове осталось впечатление: тут Серьезная Математика в основе, придумывали явно Крутые Люди (два математика-призера ACM), а не кто попало. Цель — пустить пыль в глаза — достигнута.

There are type expressions (type-expr) and numeric expressions (nat-expr). However, they are defined the same way.

но в грамматике они описаны одинаково, т.е. эту разницу опять надо помнить и закладывать в реализацию руками.

то ждёшь уже не просто вектор, а вектор юзеров. Точнее, должен ждать — в реальном коде каждый элемент, если не bare-тип, будет иметь конструктор, и по-хорошему в имплементации надо бы проверять — а нам точно в каждом элементе этого вектора прислали того типа? А если это был какой-нибудь PHP, у которого в массиве могут лежать разные типа в разных элементах?

На этом месте начинаешь задумываться — а нужен ли такой TL? Может, для телеги можно было бы и человеческий сериализатор использовать, тот же protobuf, уже тогда существовавший? Это была теория, давайте посмотрим на практику.

Существующие реализации TL в коде

TL родился в недрах ВКонтакте еще до известных событий с продажей доли Дурова и (наверное), еще до начала разработки Telegram. И в выложенных в опенсорс исходниках первой реализации можно найти много весёлых костылей. Да и сам язык там был реализован более полно, чем сейчас в Telegram. Например, хэши в схеме не используются совсем (имеется в виду встроенный псевдотип (как вектор) с девиантным поведением). Или

но рассмотрим для полноты картины, чтобы проследить, так сказать, эволюцию Гиганта Мысли.

Или вот, прекрасное:

Этот фрагмент — про шаблоны, вида:

Это определение шаблонного типа хэшмэп, как вектора пар int — Type. В C++ это выглядело бы примерно так:

так вот, alpha — ключевое слово! Но только в C++ ты можешь писать T, а должен писать alpha, beta… Но не Больше 8 параметров, на тэте фантазия кончилась. Так и представляется, что когда-то в Питере случились примерно такие диалоги:

Но это было про первую выложенную реализацию TL «вообще». Перейдём к рассмотрению реализаций в собственно Telegram-клиентах.

Vasily, [09.10.18 17:07]
Больше всего жопа раскаляется от того, что они навертели кучу абстракций, а потом забили на них болт, и обложили кодогегератор костылями
В результате, сначала от доки лётчик.jpg
Потом от кода джекичан.webp

Автором telegram-cli является Виталий Вальтман, как можно понять по встречаемости формата TLO за его (cli) пределами, член команды — сейчас библиотека для парсинга TL выделена отдельно, какое складывается впечатление о её парсере TL.

16.12 04:18 Vasily: по-моему, кто-то не осилил lex+yacc
16.12 04:18 Vasily: иначе я не могу объяснить это
16.12 04:18 Vasily: ну или им за количество строк в вк платили
16.12 04:19 Vasily: 3к+ строк др вместо парсера

Может, исключение? Давайте посмотрим, как делает это ОФИЦИАЛЬНЫЙ клиент — Telegram Desktop:

1100+ строк на Питоне, пара регулярок + особые случаи типа вектора, который, конечно, объявлен в схеме как полагается по синтаксису TL, но клали они на этот синтаксис, парсить его еще… Спрашивается, зачем было городить всё это чудище слоёное, если всё равно никто не собирается это парсить по документации?!

Кстати… Помните, мы говорили о проверке CRC32? Так вот, в кодогенераторе Telegram Desktop есть список исключений для тех типов, в которых рассчитанный CRC32 не совпадает с указанным в схеме!

Vasily, [18.12 22:49]
и тут бы задуматься, а нужен ли такой TL
если бы я хотел подгадить альтернативным реализациям, я бы начал переносы строк вставлять, половина парсеров сломается на многострочных определениях
tdesktop, впрочем, тоже

Запомните момент об однострочности, мы к нему вернемся чуть позже.

Ладно, telegram-cli — неофициальный, Telegram Desktop — официальный, но что насчет других? А кто знает. В коде Android-клиента вообще не нашлось парсера схемы (что вызывает вопросы к опенсорсности, но это для второй части), зато нашлось несколько других весёлых кусков кода, но о них в подразделе ниже.

Какие еще вопросы на практике поднимает сериализация? Например, наворотили они, конечно, с битовыми полями и условными полями:

Vasily: flags.0? true
означает, что поле присутствует и равно true, если флаг выставлен

Vasily: flags.1? int
означает, что поле присутствует, и его надо десериализовать

А, допустим, Telethon? Забегая вперёд по теме MTProto, пример — в документации есть вот такие куски, но знак % в ней описан только как «соответствующий данному bare-тип», т.е. в примерах ниже или ошибка, или нечто недокументированное:

Я не видел bare определения вектора и не встречал его

В telethon руками написан разбор

В его схеме закоментировано определение msg_container

Опять же, остаётся вопрос про %. Оно не описано.

Vadim Goncharov, [22.06.18 19:22]
а в tdesktop?

Vasily, [22.06.18 19:23]
Но их парсер TL на регуляиках это тоже скорее всего не съест

TL красивая абстракция, никто его не реализует полностью

А % в их версии схемы нет

Но тут документация противоречит сама себе, так что хз

Оно встречалось в грамматике, они могли просто забыть описать семантику

Ты ж видел доку на TL, там без поллитры не разберёшься

«Ну допустим», скажет иной читатель, «что-то вы всё критикуете, так покажите, как надо».

Василий отвечает: «а что касается парсера, мне штуки вида

как-то больше нравятся, чем

т.е. попроще — это мягко сказано».

В общем, в итоге парсер и кодогенератор для реально используемого подмножества TL уложился в примерно 100 строк грамматики и

300 строк генератора (считая и все print ‘ы генерируемого кода), включая плюшки типа информацию о типах для интроспекции в каждом классе. Каждый полиморфный тип превращается в пустой абстрактный базовый класс, а конструкторы — наследуются от него и имеют методы для сериализации и десериализации.

Нехватка типов в языке типов

Строгая типизация — это ведь хорошо, правда? Нет, это не холивар (хотя я предпочитаю динамические языки), а постулат в рамках TL. Исходя из него, язык должен обеспечивать всяческие проверки за нас. Ну окей, пусть не он сам, а реализация, но он должен их хотя бы описывать. И какие же возможности мы хотим?

Прежде всего, constraints. Вот мы видим в документации по закачке файлов:

The last part does not have to satisfy these conditions, provided its size is less than part_size.

Each part should have a sequence number, file_part, with a value ranging from 0 to 2,999.

Что-нибудь из этого присутствует в схеме? Это как-то выразимо средствами TL? Нет. Но позвольте, ведь даже дедовский Turbo Pascal умел описывать типы, задаваемые диапазонами. И еще одну вещь умел, ныне более известную как enum — тип, состоящий из перечисления фиксированного (небольшого) количества значений. В языках типа Си — числовых, заметьте, мы пока говорили только о типах чисел. А ведь есть еще массивы, строки… например, неплохо было бы описать, что вот эта строка может содержать только номер телефона, да?

Ничего из этого в TL нет. Зато есть, например, в JSON Schema. И если про делимость 512 Кб кто-то еще может возразить, что такое всё равно надо проверять в коде, то сделать так, чтобы клиент попросту не мог послать номер вне диапазона 1..3000 (и соответствующей ошибки не могло возникнуть) уж можно было бы, да.

Кстати, об ошибках и возвращаемых значениях. Глаз замыливается даже у тех, кто поработал с TL — до нас не сразу дошло, что каждая функция в TL на самом деле может вернуть не только описанный тип возврата, но и ошибку. Но это средствами самого TL не выводимо никак. Конечно, оно и так понятно и нафиг не нужно на практике (хотя на самом деле, RPC можно делать по-разному, мы еще вернемся к этому) — но как же Чистота концепций Математики Абстрактных Типов из мира горнего. Взялся за гуж — так соответствуй уж.

И в конце концов, что насчет читабельности? Ну, там, вообще хотелось бы description иметь прямо в схеме (в JSON-схеме опять же есть), но если уж с ним напряг, то как насчет практической стороны — хотя бы банально смотреть диффы при обновлениях? Смотрите сами на реальных примерах:

— channelFull#76af5481 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true id:int about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?int migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int = ChatFull;
+ channelFull#1c87a71a flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_view_stats:flags.12?true id:int about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?int migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int = ChatFull;

— message#44f9b43d flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true id:int from_id:flags.8?int to_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?int reply_to_msg_id:flags.3?int date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long = Message;
+ message#44f9b43d flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true id:int from_id:flags.8?int to_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?int reply_to_msg_id:flags.3?int date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long = Message;

У кого как, но GitHub, например, изменения внутри таких длинных строк подсвечивать отказывается. Игра «найди 10 отличий», причем что мозг сразу видит, это что начала и концы в обоих примерах одинаковы, нужно нудно вчитываться где-то в середине… На мой взгляд, вот это вот не то что в теории, а чисто визуально выглядит грязно и неряшливо.

Кстати, о чистоте теории. А зачем нужны битовые поля? Не кажется ли, что они пахнут нехорошо с точки зрения теории типов? Объяснение можно увидеть в ранних версиях схемы. Сначала да, так и было, на каждый чих создавался новый тип. Эти рудименты и сейчас есть вот в таком виде например:

Кроме того, местами эти ребята сами нарушают свою же типизацию. Например, в MTProto (следующая глава) ответ может быть пожат Gzip, всё здраво — кроме того, что нарушение слоев и схемы. Раз, и пожали не сам RpcResult, а его содержимое. Ну вот зачем так делать. Пришлось впиливать костыль, чтобы сжатие работало где угодно.

Иными словами, здесь сериализация делается ВРУЧНУЮ, а не сгенерированным кодом! Может быть, сервер реализован аналогично. В принципе, такое сгодится, если сделать один раз, но как это потом поддерживать при обновлениях? Уж не за этим ли схема была придумана? И тут мы переходим к следующему вопросу.

Версионность. Слои (layers)

Почему версии схемы названы слоями, можно делать только предположения, исходя из истории опубликованных схем. По всей видимости, поначалу авторам показалось, что базовые вещи можно делать на неизмененной схеме, и только там, где надо, на конкретные запросы указывать, что они делаются по другой версии. В принципе, даже неплохая идея — и новое будет как бы «подмешиваться», наслаиваться на старое. Но посмотрим, как это было сделано. Правда, посмотреть с самого начала не удалось — забавно, но схемы базового слоя просто не существует. Слои начались с 2. Документация рассказывает нам о специальной фиче TL:

If a client supports Layer 2, then the following constructor must be used:

In practice, this means that before every API call, an int with the value 0x289dd1f6 must be added before the method number.

Звучит нормально. Но что было дальше? Дальше появился

А дальше? Как нетрудно догадаться,

Смешно? Нет, еще рано смеяться, подумайте над тем, что каждый запрос с другого слоя нужно оборачивать в такой специальный тип — если они у Вас все разные, как их иначе различать-то? И добавление всего лишь 4 байт перед — довольно эффективный метод. So,

Но очевидно, что через некоторое время это станет некоторой вакханалией. И пришло решение:

Update: Starting with Layer 9, helper methods invokeWithLayerN can be used only together with initConnection

Ура! Через 9 версий мы пришли, наконец, к тому, что в Internet-протоколах делалось еще в 80-е — согласованию версии один раз в начале соединения!

А вот теперь таки можно смеяться. Только еще через 9 слоев был, наконец, добавлен универсальный конструктор с номером версии, который нужно вызывать только один раз в начале соединения, и смысл в слоях вроде бы пропал, теперь это просто условная версия, как и везде. Проблема решена.

Vasily, [16.07.18 14:01]
Ещё в пятницу подумалось:
События телесервер посылает без запроса. Запросы нужно заворачивать в InvokeWithLayer. Апдейты сервер не заворачивает, нет структуры для оборачивания ответов и апдейтов.

Т.е. клиент не может указать слой, в котором он хочет апдейты

Vadim Goncharov, [16.07.18 14:02]
а InvokeWithLayer разве не костыль в принципе?

Vasily, [16.07.18 14:02]
Это единственный способ

Vadim Goncharov, [16.07.18 14:02]
который по сути должен значить согласование лэйера в начале сессии

кстати, из этого следует, что даунгрейд клиента не предусмотрен

Апдейты, т.е. тип Updates в схеме — это то, что сервер присылает клиенту не в ответ на API-запрос, а самостоятельно по возникновению события. Это сложная тема, которая будет рассмотрена в другом посте, сейчас же важно знать, что сервер копит Updates и во время оффлайна клиента.

Таким образом, при отказе от оборачивания каждого пакета в указание ему версии, отсюда логически возникают следующие возможные проблемы:

Думаете, это сугубо теоретические умствования, и на практике такого не может возникнуть, ведь сервер написан корректно (во всяком случае, тестируется хорошо)? Ха! Как бы не так!

Именно на это мы в августе и напоролись. 14 августа мелькали сообщения, что на серверах Telegram что-то обновляеют… а дальше в логах:

и далее несколько мегабайт стэктрейсов (ну, заодно и логирование пофиксили). Ведь если у Вас в TL что-то не распозналось — он же бинарный по сигнатурам, дальше в потоке ВСЁ поедет, декодирование станет невозможным. Что вообще в такой ситуации делать?

Ну, первое что любому в голову приходит — отсоединиться и попробовать заново. Не помогло. Гуглим по CRC32 — это оказались объекты с 73 схемы, хотя мы работали на 82. Внимательно смотрим в логи — там идентификаторы с двух разных схем!

Может, проблема сугубо в нашем неофициальном клиенте? Нет, запускаем Telegram Desktop 1.2.17 (версия, поставляемая в ряде дистрибутивов Linux), он пишет в лог Exception: MTP Unexpected type id #b5223b0f read in MTPMessageMedia.

На чем написан telegram desktop. Смотреть фото На чем написан telegram desktop. Смотреть картинку На чем написан telegram desktop. Картинка про На чем написан telegram desktop. Фото На чем написан telegram desktop

Гугль показал, что подобная проблема у кого-то из неофициальных клиентов уже случалась, но тогда номера версий и соответственно предположения были другие.

Так что же делать-то? Мы с Василием разделились: он попробовал обновить схему до 91, я решил подождать несколько дней и попробовать на 73. Оба способа сработали, но поскольку они эмпирические, нет никакого понимания, ни на сколько версий вверх или вниз надо прыгать, ни сколько времени надо ждать.

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

Объяснение? Как можно догадаться по различным косвенным симптомам, сервер состоит из многих процессов разных типов на различных машинах. Скорее всего, тот из серверов, что отвечает за «буферизацию», положил в очередь то, что ему отдавали вышестоящие, а они отдавали в той схеме, которая была на момент генерации. И пока эта очередь не «протухла», ничего с этим сделать было нельзя.

Разве что… но ведь это жуткий костыль. Нет, прежде чем думать о безумных идеях, давайте посмотрим в код официальных клиентов. В версии для Android мы не находим никакого TL-парсера, но находим здоровенный файл (гитхаб отказывается его подкрашивать) с (де)сериализацией. Вот фрагменты кода:

Кхм… выглядит дико. Но, наверное, это сгенерированный код, тогда ладно. Зато уж точно все версии поддерживает! Правда, непонятно, почему всё намешано в одну кучу, и секретные чаты, и всякие _old7 как-то не похожи на машинную генерацию… Впрочем, больше всего я офигел от

В исходниках Telegram Desktop, кстати, случается аналогичное — раз так, и несколько коммитов подряд в схему не меняют её номера слоя, а что-то фиксят. В условиях, когда официального источника данных по схеме нет, откуда её брать, кроме исходников официального клиента? А возьмешь оттуда, не можешь быть уверен, что схема целиком правильная, пока не протестируешь все методы.

А как такое вообще можно тестировать? Надеюсь, любители юнит-, функциональных и прочих тестов поделятся в комментариях.

Ладно, рассмотрим еще фрагмент кода:

Вот этот комментарий «manually created» наводит на мысль, что лишь часть этого файла написана вручную (представляете весь кошмар в части maintenance?), а остальное таки сгенерировано машиной. Однако, тогда возникает другой вопрос — о том, что исходники доступны не полностью (а-ля блобы под GPL в ядре Linux), однако это уже тема для второй части.

Но довольно. Перейдём к протоколу, поверх которого вся эта сериализация гоняется.

MTProto

Итак, открываем общее описание и детальное описание протокола и первым делом спотыкаемся о терминологию. И с обилием всего. Вообще, это похоже фирменная фишка Telegram — называть вещи в разных местах по-разному, либо разные вещи одним словом, либо наоборот (например, в высокоуровневом API если увидите sticker pack — это не то, что Вы подумали).

Например, «сообщение» (message) и «сессия» (session) — здесь значат другое, чем в привычном интерфейсе Telegram-клиента. Ну, с сообщением всё понятно, его можно было бы трактовать в терминах ООП, или же просто называть словом «пакет» — это низкий, транспортный уровень, здесь не те сообщения, что в интерфейсе, много служебных. А вот сессия… но обо всём по порядку.

Транспортный уровень

Первым делом — транспорт. Нам расскажут аж про 5 вариантов:

Vasily, [15.06.18 15:04]
А ещё есть UDP транспорт, но он не документирован

А TCP в трёх вариантах

Первый похож на UDP поверх TCP, каждый пакет включает в себя sequence number и crc
Почему читать доки на тележку так больно?

Ну хорошо, Padded intermediate для MTProxy, это позже добавили из-за известных событий. А вот зачем еще две версии (итого три), когда можно было бы обойтись одной? Все четыре по сути отличаются лишь тем, каким образом задать длину и payload собственно того основного MTProto, о котором речь пойдёт дальше:

Складывается впечатление, что Николай Дуров очень любит изобретать велосипеды, в том числе сетевые протоколы, без реальной практической надобности.

Остальные варианты транспорта, в т.ч. Web и MTProxy, мы сейчас рассматривать не будем, может быть, в другом посте, если будет запрос. Про этот самый MTProxy вспомним сейчас лишь, что вскоре после его выпуска в 2018, провайдеры быстренько научились блокировать именно его, предназначенного для обхода блокировок, по размеру пакета! А также тот факт, что написанный (опять же Вальтманом) сервер MTProxy на Си был излишне завязан на линуксовую специфику, хотя это совсем не требовалось (Фил Кулин подтвердит), и что аналогичный сервер то ли на Go, то ли на Node.js уместился менее чем в сотню строк.

Но делать выводы о технической грамотности этих людей делать будем в конце раздела, после рассмотрения других вопросов. Пока перейдём к 5-му уровню OSI, сессионному — на который они поместили MTProto session.

Ключи, сообщения, сессии, Diffie-Hellman

Поместили они его туда не совсем корректно… Сессия — это не та сессия, что видна в интерфейсе под Active sessions. Но по порядку.

На чем написан telegram desktop. Смотреть фото На чем написан telegram desktop. Смотреть картинку На чем написан telegram desktop. Картинка про На чем написан telegram desktop. Фото На чем написан telegram desktop

Вот мы получили с транспортного уровня строку байт известной длины. Это либо шифрованное сообщение, либо plaintext — если мы еще на стадии согласования ключа и собственно им и занимаемся. О каком из кучи понятий под названием «ключ» идет речь? Проясним этот вопрос за саму команду Telegram (приношу извинения за перевод с английского собственной документации к либе усталым мозгом в 4 утра, некоторые фразы было проще оставить как есть):

Есть две сущности под названием session — одна в UI официальных клиентов под «current sessions», где каждой сессии соответствует целое устройство / OS.
Вторая — MTProto session, у которой есть sequence number сообщения (в низкоуровневом смысле) в ней, и которая может длиться между разными TCP-соединениями. Одновременно могут быть установлены несколько MTProto-сессий, например для ускорения закачки файлов.

Заметим, что salt (и future salts) тоже одна на auth_key т.е. shared между всеми MTProto sessions к одному и тому же DC.

Что значит «между разными TCP-соединениями»? Значит, что это нечто вроде авторизации кукой на веб-сайте — она сохраняется (переживает) много TCP-соединений к данному серверу, но однажды протухнет. Только в отличие от HTTP, в MTProto внутри сессии сообщения последовательно нумеруются и подтверждаются, въехали в туннель, разорвалось соединение — после установления нового соединения сервер любезно отправит всё то в этой сессии, что не доставил в прошлом TCP-соединении.

Однако, информация выше приведена выжимкой после долгих месяцев разбирательств. А пока что — мы ведь реализуем свой клиент с нуля? — вернемся к началу.

Так что, генерируем auth_key по версии Диффи-Хеллмана от Telegram. Попытаемся понять документацию.

Vasily, [19.06.18 20:05]
data_with_hash := SHA1(data) + data + (any random bytes); such that the length equal 255 bytes;
encrypted_data := RSA (data_with_hash, server_public_key); a 255-byte long number (big endian) is raised to the requisite power over the requisite modulus, and the result is stored as a 256-byte number.

У них какой-то наркоманский DH

Не похоже на DH здорового человека
В дх нет двух публичных ключей

Vasily, [20.06.18 00:26]
Я ещё не дошёл до запроса appid

Это я запрос на DH отправил

А, в доке на транспорт написано, что может ответить 4 байтами кода ошибки. И всё

Вот я ему: «лови свою ефигню шифрованную ключом сервера с отпечатком таким-то, хочу DH», а оно в ответ тупо 404

Что бы Вы подумали на такой ответ сервера? Что делать? Спросить-то не у кого (но об этом во второй части).

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

Мне вот больше заняться нечем, только и мечтал числа туда-сюда конвертить

Два 32 битных числа. Я их и упаковал как все остальные

Но нет, именно эти два нужно сначала в строку как BE

Vadim Goncharov, [20.06.18 15:49]
и из-за этого 404?

Vasily, [20.06.18 15:49]
ДА!

Vadim Goncharov, [20.06.18 15:50]
вот я и не понимаю, что он может «не нашла»

Vasily, [20.06.18 15:50]
примерно

Не нашла такого разложения на простые делители %)

Даже error reporting не осилили

Vasily, [20.06.18 20:18]
О, там ещё и MD5. Уже три разных хэша

The key fingerprint is computed as follows:

Итак, положим, auth_key размером 2048 бит мы по Диффи-Хеллману получили. Что дальше? Дальше мы обнаруживаем, что младшие 1024 бита этого ключа никак не используются… но подумаем пока вот о чем. На данном шаге у нас есть с сервером общий секрет. Установлен аналог TLS-сессии, весьма затратной процедурой. Но сервер еще ничего не знает о том, кто мы такие! Еще нет, собственно, авторизации. Т.е. если Вы мыслили в понятиях «логин-пароль», как когда-то в ICQ, или хотя бы «логин-ключ», как в SSH (например на какой-нибудь gitlab/github). Мы получили анонимуса. А если сервер ответит нам «данные телефонные номера обслуживаются другим DC»? Или вообще «ваш телефонный номер забанен»? Лучшее, что мы можем сделать — это сохранить ключ в надежде, что еще пригодится и не протухнет к тому моменту.

Кстати, «получили» мы его с оговорками. Вот например, мы доверяем серверу? Вдруг он поддельный? Нужны бы криптографические проверки:

Vasily, [21.06.18 17:53]
Они предлагают мобильным клиентам проверять 2кбитное число на простоту %)

Но вообще непонятно, нафейхоа

Vasily, [21.06.18 18:02]
В доке не сказано, что делать, если оно не простое оказалось

Не сказано. Давайте посмотрим, что в этом случае делает официальный клиент под Андроид? А вот что (и да, там весь файл интересный) — как говорится, я просто оставлю это здесь:

Не, там конечно еще какие-то проверки простоты числа есть, но лично я достаточными познаниями в математике уже не обладаю.

Ладно, мы получили основной ключ. Чтобы авторизоваться, т.е. послать запросы, надо производить дальнейшее шифрование, уже с помощью AES.

The message key is defined as the 128 middle bits of the SHA256 of the message body (including session, message ID, etc.), including the padding bytes, prepended by 32 bytes taken from the authorization key.

Note that MTProto 2.0 requires from 12 to 1024 bytes of padding, still subject to the condition that the resulting message length be divisible by 16 bytes.

Так сколько паддинга сыпать?

И да, тут тоже 404 в случае ошибки

Я не криптограф, может быть, в этом режиме в данном случае и нет ничего плохого с теоретической точки зрения. Но я могу совершенно точно назвать практическую проблему, на примере Telegram Desktop. Он локальный кэш (все вот эти D877F783D5D3EF8C) шифрует тем же способом, что сообщения в MTProto (только в данном случае версии 1.0), т.е. сначала ключ сообщения, потом сами данные (и где-то в стороне основной большой auth_key на 256 байт, без которого msg_key бесполезен). Так вот, проблема становится заметна на больших файлах. А именно, Вам надо держать две копии данных — шифрованную и расшифрованную. А если там мегабайты, или потоковое видео, например. Классические схемы с MAC после шифротекста позволяют Вам считать его потоково, сразу передавая. А с MTProto придется сначала зашифровать или расшифровать сообщение целиком, только потом передавать в сеть или на диск. Поэтому в свежих версиях Telegram Desktop в кэше в user_data применяется уже и другой формат — с AES в режиме CTR.

Vasily, [21.06.18 01:27]
О, я узнал, что такое IGE: IGE was the first attempt at an «authenticating encryption mode,» originally for Kerberos. It was a failed attempt (it does not provide integrity protection), and had to be removed. That was the beginning of a 20 year quest for an authenticating encryption mode that works, which recently culminated in modes like OCB and GCM.

А теперь аргументы со стороны телеги:

The team behind Telegram, led by Nikolai Durov, consists of six ACM champions, half of them Ph.Ds in math. It took them about two years to roll out the current version of MTProto.

Чот смешно. Два года на нижний уровень

А могли бы просто взять tls

Ладно, допустиим, шифрование и прочие нюансы мы сделали. Можно, наконец, посылать сериализованные в TL запросы и десериализовывать ответы? Так а что и как слать надо? Вот, допустим, метод initConnection, наверное это оно?

Vasily, [25.06.18 18:46]
Initializes connection and save information on the user’s device and application.

Оно принимает app_id, device_model, system_version, app_version и lang_code.

Документация как всегда. Feel free to study the open source

Если с invokeWithLayer всё было примерно понятно, то здесь-то что? Оказывается, предположим у нас — клиент уже имел нечто, о чем спросить сервер — имеется запрос, который мы хотели послать:

Vasily, [25.06.18 19:13]
Судя по коду, первый вызов заворачивается в эту дрисню, а сама дрисня в invokewithlayer

Почему initConnection не мог быть отдельным вызовом, а обязательно должен быть оберткой? Да, как оказалось, его надо обязательно каждый раз в начале каждой сессии делать, а не разово, как с основным ключом. Но! Его не может вызвать неавторизованный пользователь! Вот мы добрались до этапа, в котором применима вот эта страница документации — и она сообщает нам, что.

Only a small portion of the API methods are available to unauthorized users:

Теперь вспомним то, что мы попали на этом этапе на сервере анонимусом. Не слишком ли затратно для того, чтобы просто получить IP-адрес? Почему было бы не делать это, и другие операции, в нешифрованной части MTProto? Слышу возражение: «а как удостовериться, что это не РКН фальшивыми адресами ответит?». На это мы вспомним, что вообще-то в официальные клиенты вшиты RSA-ключи, т.е. можно просто подписать эту информацию. Собственно, так уже и делается для информации по обходам блокировок, которую клиенты получают по другим каналам (логично, что это нельзя сделать в самом MTProto, еще ведь надо знать, куда соединиться).

Ну, ладно. На этом этапе авторизации клиента мы еще не авторизованы и не регистрировали своё приложение. Мы хотим просто пока посмотреть, что отвечает сервер на методы, доступные неавторизованному пользователю. И тут.

В схеме первое, приходит второе

В схеме tdesktop третье значение

Да, с тех пор, конечно, документацию обновили. Хотя скоро она снова может стать неактуальной. А откуда должен знать начинающий разработчик? Может быть, если зарегистрировать своё приложение, то сообщат? Василий сделал это, но увы — ничего ему не прислали (снова, поговорим об этом во второй части).

… Вы заметили, что мы уже как-то перешли к API, т.е. к следующему уровню, и что-то пропустили в теме MTProto? Ничего удивительного:

Vasily, [28.06.18 02:04]
Мм, они шарят часть алгоритмов на e2e

Mtproto определяет алгоритмы и ключи шифрования для обоих доменов, а также немного структуру обёртки

Но они постоянно смешивают разные уровни стека, так что не всегда понятно, где закончился mtproto и начался следующий уровень

Стоит отметить в теме MTProto еще некоторые вещи.

Сообщения о сообщениях, msg_id, msg_seqno, подтверждения, пинги не в ту сторону и другие идиосинкразии

Почему о них нужно знать? Потому что они «протекают» на уровень выше, и о них нужно знать, работая с API. Положим, msg_key нас не интересует, нижний уровень расшифровал всё для нас. Но внутри расшифрованных данных у нас такие поля (еще длина данных, чтоб знать, где padding, но это не важно):

Серверу разрешено вообще дропать сессии и отвечать таким образом по многим поводам. Собственно, что такое сессия MTProto со стороны клиента? Это два числа, session_id и seq_no сообщения внутри этой сессии. Ну, и нижележащее TCP-соединение, конечно. Допустим, наш клиент еще много чего не умеет, отсоединился, переподсоединился. Если это произошло быстро — в новом TCP-соединении продолжилась старая сессия, увеличиваем seq_no дальше. Если долго — сервер мог её удалить, потому что на его стороне это еще и очередь, как мы выяснили.

A message requiring an explicit acknowledgment. These include all the user and many service messages, virtually all with the exception of containers and acknowledgments.

Message Sequence Number (msg_seqno)

A 32-bit number equal to twice the number of “content-related” messages (those requiring acknowledgment, and in particular those that are not containers) created by the sender prior to this message and subsequently incremented by one if the current message is a content-related message. A container is always generated after its entire contents; therefore, its sequence number is greater than or equal to the sequence numbers of the messages contained in it.

Итак, msg_id нужен для.

RPC: запросы, ответы, ошибки. Подтверждения.

и его должна делать каждая сторона. Но не всегда! Если Вы получили RpcResult, он сам служит подтверждением. То есть, на Ваш запрос сервер может ответить MsgsAck — типа, «я получил». Может сразу ответить RpcResult. Может быть и то и другое.

И да, Вы таки должны ответить на ответ! Подтверждением. Иначе сервер будет считать его недоставленным и вывалит Вам его опять. Даже после переподсоединения. Но тут, конечно, вопрос таймаутов возникнет. Рассмотрим их чуть позже.

А пока рассмотрим возможные ошибки выполнения запросов.

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

На него возможны три варианта ответа, опять пересекающихся с механизмом подтверждений, попытаться понять, какими они должны быть (и каков вообще список типов, не требущих подтверждений), читателю оставляется в качестве домашнего задания (замечание: в исходниках Telegram Desktop информация не полна).

Наркомания: статусы сообщений о сообщениях

Вообще, ощущение упоротости оставляют многие места в TL, MTProto и Telegram в целом, но из вежливости, тактичности и прочих soft skills мы об этом вежливо промолчали, а маты в диалогах отцензурировали. Однако это место, бОльшая часть страницы про сообщения про сообщения вызывает оторопь даже у меня, давно работающего с сетевыми протоколами и видывавшего велосипеды разной степени кривости.

Начинается она безобидно, с подтверждений. Дальше нам рассказывают о

Ну, с ними придется столкнуться каждому начинающему работать с MTProto, в цикле «поправил — перекомпилировал — запустил» получить ошибки номеров или успевшую протухнуть за время правок соль — обычное дело. Однако тут два момента:

The intention is that error_code values are grouped (error_code >> 4): for example, the codes 0x40 — 0x4f correspond to errors in container decomposition.

но, во-первых, сдвиг в другую сторону, во-вторых, всё равно, где остальные коды? В голове автора. Впрочем, это мелочи.

Наркомания начинается в сообщениях о статусах сообщений и копиях сообщений:

Даже помолчим о том, что в msgs_state_info опять торчат уши недоделанного TL (нужен был вектор байт, и в младших двух битах enum, а в старших флаги). Суть в другом. Кто-нибудь понимает, зачем всё это на практике в реальном клиенте нужно. С трудом, но можно представить себе какую-то пользу, если человек занимается отладкой, причем в интерактивном режиме — спросить у сервера, что да как. Но здесь описываются запросы в обе стороны.

Отсюда вытекает, что каждая сторона должна не просто шифровать и отправлять сообщения, но и хранить данные о них самих, об ответах на них, причем неизвестное количество времени. Документация ни тайминги, ни практическую применимость этих фич не описывает никак. Что самое удивительное, они действительно используются в коде официальных клиентов! Видимо, им сообщили что-то, что не вошло в открытую документацию. Понять же из кода, зачем, уже не так просто, как в случае TL — это не (сравнительно) логически изолированная часть, а кусок, завязанный на архитектуру приложения, т.е. потребует значительно больше времени на вникание в код приложения.

Пинги и тайминги. Очереди.

Из всего, если вспомнить догадки об архитектуре сервера (распределение запросов по бэкендам), вытекает довольно унылая вещь — несмотря на все гарантии доставки что в TCP (либо данные доставлены, либо Вам сообщат о разрыве, но данные до момента проблемы будут доставлены), что подтверждения в самом MTProto — гарантий нет. Сервер может запросто прое потерять или выкинуть Ваше сообщение, и ничего с этим сделать нельзя, только городить костыли разных видов.

И прежде всего — очереди сообщений. Ну, с одной-то всё было очевидно с самого начала — неподтвержденное сообщение надо хранить и перепосылать. А через какое время? А шут его знает. Возможно, вон те наркоманские сервисные сообщение как-то костылями решают эту проблему, скажем, в Telegram Desktop примерно штуки 4 очереди, им соответствующих (может больше, как уже говорилось, для этого надо вникать в его код и архитектуру более серьезно; при этом мы знаем, что за образец его брать нельзя, энное количество типов из схемы MTProto в нём не используется).

Почему так происходит? Вероятно, программисты сервера не смогли обеспечить надежность внутри кластера, или хотя бы даже буферизацию на фронте-балансировщике, и переложили эту проблему на клиента. От безысходности Василий попытался реализовать альтернативный вариант, с всего двумя очередями, используя алгоритмы из TCP — замеряя RTT до сервера и корректируя размер «окна» (в сообщениях) в зависимости от числа неподтвержденных запросов. То есть, грубая такая эвристика для оценки загруженности сервера — сколько одновременно наших запросов он может жевать и не терять.

Ну то есть, Вы понимаете, да? Если поверх работающего по TCP протокола приходится реализовывать опять TCP — это говорит об очень плохо спроектированном протоколе.

И в этом месте вступают в действие уже не технические соображения. По опыту, мы видели много костылей, а кроме того, сейчас увидим еще примеры плохих советов и архитектуры — в таких условиях, стоит ли доверять и принимать такие решения? Вопрос риторический (конечно, нет).

О чем речь? Если по теме «наркоманские сообщения о сообщениях» еще можно спекулировать возражениями вида «это вы тупые, не поняли наш гениальный замысел!» (так напишите сначала документацию, как полагается у нормальных людей, с rationale и примерами обмена пакетов, тогда и поговорим), то тайминги/таймауты — вопрос сугубо практический и конкретный, тут всё давно известно. А что же нам говорит документация о таймаутах?

A server usually acknowledges the receipt of a message from a client (normally, an RPC query) using an RPC response. If a response is a long time coming, a server may first send a receipt acknowledgment, and somewhat later, the RPC response itself.

A client normally acknowledges the receipt of a message from a server (usually, an RPC response) by adding an acknowledgment to the next RPC query if it is not transmitted too late (if it is generated, say, 60-120 seconds following the receipt of a message from the server). However, if for a long period of time there is no reason to send messages to the server or if there is a large number of unacknowledged messages from the server (say, over 16), the client transmits a stand-alone acknowledgment.

… Перевожу: мы сами не знаем, сколько и как надо, ну давайте прикинем, что пусть будет вот так.

Ping Messages (PING/PONG)

A response is usually returned to the same connection:

These messages do not require acknowledgments. A pong is transmitted only in response to a ping while a ping can be initiated by either side.

Deferred Connection Closure + PING

Works like ping. In addition, after this is received, the server starts a timer which will close the current connection disconnect_delay seconds later unless it receives a new message of the same type which automatically resets all previous timers. If the client sends these pings once every 60 seconds, for example, it may set disconnect_delay equal to 75 seconds.

Да вы с ума сошли?! За 60 секунд поезд въедет на станцию, высадит-возьмет пассажиров, и снова потеряет связь в туннеле. За 120 секунд, пока прочухаетесь, он приедет на другую, и соединение скорее всего порвётся. Ну, понятно откуда ноги растут — «слышал звон, да не знает где он», есть алгоритм Нагла и опция TCP_NODELAY, предназначавшаяся для интерактивной работы. Но, простите, её дефолтное значение задержи — 200 миллисекунд. Если вам так уж хочется изобразить нечто похожее и сэкономить на возможной паре пакетов — ну отложите, накрайняк, на 5 секунд, или чему там сейчас равен таймаут сообщения «User is typing. «. Но не больше.

И наконец, пинги. То бишь, проверка живости TCP-соединения. Забавно, но примерно 10 лет назад я писал критический текст о мессенджере общаги нашего факультета — там авторы тоже пинговали сервер с клиента, а не наоборот. Но одно дело студенты 3 курса, а другое — международная контора, да.

Сначала небольшой ликбез. TCP-соединение, при отсутствии обмена пакетами, может жить неделями. Это и хорошо, и плохо, в зависимости от цели. Хорошо, если у Вас было открыто SSH-соединение на сервер, Вы встали из-за компа, перезагрузили роутер по питанию, вернулись на место — сессия через этот сервер не порвалась (ничего не набирали, пакетов не было), удобно. Плохо, если на сервере тысячи клиентов, каждый занимает ресурсы (привет, Постгрес!), и хост клиента, возможно, давно уже перезагрузился — но мы об этом не узнаем.

Системы чатов / IM относятся ко второму случаю по еще одной, дополнительной причине — онлайн-статусы. Если пользователь «отвалился», надо сообщить об этом его собеседникам. Иначе получится ошибка, которую допустили создатели Jabber (и 20 лет исправляли) — пользователь отсоединился, но ему продолжают писать сообщения, считая, что он online (которые еще и полностью терялись в эти несколько минут до обнаружения разрыва). Нет, опция TCP_KEEPALIVE, которую многие не понимающие, как работают таймеры TCP, суют куда попало (ставя дикие значения типа десятков секунд), здесь не поможет — Вам нужно убедиться, что живо не только ядро ОС машины пользователя, но и нормально функционирует, в состоянии ответить, и само приложение (думаете, оно не может зависнуть? Telegram Desktop на Ubuntu 18.04 у меня зависал неоднократно).

Именно поэтому пинговать должен сервер клиента, а не наоборот — если это делает клиент, при разрыве соединения пинг не будет доставлен, цель не достигнута.

А как надо было проектировать?

Полагаю, вышеприведенные факты достаточно явственно свидетельствуют о не очень высокой компетенции команды Telegram/ВКонтакте в области транспортного (и ниже) уровня компьютерных сетей и их низкой квалификации в соответствующих вопросах.

Почему же оно такое сложное вышло, и чем архитекторы Telegram могут попытаться возразить? Тем, что они пытались сделать сессию, которая переживает разрывы TCP-соединений, т, е. что не доставили сейчас — доставим позже. Вероятно, еще попытались сделать UDP-транспорт, правда столкнулись со сложностями и забросили (потому и в документации пусто — нечем похвастаться было). Но из-за непонимания того, как работают сети вообще и TCP в частности, где можно на него положиться, а где нужно делать самому (и как), и попытки совместить это с криптографией «одним выстрелом двух зайцев» — получился вот такой кадавр.

А как надо было? Исходя из того, что msg_id является меткой времени, необходимой с криптографической точки зрения для предотвращения replay-атак, ошибкой является навешивание на него функции уникального идентификатора. Поэтому, без кардинального изменения текущей архитектуры (когда формируется поток Updates, это тема высокоуровневого API для другой части этой серии постов), нужно было бы:

Тоже не самый удачный вариант, идентификатором мог бы служить и полный рандом — так уже делается в высокоуровневом API при отправке соообщения, кстати. Лучше было бы вообще переделать архитектуру с относительной на абсолютную, но это тему уже для другой части, не этого поста.

Та-даам! Итак, продравшись через путь, полный боли и костылей, мы наконец смогли отправлять на сервер любые запросы и получать на них любые ответы, а также получать от сервера апдейты (не в ответ на запрос, а он сам нам присылает, типа PUSH, если кому-то так понятней).

Внимание, сейчас будет единственный в статье пример на Perl! (для тех, кто не знаком с синтаксисом, первый аргумент bless — структура данных объекта, второй — его класс):

Да, специально не под спойлером — если Вы не вчитались, идите и сделайте это!

… на что же это похоже? Что-то очень знакомое… может, это структура данных типичного Web API в JSON, только разве что еще к объектам классы прицепили.

Так это же получается… Что же это выходит, товарищи. Столько усилий — и мы остановились передохнуть там, где Web-программисты только начинают. А просто JSON поверх HTTPS был бы не проще?! А что же мы получили в обмен? Стоили ли эти усилия того?

Компактная сериализация. Видя вот эту структуру данных, похожую на JSON, вспоминается, что есть его бинарные варианты. Отметем MsgPack как недостаточно расширяемый, но вот есть, например, CBOR — между прочим, стандарт, описанный в RFC 7049. Примечателен он тем, что в нём определены теги, как механизм расширения, и среди уже стандартизированных имеются:

Ну что ж, я попробовал одни и те же данные сериализовать в TL и в CBOR со включенной упаковкой строк и объектов. Результат стал различаться в пользу CBOR где-то от мегабайта:

Итак, вывод: есть существенно более простые форматы, не подверженные проблеме сбоя синхронизации или неизвестного идентификатора, с сопоставимой эффективностью.

Быстрое установление соединения. Имеется в виду нулевой RTT после переподключения (когда ключ был уже однажды выработан) — применимо с первого же сообщения MTProto, но при некоторых оговорках — попали в ту же соль, сессия не протухла, etc. Что нам взамен предлагает TLS? Цитата по теме:

При использовании PFS в TLS могут применяться TLS session tickets (RFC 5077) для возобновления зашифрованной сессии без повторного согласования ключей и без сохранения ключевой информации на сервере. При открытии первого соединения и создания ключей, сервер шифрует состояние соединения и передает его клиенту (в виде session ticket). Соответственно, при возобновлении соединения клиент посылает session ticket, содержащий в том числе сессионный ключ, обратно серверу. Сам ticket шифруется временным ключом (session ticket key), который хранится на сервере и должен распределяться по всем frontend-серверам, обрабатывающим SSL в кластеризованных решениях.[10]. Таким образом, введение session ticket может нарушать PFS в случае компрометации временных серверных ключей, например, при их длительном хранении (OpenSSL, nginx, Apache по умолчанию хранят их в течение всего времени работы программы; популярные сайты используют ключ в течение нескольких часов, вплоть до суток).

Здесь RTT не нулевой, нужно обменяться как минимум ClientHello и ServerHello, после чего вместе с Finished клиент уже может слать данные. Но тут следует вспомнить, что у нас не Web, с его кучей вновь открываемых соединений, а мессенджер, соединение у которого часто одно и более-менее долгоживущее, относительно коротких запросов на Web-страницы — всё мультиплексируется внутри. То есть, вполне приемлемо, если нам не попался совсем уж плохой перегон метро.

Что-то еще забыл? Пишите в комментах.

To be continued!

Во второй части этой серии постов мы рассмотрим более не технические, а организационные моменты — подходы, идеология, интерфейс, отношение к пользователям и т.д. Опираясь, впрочем, на ту техническую информацию, что была изложена здесь.

В третьей части будет продолжение разбора технической составляющей / опыта разработки. Вы узнаете, в частности:

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *