Критика протоколу та оргпідходів 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, Ви подумали правильно. Мета та ж: деяка мова для опису можливого набору даних, що передаються. На цьому, власне, схожість закінчується. Якщо зі сторінки протоколу MTProto, або з дерева вихідних текстів офіційного клієнта, ми спробуємо відкрити якусь схему, то побачимо щось на кшталт:

int ? = Int;
long ? = Long;
double ? = Double;
string ? = String;

vector#1cb5c415 {t:Type} # [ t ] = Vector t;

rpc_error#2144ca19 error_code:int error_message:string = RpcError;

rpc_answer_unknown#5e2ad36e = RpcDropAnswer;
rpc_answer_dropped_running#cd78e586 = RpcDropAnswer;
rpc_answer_dropped#a43ad8b7 msg_id:long seq_no:int bytes:int = RpcDropAnswer;

msg_container#73f1f8dc messages:vector<%Message> = MessageContainer;

---functions---

set_client_DH_params#f5045f1f nonce:int128 server_nonce:int128 encrypted_data:bytes = Set_client_DH_params_answer;

ping#7abe77ec ping_id:long = Pong;
ping_delay_disconnect#f3427b8c ping_id:long disconnect_delay:int = Pong;

invokeAfterMsg#cb9f372d msg_id:long query:!X = X;
invokeAfterMsgs#3dc4b4f0 msg_ids:Vector<long> query:!X = X;

account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User;
account.sendChangePhoneCode#8e57deb flags:# allow_flashcall:flags.0?true phone_number:string current_number:flags.0?Bool = auth.SentCode;

Людина, яка бачить це вперше, інтуїтивно зможе розпізнати тільки частину написаного — ну, це мабуть структури (хоча де ім'я, ліворуч чи праворуч?), ось є поля в них, після яких через двокрапку йде тип… напевно. Ось у кутових дужках напевно шаблони як у Сі ++ (насправді, не зовсім). А що означають всі інші символи, знаки питання, оклику, відсотки, грати (причому ж у різних місцях значать різне), десь присутні, а десь ні, шістнадцяткові циферки — і найголовніше, як із цього отримати правильний (який не буде відкинуто сервером) потік байт? Прийде читати документацію (так, там поруч бувають посилання на схему в JSON-версії - але зрозуміліше від цього не стає).

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

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

  • да, мета звучить добре, але на жаль, вона не досягається
  • освіта у ВНЗ Росії варіює навіть серед IT-шних спеціальностей – відповідний курс читали не всім
  • нарешті, як ми побачимо, на практиці це не вимагаєтьсяоскільки використовується лише обмежена підмножина навіть того TL, що був описаний

Як сказав LeoNerd на каналі #perl в IRC-мережі FreeNode, який намагався реалізувати гейт із Telegram до Matrix (переклад цитати неточний по пам'яті):

Таке почуття, що хтось вперше познайомився з теорією типів, захопився і почав намагатися грати з цим, не особливо переймаючись, чи потрібно це на практиці.

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

Але спершу

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

constructor = Type;
myVec ids:Vector<long> = Type;

fixed#abcdef34 id:int = Type2;

fixedVec set:Vector<Type2> = FixedVec;

constructorOne#crc32 field1:int = PolymorType;
constructorTwo#2crc32 field_a:long field_b:Type3 field_c:int = PolymorType;
constructorThree#deadcrc bit_flags_of_what_really_present:# optional_field4:bit_flags_of_what_really_present.1?Type = PolymorType;

an_id#12abcd34 id:int = Type3;
a_null#6789cdef = Type3;

Починає визначення завжди конструктор, після якого опціонально (на практиці завжди) через символ # випливає CRC32 від нормалізованого рядка опису цього типу. Далі йде опис полів, якщо вони є тип може бути і порожнім. Закінчує це через знак рівності ім'я типу, якому даний конструктор — тобто, фактично, підтип — належить. Той тип, що праворуч від знака рівності, він поліморфний — тобто може відповідати кілька конкретних типів.

Якщо ж визначення зустрілося після рядка ---functions---, то синтаксис залишиться таким же, але сенс буде вже інший: конструктор стане ім'ям RPC-функції, поля - параметрами (ну тобто він залишиться точно такою ж структурою даної, як описано нижче, просто такий буде сенс, що наділяється), а «поліморфний тип » - Типом результату, що повертається. Він, правда, все одно залишиться поліморфним — просто визначеним у секції ---types---, А цей конструктор «не рахуватися». Перевантаження типів викликуваних функцій з їхньої аргументів, тобто. кілька функцій з тим самим ім'ям, але різної сигнатурою, як і C++, в TL чомусь не передбачено.

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

Як це відбувається? Десеріалізатор, який завжди читає по 4 байти, бачить значення 0xcrc32 — і розуміє, що далі буде field1 з типом int, тобто. читає рівно 4 байти, на цьому вищележаче поле з типом PolymorType прочитано. Бачить 0x2crc32 і розуміє, що далі два поля, спочатку long, Отже читаємо 8 байт. А далі знову складний тип, який аналогічно десеріалізується. Наприклад, Type3 міг бути оголошений у схемі як тільки два конструктори, відповідно, далі повинні зустрітися або 0x12abcd34, після якого треба прочитати ще 4 байти int, або 0x6789cdef, після якого нічого не буде. Що-небудь інше — треба викинути виняток. У будь-якому випадку після цього ми повертаємось до читання 4 байт int поля field_c в constructorTwo і на тому закінчуємо читати наш PolymorType.

Зрештою, якщо попався 0xdeadcrc для constructorThree, Все стає складніше. Першим у нас полі bit_flags_of_what_really_present з типом # — насправді, це лише аліас для типу nat, Що означає "натуральне число". Тобто, по суті, unsigned int — єдиний випадок, коли в реальних схемах зустрічаються беззнакові числа. Отже, далі конструкція зі знаком питання, що означає, що це поле — воно буде присутнім on the wire, лише якщо встановлено відповідний біт у полі, на яке послалися (приблизно як тернарний оператор). Отже, припустимо, що цей біт стояв, значить далі треба читати поле типу Type, У якого в нашому прикладі 2 конструктори. Один порожній (складається лише з ідентифікатора), в іншому є поле ids з типом ids:Vector<long>.

Ви можете подумати, що як шаблони і generic'і ​​у плюсах або Java. Але немає. Ну майже. Це єдиний випадок застосування кутових дужок у реальних схемах, і він використовується ТІЛЬКИ для Vector. У потоці байт це будуть 4 байти CRC32 для самого типу Vector, завжди однакові, потім 4 байти — кількість елементів масиву, і далі самі ці елементи.

Додайте до цього те, що серіалізація завжди відбувається словами по 4 байти, всі типи їй кратні - до вбудованих типів описані ще bytes и string з ручною серіалізацією довжини і цього вирівнювання по 4 - ну, начебто звучить нормально і навіть порівняно ефективно? Хоча TL заявляється як ефективна бінарна серіалізація, але хрін вже з ними, з розширенням чого завгодно, навіть булевих значень та односимвольних рядків до 4 байт, все одно JSON буде куди товщі? Он, навіть непотрібні поля можуть бути пропущені бітовими прапорами, все зовсім добре, і навіть розширюється на майбутнє, взяв та й досипав нових опціональних полів у конструктор потім?

А ось ні, якщо читати не мій короткий опис, а повну документацію і подумати над реалізацією. По-перше, CRC32 конструктора вважається за нормалізованим рядком текстового опису схеми (прибрати зайві whitespace і т.д.) — тому якщо додається нове поле, зміниться рядок опису типу, отже, і її CRC32 і, отже, серіалізація. Та й що старий клієнт робив би, якби йому прийшло поле з новими встановленими прапорами, а він не знає, що з ними робити далі?

По-друге, згадаємо про CRC32, яка застосовується тут по суті як хеш-функції для унікального визначення, що за тип (де)серіалізується. Тут ми стикаємося з проблемою колізій — і ні, ймовірність не одиниця на 232, а значно більша. Хто згадав про те, що CRC32 заточена на виявлення (і виправлення) помилок у каналі зв'язку, і відповідно покращує ці властивості на шкоду іншим? Наприклад, їй начхати на перестановку байт: якщо Ви порахуєте CRC32 від двох рядків, у другій перші 4 байти поміняєте місцями з наступними 4 байтами - вона буде однакова. Коли у нас на вході текстові рядки з латинського алфавіту (і трохи пунктуації), і ці імена не є особливо випадковими, ймовірність такої перестановки дуже підвищується.

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

Зрештою, гаразд, ми зрозуміли, що конструктори з типом поля Vector<int> и Vector<PolymorType> матимуть різний CRC32. А що щодо вистави на лінії? І з погляду теорії, чи стає це частиною типу? Припустимо, ми передаємо масив із десяти тисяч чисел, ну з Vector<int> все зрозуміло, довжина та ще 40000 байт. А якщо це Vector<Type2>, Що складається тільки з одного поля int і він один у типі - чи треба нам 10000 разів повторювати 0xabcdef34 і потім 4 байти int, або ж мова може Вивести це за нас з конструктора fixedVec і замість 80000 байт передати знову лише 40000?

Це зовсім не пусте теоретичне питання — уявіть, Ви отримуєте список користувачів групи, кожен з яких має id, ім'я, прізвище — різниця в обсязі даних, що передаються по мобільному з'єднанню, може бути значною. Саме ефективність серіалізації Telegram нам і рекламують.

Отже ...

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

Якщо Ви спробуєте продертися через сторінки опису комбінаторів і навколо, Ви побачите, що вектор (і навіть матрицю) формально намагаються вивести через кілька листів. Але зрештою забивають, кінцевий крок пропускається, і просто дається визначення вектора, який ще й не прив'язаний до типу. У чому тут річ? У мовах програмування, особливо функціональних, цілком типово описати структуру рекурсивно - компілятор з його 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.

Значення опційного параметра не є внесеним у послідовність повідомлень, що є віднесеним від результату типу (якщо мова йде про приоритетне deserialization).

Придивіться: vector {t:Type} # [ t ] = Vector t - але ніде у самому цьому визначенні не сказано, що перше число має дорівнювати довжині вектора! І нізвідки це не випливає. Це даність, яку треба пам'ятати і реалізовувати руками. В інших місцях документація навіть чесно згадує, що тип несправжній.

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

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

До речі, про кількість. Нагадаємо, # це синонім nat, натурального числа:

There are type expressions (type-expr) and numeric expressions (nat-expr). Хоча, вони вважаються тим самим способом.

type-expr ::= expr
nat-expr ::= expr

Однак у граматиці вони описані однаково, тобто. цю різницю знову треба пам'ятати та закладати у реалізацію руками.

Ну і так, шаблонні типи (vector<int>, vector<User>) мають загальний ідентифікатор (#1cb5c415), тобто. якщо знаєш, що виклик оголошено як

users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;

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

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

Існуючі реалізації TL у коді

TL народився в надрах ВКонтакте ще до відомих подій з продажем частки Дурова та (мабуть), ще до початку розробки Telegram. І у викладених в опенсорс вихідниках першої реалізації можна знайти багато веселих милиць. Та й сама мова там була реалізована повніше, ніж зараз у Telegram. Наприклад, хеші у схемі не використовуються зовсім (мається на увазі вбудований псевдотип (як вектор) з девіантною поведінкою). Або

Templates are not used now. Instead, the same universal constructors (for example, vector {t:Type} [t] = Vector t) are used w

але розглянемо повноти картини, щоб простежити, так би мовити, еволюцію Гіганта Думки.

#define ZHUKOV_BYTES_HACK

#ifdef ZHUKOV_BYTES_HACK

/* dirty hack for Zhukov request */

Або ось, прекрасне:

    static const char *reserved_words_polymorhic[] = {

      "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", NULL

      };

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

intHash {alpha:Type} vector<coupleInt<alpha>> = IntHash<alpha>;

Це визначення шаблонного типу хеш-меп, як вектор пар int — Type. У C++ це виглядало б приблизно так:

    template <T> class IntHash {
      vector<pair<int,T>> _map;
    }

так ось, alpha - ключове слово! Але тільки в C++ ти можеш писати T, а маєш писати alpha, beta… Але не більше 8 параметрів, на тіті фантазія скінчилася. Так і видається, що колись у Пітері трапилися приблизно такі діалоги:

-- Надо сделать в TL шаблоны
-- Бл... Ну пусть параметры зовут альфа, бета,... Какие там ещё буквы есть... О, тэта!
-- Грамматика? Ну потом напишем

-- Смотрите, какой я синтаксис придумал для шаблонов и вектора!
-- Ты долбанулся, как мы это парсить будем?
-- Да не ссыте, он там один в схеме, захаркодить -- и ок

Але це було про першу викладену реалізацію TL «загалом». Перейдемо до розгляду реалізацій у власне Telegram-клієнтах.

Слово Василеві:

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

Звичайно, від знайомих з алгоритмами і математикою людей ми можемо очікувати, що вони читали Ахо, Ульмана, і знайомі зі стандартами, що стали за десятиліття, де-факто в галузі інструментами для написання компіляторів своїх DSL, правда?

автором 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к+ рядків ін<censored> замість парсера

Може виняток? Давайте подивимося, як робить це ОФІЦІЙНИЙ клієнт — Telegram Desktop:

    nametype = re.match(r'([a-zA-Z.0-9_]+)(#[0-9a-f]+)?([^=]*)=s*([a-zA-Z.<>0-9_]+);', line);
    if (not nametype):
      if (not re.match(r'vector#1cb5c415 {t:Type} # [ t ] = Vector t;', line)):
         print('Bad line found: ' + line);

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
означає, що поле є, і його треба десеріалізувати

Vasily: Дупа, не гори, що ти робиш!
Vasily: Там десь у доці є згадка, що true – це голий тип нульової довжини, але з їхньої доки щось зібрати нереально
Vasily: У сорцях відкритих реалізацій цього теж немає, зате є купа милиць і підпірок

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

Vasily, [22.06.18 18:38] В одному місці:

msg_container#73f1f8dc messages:vector message = MessageContainer;

В іншому:

msg_container#73f1f8dc messages:vector<%Message> = MessageContainer;

І це дві великі різниці, в реалі приходить якийсь голий вектор

Я не бачив bare визначення вектора та не зустрічав його

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

У його схемі закоментовано визначення msg_container

Знову ж таки, залишається питання про %. Воно не описано.

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

Vasily, [22.06.18 19:23] Але їхній парсер TL на регуляїках це теж швидше за все не з'їсть

// parsed manually

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

А % у їхній версії схеми немає

Але тут документація суперечить сама собі, тож хз

Воно зустрічалося у граматиці, вони могли просто забути описати семантику

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

"Ну припустимо", скаже інший читач, "щось ви все критикуєте, так покажіть, як треба".

Василь відповідає: «а щодо парсера, мені штуки виду

    args: /* empty */ { $$ = NULL; }
        | args arg { $$ = g_list_append( $1, $2 ); }
        ;

    arg: LC_ID ':' type-term { $$ = tl_arg_new( $1, $3 ); }
            | LC_ID ':' condition '?' type-term { $$ = tl_arg_new_cond( $1, $5, $3 ); free($3); }
            | UC_ID ':' type-term { $$ = tl_arg_new( $1, $3 ); }
            | type-term { $$ = tl_arg_new( "", $1 ); }
            | '[' LC_ID ']' { $$ = tl_arg_new_mult( "", tl_type_new( $2, TYPE_MOD_NONE ) ); }
            ;

якось більше подобаються, ніж

struct tree *parse_args4 (void) {
  PARSE_INIT (type_args4);
  struct parse so = save_parse ();
  PARSE_TRY (parse_optional_arg_def);
  if (S) {
    tree_add_child (T, S);
  } else {
    load_parse (so);
  }
  if (LEX_CHAR ('!')) {
    PARSE_ADD (type_exclam);
    EXPECT ("!");
  }
  PARSE_TRY_PES (parse_type_term);
  PARSE_OK;
}

або

        # Regex to match the whole line
        match = re.match(r'''
            ^                  # We want to match from the beginning to the end
            ([w.]+)           # The .tl object can contain alpha_name or namespace.alpha_name
            (?:
                #             # After the name, comes the ID of the object
                ([0-9a-f]+)    # The constructor ID is in hexadecimal form
            )?                 # If no constructor ID was given, CRC32 the 'tl' to determine it

            (?:s              # After that, we want to match its arguments (name:type)
                {?             # For handling the start of the '{X:Type}' case
                w+            # The argument name will always be an alpha-only name
                :              # Then comes the separator between name:type
                [wd<>#.?!]+  # The type is slightly more complex, since it's alphanumeric and it can
                               # also have Vector<type>, flags:# and flags.0?default, plus :!X as type
                }?             # For handling the end of the '{X:Type}' case
            )*                 # Match 0 or more arguments
            s                 # Leave a space between the arguments and the equal
            =
            s                 # Leave another space between the equal and the result
            ([wd<>#.?]+)     # The result can again be as complex as any argument type
            ;$                 # Finally, the line should always end with ;
            ''', tl, re.IGNORECASE | re.VERBOSE)

це ВЕСЬ лексер:

    ---functions---         return FUNCTIONS;
    ---types---             return TYPES;
    [a-z][a-zA-Z0-9_]*      yylval.string = strdup(yytext); return LC_ID;
    [A-Z][a-zA-Z0-9_]*      yylval.string = strdup(yytext); return UC_ID;
    [0-9]+                  yylval.number = atoi(yytext); return NUM;
    #[0-9a-fA-F]{1,8}       yylval.number = strtol(yytext+1, NULL, 16); return ID_HASH;

    n                      /* skip new line */
    [ t]+                  /* skip spaces */
    //.*$                 /* skip comments */
    /*.**/              /* skip comments */
    .                       return (int)yytext[0];

тобто. простіше – це м'яко сказано».

Загалом, в результаті парсер і кодогенератор для реально використовуваного підмножини TL уклався приблизно в 100 рядків граматики і ~300 рядків генератора (вважаючи і все printти генерованого коду), включаючи плюшки типу інформацію про типи для інтроспекції в кожному класі. Кожен поліморфний тип перетворюється на порожній абстрактний базовий клас, а конструктори успадковуються від нього і мають методи для серіалізації та десеріалізації.

Нестача типів у мові типів

Сувора типізація — адже це добре, правда? Ні, це не холівар (хоча я віддаю перевагу динамічним мовам), а постулат в рамках TL. Виходячи з нього, мова має забезпечувати всілякі перевірки за нас. Ну окей, хай не він сам, а реалізація, але він повинен їх хоч би описувати. І які можливості ми хочемо?

Насамперед, constraints. Ось ми бачимо в документації із закачування файлів:

File's binary content is the split into parts. All parts must have the same size ( розмір_частини ) and the following conditions must be met:

  • part_size % 1024 = 0 (divisible by 1KB)
  • 524288 % part_size = 0 (512KB must be evenly divisible by part_size)

Остання частина не має, щоб відповідати цим умовам, передбачений його розміром не є тим, що part_size.

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

Після того, як файл має бути partitioned, вам потрібний спосіб вибору методу для захисту його на сервері. Use upload.saveBigFilePart in case the full size of the file is more than 10 MB and upload.saveFilePart for smaller files.
[…] Один з наступних даних введення слів може бути відновлений:

  • FILE_PARTS_INVALID — Invalid number of parts. The value is not between 1..3000

Щось із цього є у схемі? Це якось виразно засобами 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<BotInfo> 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<BotInfo> 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<MessageEntity> 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<MessageEntity> views:flags.10?int edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long = Message;

У кого як, але GitHub, наприклад, зміни усередині таких довгих рядків підсвічувати відмовляється. Гра «знайди 10 відмінностей», причому що мозок одразу бачить, що початки і кінці в обох прикладах однакові, потрібно нудно вчитуватися десь у середині… На мій погляд, ось це ось не те що в теорії, а суто візуально виглядає брудно і неохайно.

До речі, про чистоту теорії. А навіщо потрібні бітові поля? Чи не здається, що вони пахнуть погано з погляду теорії типів? Пояснення можна побачити у ранніх версіях схеми. Спочатку так, так і було, на кожен чих створювався новий тип. Ці рудименти і зараз є ось у такому вигляді, наприклад:

storage.fileUnknown#aa963b05 = storage.FileType;
storage.filePartial#40bc6f52 = storage.FileType;
storage.fileJpeg#7efe0e = storage.FileType;
storage.fileGif#cae1aadf = storage.FileType;
storage.filePng#a4f63c0 = storage.FileType;
storage.filePdf#ae1e508d = storage.FileType;
storage.fileMp3#528a0677 = storage.FileType;
storage.fileMov#4b09ebbc = storage.FileType;
storage.fileMp4#b3cea0e4 = storage.FileType;
storage.fileWebp#1081464c = storage.FileType;

Але тепер уявіть, якщо у Вас у структурі 5 опціональних полів, то Вам знадобиться 32 типи для всіх можливих варіантів. Комбінаторний вибух. Так кришталева чистота теорії TL вкотре розбилася про чавунну дупу суворої реальності серіалізації.

Крім того, місцями ці хлопці самі порушують свою типізацію. Наприклад, у MTProto (наступний розділ) відповідь може бути потиснутий Gzip, все здорово - крім того, що порушення шарів і схеми. Раз і потиснули не сам RpcResult, а його вміст. Ну ось навіщо так робити?.. Довелося впилювати милицю, щоб стиснення працювало де завгодно.

Або ще приклад, ми одного разу виявили помилку — посилався InputPeerUser замість InputUser. Або навпаки. Але ж це працювало! Тобто серверу було пофіг на тип. Як таке може бути? Відповідь, можливо, підкажуть фрагменти коду з telegram-cli:

  if (tgl_get_peer_type (E->id) != TGL_PEER_CHANNEL || (C && (C->flags & TGLCHF_MEGAGROUP))) {
    out_int (CODE_messages_get_history);
    out_peer_id (TLS, E->id);
  } else {    
    out_int (CODE_channels_get_important_history);

    out_int (CODE_input_channel);
    out_int (tgl_get_peer_id (E->id));
    out_long (E->id.access_hash);
  }
  out_int (E->max_id);
  out_int (E->offset);
  out_int (E->limit);
  out_int (0);
  out_int (0);

Іншими словами, тут серіалізація робиться ВРУЧНУ, а не згенерованим кодом! Можливо, сервер реалізований аналогічно?.. У принципі, таке пригодиться, якщо зробити один раз, але як це потім підтримувати під час оновлень? Чи не за цим схема була вигадана? І тут ми переходимо до наступного питання.

Версійність. Шари (layers)

Чому версії схеми названі шарами, можна робити лише припущення, з історії опублікованих схем. Очевидно, спочатку авторам здалося, що базові речі можна робити на незміненій схемі, і лише там, де треба, на конкретні запити вказувати, що вони робляться за іншою версією. В принципі, навіть непогана ідея — і нове «підмішуватиметься», нашаровуватиметься на старе. Але побачимо, як це було зроблено. Щоправда, подивитися з самого початку не вдалося — кумедно, але схем базового шару просто не існує. Шари почалися з 2. Документація розповідає нам про спеціальну фічу TL:

Якщо клієнти підтримують 2-річну версію, то наступні конструктори повинні бути використані:

invokeWithLayer2#289dd1f6 {X:Type} query:!X = X;

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

Звучить нормально. Але що було далі? Далі з'явився

invokeWithLayer3#b7475268 query:!X = X;

А далі? Як неважко здогадатися,

invokeWithLayer4#dea0d430 query:!X = X;

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

invokeWithLayer5#417a57ae query:!X = X;

Але очевидно, що через деякий час це стане певною вакханалією. І прийшло рішення:

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

Ура! Через 9 версій ми прийшли, нарешті, до того, що в Internet-протоколах робилося ще в 80-ті - узгодження версії один раз на початку з'єднання!

А далі?..

invokeWithLayer10#39620c41 query:!X = X;
...
invokeWithLayer18#1c900537 query:!X = X;

А ось тепер можна сміятися. Тільки ще через 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 щось оновлюють... а далі в логах:

2019-08-15 09:28:35.880640 MSK warn  main: ANON:87: unknown object type: 0x80d182d1 at TL/Object.pm line 213.
2019-08-15 09:28:35.751899 MSK warn  main: ANON:87: unknown object type: 0xb5223b0f at TL/Object.pm line 213.

і далі кілька мегабайт стектрейсів (ну, заодно і логування пофіксували). Адже якщо у Вас у TL щось не розпізналося – він же бінарний за сигнатурами, далі в потоці ВСІ поїде, декодування стане неможливим. Що взагалі в такій ситуації робити?

Ну, перше, що кожному на думку приходить — від'єднатися і спробувати заново. Не допомогло. Гуглимо по CRC32 — це виявилися об'єкти з 73 схеми, хоча ми працювали на 82. Уважно дивимося у логи — там ідентифікатори з двох різних схем!

Може проблема суто в нашому неофіційному клієнті? Ні, запускаємо Telegram Desktop 1.2.17 (версія, що поставляється в ряді дистрибутивів Linux), він пише в лог Exception: MTP

Критика протоколу та оргпідходів Telegram. Частина 1, технічна: досвід написання клієнта з нуля - TL, MT

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

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

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

Пояснення? Як можна здогадатися з різних непрямих симптомів, сервер складається з багатьох процесів різних типів на різних машинах. Швидше за все, той із серверів, що відповідає за «буферизацію», поклав у чергу те, що йому віддавали вищі, а вони віддавали у тій схемі, яка була на момент генерації. І доки ця черга не «протухла», нічого з цим зробити було не можна.

Хіба що… але це жахливий милиця?!.. Ні, перш ніж думати про божевільні ідеї, давайте подивимося в код офіційних клієнтів. У версії для Android ми не знаходимо ніякого TL-парсера, але знаходимо великий файл (гітхаб відмовляється його підфарбовувати) з (де)серіалізацією. Ось фрагменти коду:

public static class TL_message_layer68 extends TL_message {
    public static int constructor = 0xc09be45f;
//...
//еще пачка подобных
//...
    public static class TL_message_layer47 extends TL_message {
        public static int constructor = 0xc992e15c;
        public static Message TLdeserialize(AbstractSerializedData stream, int constructor, boolean exception) {
            Message result = null;
            switch (constructor) {
                case 0x1d86f70e:
                    result = new TL_messageService_old2();
                    break;
                case 0xa7ab1991:
                    result = new TL_message_old3();
                    break;
                case 0xc3060325:
                    result = new TL_message_old4();
                    break;
                case 0x555555fa:
                    result = new TL_message_secret();
                    break;
                case 0x555555f9:
                    result = new TL_message_secret_layer72();
                    break;
                case 0x90dddc11:
                    result = new TL_message_layer72();
                    break;
                case 0xc09be45f:
                    result = new TL_message_layer68();
                    break;
                case 0xc992e15c:
                    result = new TL_message_layer47();
                    break;
                case 0x5ba66c13:
                    result = new TL_message_old7();
                    break;
                case 0xc06b9607:
                    result = new TL_messageService_layer48();
                    break;
                case 0x83e5de54:
                    result = new TL_messageEmpty();
                    break;
                case 0x2bebfa86:
                    result = new TL_message_old6();
                    break;
                case 0x44f9b43d:
                    result = new TL_message_layer104();
                    break;
                case 0x1c9b1027:
                    result = new TL_message_layer104_2();
                    break;
                case 0xa367e716:
                    result = new TL_messageForwarded_old2(); //custom
                    break;
                case 0x5f46804:
                    result = new TL_messageForwarded_old(); //custom
                    break;
                case 0x567699b3:
                    result = new TL_message_old2(); //custom
                    break;
                case 0x9f8d60bb:
                    result = new TL_messageService_old(); //custom
                    break;
                case 0x22eb6aba:
                    result = new TL_message_old(); //custom
                    break;
                case 0x555555F8:
                    result = new TL_message_secret_old(); //custom
                    break;
                case 0x9789dac4:
                    result = new TL_message_layer104_3();
                    break;

або

    boolean fixCaption = !TextUtils.isEmpty(message) &&
    (media instanceof TLRPC.TL_messageMediaPhoto_old ||
     media instanceof TLRPC.TL_messageMediaPhoto_layer68 ||
     media instanceof TLRPC.TL_messageMediaPhoto_layer74 ||
     media instanceof TLRPC.TL_messageMediaDocument_old ||
     media instanceof TLRPC.TL_messageMediaDocument_layer68 ||
     media instanceof TLRPC.TL_messageMediaDocument_layer74)
    && message.startsWith("-1");

Кхм… виглядає дико. Але, напевно, це згенерований код, тоді добре?.. Зате точно всі версії підтримує! Щоправда, незрозуміло, чому все намішано в одну купу, і секретні чати, і всілякі _old7 якось не схожі на машинну генерацію ... Втім, найбільше я офігел від

TL_message_layer104
TL_message_layer104_2
TL_message_layer104_3

Хлопці, ви там що, навіть усередині одного шару визначитися не можете? Ну, гаразд, «два», припустимо, релизнулися з помилкою, ну буває, але ТРИ?.. Відразу ж ще раз на ті ж граблі? Що це за порнографія, пардон?

У вихідниках Telegram Desktop, до речі, трапляється аналогічне — якщо так, і кілька коммітів поспіль у схему не змінюють її номери шару, а щось фіксують. В умовах коли офіційного джерела даних за схемою немає, звідки її брати, крім вихідників офіційного клієнта? А візьмеш звідти, не можеш бути впевненим, що схема цілком правильна, доки не протестуєш усі методи.

А як таке загалом можна тестувати? Сподіваюся, любителі юніт-, функціональних та інших тестів поділяться у коментарях.

Гаразд, розглянемо ще фрагмент коду:

public static class TL_folders_deleteFolder extends TLObject {
    public static int constructor = 0x1c295881;

    public int folder_id;

    public TLObject deserializeResponse(AbstractSerializedData stream, int constructor, boolean exception) {
        return Updates.TLdeserialize(stream, constructor, exception);
    }

    public void serializeToStream(AbstractSerializedData stream) {
        stream.writeInt32(constructor);
        stream.writeInt32(folder_id);
    }
}

//manually created

//RichText start
public static abstract class RichText extends TLObject {
    public String url;
    public long webpage_id;
    public String email;
    public ArrayList<RichText> texts = new ArrayList<>();
    public RichText parentRichText;

    public static RichText TLdeserialize(AbstractSerializedData stream, int constructor, boolean exception) {
        RichText result = null;
        switch (constructor) {
            case 0x1ccb966a:
                result = new TL_textPhone();
                break;
            case 0xc7fb5e01:
                result = new TL_textSuperscript();
                break;

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

Але годі. Перейдемо до протоколу, поверх якого вся ця серіалізація ганяється.

MT Proto

Отже, відкриваємо Загальний опис и детальний опис протоколу і насамперед спотикаємося про термінологію. І з великою кількістю всього. Взагалі, це схоже фірмова фішка Telegram - називати речі в різних місцях по-різному, або різні речі одним словом, або навпаки (наприклад, у високорівневому API якщо побачите sticker pack це не те, що Ви подумали).

Наприклад, "повідомлення" (message) і "сесія" (session) - тут означають інше, ніж у звичному інтерфейсі Telegram-клієнта. Ну, з повідомленням все зрозуміло, його можна було б трактувати в термінах ОВП, або просто називати словом «пакет» — це низький, транспортний рівень, тут не ті повідомлення, що в інтерфейсі, багато службових. А ось сесія... але про все по порядку.

Транспортний рівень

Насамперед — транспорт. Нам розкажуть аж про 5 варіантів:

  • TCP
  • Вебсокет
  • Websocket over HTTPS
  • HTTP
  • HTTPS

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

А TCP у трьох варіантах

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

Ну, зараз там TCP вже у 4 варіантах:

  • Скорочений
  • Проміжний
  • Padded intermediate
  • Повний

Ну добре, Padded intermediate для MTProxy, це пізніше додали через відомі події. А ось навіщо ще дві версії (всього три), коли можна було б обійтися однією? Всі чотири по суті відрізняються лише тим, яким чином задати довжину та платі власне того основного MTProto, про яке йтиметься далі:

  • в Abridged це 1 або 4 байти, але не 0xef, потім тіло
  • в Intermediate це 4 байти довжини і поле, причому перший раз клієнт має надіслати 0xeeeeeeee для вказівки, що це Intermediate
  • у Full найбільша наркоманія, з точки зору мережевика: довжина, sequence number, причому НЕ ТОЙ, що в основному MTProto, тіло, CRC32. Так, все це поверх TCP. Який надає нам надійний транспорт як послідовного потоку байт, ніякі послідовності не потрібні, тим паче контрольні суми. Окей, мені зараз заперечать, що TCP 16-бітна контрольна сума, отже спотворення даних трапляється. Добре, тільки у нас взагалі-то криптографічний протокол з хешами довше 16 байт, всі ці помилки - і навіть більше - будуть відловлені на розбіжності SHA рівнем вище. Жодного сенсу в CRC32 поверх цього — НІ.

Порівняємо Abridged, в якому можливий один байт довжини, з Intermediate, який обґрунтовується "In case 4-byte data alignment is needed", що досить-таки нісенітниця. Що, вважається, що програмісти Telegram настільки невміли, що не можуть прочитати дані із сокету у вирівняний буфер? Це все одно доведеться робити, тому що читання може повернути Вам будь-яку кількість байт (а ще бувають наприклад проксі-сервера ...). Або з іншого боку, навіщо городити Abridged, якщо зверху у нас все одно будуть здоровенні padding'и від 16 байт - заощадити 3 байти іноді ?

Складається враження, що Микола Дуров дуже любить винаходити велосипеди, у тому числі мережеві протоколи, без реальної практичної потреби.

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

Але робити висновки про технічну грамотність цих людей робити будемо наприкінці розділу після розгляду інших питань. Поки перейдемо до 5-го рівня OSI, сесійного – на який вони помістили MTProto session.

Ключі, повідомлення, сесії, Diffie-Hellman

Помістили вони його туди не зовсім коректно… Сесія – це не та сесія, що видно в інтерфейсі під Active sessions. Але по порядку.

Критика протоколу та оргпідходів Telegram. Частина 1, технічна: досвід написання клієнта з нуля - TL, MT

Ось ми отримали з рівня рівня рядок байт відомої довжини. Це або шифроване повідомлення, або plaintext - якщо ми ще на стадії узгодження ключа і власне ним і займаємося. Про яке з купи понять під назвою «ключ» йдеться? Прояснимо це питання за саму команду Telegram (перепрошую за переклад з англійської власної документації до ліби втомленим мозком о 4 ранку, деякі фрази було простіше залишити як є):

Є дві сутності під назвою Сесія - Одна в UI офіційних клієнтів під «current sessions», де кожній сесії відповідає цілий пристрій / OS.
друга - MTProto session, яка має sequence number повідомлення (у низькорівневому сенсі) в ній, і яка може тривати між різними з'єднаннями TCP. Одночасно можуть бути встановлені кілька MTProto-сесій, наприклад, для прискорення закачування файлів.

Між цими двома сесіях знаходиться поняття авторизації. У виродженому випадку, можна сказати, що UI-сесія є те саме, що авторизаціїАле на жаль, все складно. Дивимося:

  • Користувач на новому пристрої спочатку генерує auth_key і bounds it to account, наприклад по SMS - тому і авторизації
  • Сталося це всередині першої MTProto session, яка має session_id у собі.
  • На цьому кроці, комбінація авторизації и session_id могла бути названа екземпляр — це слово зустрічається в документації та коді деяких клієнтів
  • Потім клієнт може відкрити кілька MTProto sessions під одним і тим же auth_key - До того ж DC.
  • Потім, одного разу клієнту знадобиться запитати файл у іншого DC - і для цього DC буде згенеровано новий auth_key !
  • Щоб повідомити системі, що це не новий користувач реєструється, а та сама авторизації (UI-сесія), клієнт використовує виклики API auth.exportAuthorization у домашньому DC auth.importAuthorization у новому DC.
  • Так само, може бути відкрито кілька MTProto sessions (кожна з власним session_id) до цього нового DC, під його auth_key.
  • Нарешті, клієнт може захоч Perfect Forward Secrecy. Кожен auth_key був постійний key - per DC - і клієнт може викликати auth.bindTempAuthKey для використання тимчасовий auth_key - І знову, тільки один temp_auth_key per DC, загальний для всіх MTProto sessions до цього DC.

Зауважимо, що сіль (і 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) є наведений до requisite power over the requisite modulus, і результат є stored as a 256-byte number.

У них якийсь наркоманський DH

Не схоже на DH здорової людини
У дх немає двох публічних ключів

Ну з цим в результаті розібралися але осад залишився - робиться proof of work клієнтом, що він зміг факторизувати число. Тип захисту від DoS-атак. І RSA-ключ використовується лише один раз в одному напрямку, по суті, для шифрування new_nonce. Але поки ця начебто проста операція вийде, з чим доведеться зіткнутися?

Vasily, [20.06.18 00:26] Я ще не дійшов до запиту appid

Це я запит на DH відправив

А в доці на транспорт написано, що може відповісти 4 байтами коду помилки. І все

Ну, ось сказав він мені -404, і що?

Ось я йому: «лови свою ефігню шифровану ключем сервера з таким відбитком, хочу 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. Вже три різні хеші

Key fingerprint is computed as follows:

digest = md5(key + iv)
fingerprint = substr(digest, 0, 4) XOR substr(digest, 4, 4)

SHA1 та sha2

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

До речі, «отримали» ми його із застереженнями. Ось, наприклад, ми довіряємо серверу? Аж раптом він підроблений? Потрібні б криптографічні перевірки:

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

Але взагалі незрозуміло, нафейхоа

Vasily, [21.06.18 18:02] У доці не сказано, що робити, якщо воно не просте виявилося

Чи не сказано. Давайте подивимося, що в цьому випадку робить офіційний клієнт під Андроїд? А ось що (і так, там весь файл цікавий) - як то кажуть, я просто залишу це тут:

278     static const char *goodPrime = "c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b";
279   if (!strcasecmp(prime, goodPrime)) {

Ні, там звичайно ще якісь перевірки простоти числа є, але особисто я достатніми знаннями в математиці вже не маю.

Гаразд, ми отримали основний ключ. Щоб авторизуватися, тобто. надіслати запити, треба проводити подальше шифрування, вже за допомогою AES.

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

Vasily, [22.06.18 14:08] Середні, сука, біти

отримав auth_key. Всі. Далі за них... не зрозуміло з доки. Feel free to study the open source code.

Зверніть увагу, що MTProto 2.0 вимагають від 12 до 1024 байт Padding, продовжує спостерігати за умовою, що результатом повідомлення буде 16 байт.

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

І так, тут теж 404 у разі помилки

Якщо хтось уважно вивчив схему та текст документації, звернув увагу, що ніякого MAC там немає. І що AES використовується в якомусь режимі IGE, що більш ніде не застосовується. Вони, звичайно, пишуть про це у своєму FAQ… Тут, на кшталт, сам ключ повідомлення заразом і є SHA-хеш розшифрованих даних, що використовується для перевірки цілісності — причому в разі розбіжності документація чомусь рекомендує silently ignore їх (а як же безпека, раптом нас ламають?).

Я не криптограф, можливо, в цьому режимі в даному випадку немає нічого поганого з теоретичної точки зору. Але я можу точно назвати практичну проблему, на прикладі 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 була перша спроба в "authenticating encryption mode," originally for Kerberos. Він був впевнений у тому, що він не гарантує надійності захисту, і він буде removed. Те, що було розпочато 20 років quest for authenticating encryption mode that works, which recently culminated in modes як OCB і GCM.

А тепер аргументи з боку воза:

Тяга під Telegram, led by Nikolai Durov, присутній з шести ACM шампіонів, половина їх Ph.Ds in math. Ідеться про них про два роки до roll out 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.

І якийсь query

Документація, як завжди. Feel free to study the open source

Якщо з invokeWithLayer все було приблизно зрозуміло, то тут що? Виявляється, припустимо у нас - клієнт вже мав щось, про що запитати сервер - є запит, який ми хотіли надіслати:

Vasily, [25.06.18 19:13] Судячи з коду, перший виклик загортається в цю дрисню, а сама дрисня в invokewithayer

Чому initConnection не міг бути окремим викликом, а обов'язково має бути обгорткою? Так, як виявилося, його треба обов'язково щоразу на початку кожної сесії робити, а не разово, як із основним ключем. Але! Його не може викликати неавторизований користувач! Ось ми дісталися до етапу, в якому застосовується ось ця сторінка документації і вона повідомляє нам, що…

Лише невелика порція API методів є доступною для неповноцінних користувачів:

  • auth.sendCode
  • auth.resendCode
  • account.getPassword
  • auth.checkPassword
  • auth.checkPhone
  • auth.signUp
  • auth.signIn
  • auth.importAuthorization
  • help.getConfig
  • help.getNearestDc
  • help.getAppUpdate
  • help.getCdnConfig
  • langpack.getLangPack
  • langpack.getStrings
  • langpack.getDifference
  • langpack.getLanguages
  • langpack.getLanguage

Найперший з них, auth.sendCode, і є той заповітний перший запит, в якому ми надішлемо api_id та api_hash, і після якого нам надходить SMS із кодом. А якщо ми потрапили не в той DC (телефонні номери цієї країни обслуговує інший, наприклад), то прийде помилка з номером потрібного DC. Щоб дізнатися, на яку IP-адресу за номером DC треба з'єднуватися, нам допоможе help.getConfig. Колись там було лише 5 записів, але після відомих подій 2018 року кількість значно зросла.

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

Ну добре. На цьому етапі авторизації клієнта ми ще не авторизовані та не реєстрували свою програму. Ми хочемо просто поки подивитися, що сервер відповідає на методи, доступні неавторизованому користувачеві. І тут…

Vasily, [10.07.18 14:45] https://core.telegram.org/method/help.getConfig

config#7dae33e0 [...] = Config;
help.getConfig#c4f9186b = Config;

https://core.telegram.org/api/datacenter

config#232d5905 [...] = Config;
help.getConfig#c4f9186b = Config;

У схемі перше, приходить друге

У схемі tdesktop третє значення

Так, з того часу, звісно, ​​документацію оновили. Хоча незабаром вона знову може стати неактуальною. А звідки повинен знати розробник-початківець? Може, якщо зареєструвати свій додаток, то повідомлять? Василь зробив це, але, на жаль, нічого йому не надіслали (знов, поговоримо про це в другій частині).

… Ви помітили, що ми вже якось перейшли до API, тобто. до наступного рівня, і щось пропустили у темі MTProto? Нічого дивного:

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

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

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

Як поєднують? Ну ось той самий тимчасовий ключ для PFS, наприклад (до речі, Telegram Desktop його не вміє). Він виконується запитом API auth.bindTempAuthKey, тобто. із верхнього рівня. Але при цьому вторгається в шифрування на нижньому рівні — після нього, наприклад, треба наново робити initConnection і т.п., це не просто Простий запит. Окремо доставляє ще й те, що можна мати лише ОДИН тимчасовий ключ на DC, хоча поле auth_key_id у кожному повідомленні дозволяє міняти ключ хоч кожне повідомлення, і що сервер має право у будь-який момент «забути» тимчасовий ключ — що в цьому випадку робити, документація не каже… ну чому не можна було б мати кілька ключів, як із набором future salts, а ?..

Варто зазначити у темі MTProto ще деякі речі.

Повідомлення про повідомлення, msg_id, msg_seqno, підтвердження, пінги не в той бік та інші ідіосинкразії

Чому про них треба знати? Тому що вони протікають на рівень вище, і про них потрібно знати, працюючи з API. Допустимо, msg_key нас не цікавить, нижній рівень розшифрував все для нас. Але всередині розшифрованих даних у нас такі поля (ще довжина даних, щоб знати де padding, але це не важливо):

  • salt - int64
  • session_id - int64
  • message_id - int64
  • seq_no - int32

Нагадаємо, сіль - одна на весь DC. Навіщо про неї знати? Не лише тому, що є запит get_future_salts, Який повідомляє, в які інтервали які будуть валідні, але й тому, що якщо Ваша сіль «протухла», то повідомлення (запит) просто загубиться. Сервер, звичайно, повідомить нову сіль, видавши new_session_created — але зі старим доведеться якось робити перенаправлення, наприклад. І це питання впливає на архітектуру програми.

Серверу дозволено взагалі тремтіти сесії і відповідати таким чином з багатьох приводів. Що таке сесія MTProto з боку клієнта? Це два числа, session_id и seq_no повідомлення усередині цієї сесії. Ну, і нижнє TCP-з'єднання, звичайно. Допустимо, наш клієнт ще багато чого не вміє, від'єднався, перепід'єднався. Якщо це сталося швидко — у новому TCP-з'єднанні продовжилася стара сесія, збільшуємо seq_no далі. Якщо довго – сервер міг її видалити, бо на його боці це ще й черга, як ми з'ясували.

Який має бути seq_no? О, це хитре питання. Спробуйте чесно зрозуміти, що йшлося про:

Content-related Message

A message requiring explicit acknowledgment. Вони включають всі користувачі і багато служб, віртуально всі з виключенням containers and acknowledgments.

Message Sequence Number (msg_seqno)

У 32-бітному номері еквівалентно до двадцяти номерів “content-related” messages (наскільки вимагає знання, і в особливій темі, що немає containers), створена при цьому приором до цього message і subsequently incremented by one if the content-related message. A container is always generated after its entire contents; тому,що його послідовність номера є великою, ніж або еквівалентом до послідовності номерів, що містяться в ньому.

Що це за цирк з інкрементом на 1, а потім ще на 2? кілька підтверджень, що мають один і той же seq_no! Як? Ну наприклад, сервер нам щось шле, шле, а ми самі мовчимо, тільки відповідаємо сервісними повідомленнями підтверджень про отримання його повідомлень. У цьому випадку наші вихідні підтвердження будуть мати той самий вихідний номер. Якщо Ви знайомі з TCP і подумали, що це звучить якось дико, але начебто не дуже дико, адже в TCP seq_no не змінюється, а підтвердження йде на seq_no того боку — то поспішаю засмутити. У MTProto підтвердження йдуть НЕ по seq_no, як у TCP, а по msg_id !

Що ж це за msg_idНайважливіше з цих полів? Унікальний ідентифікатор повідомлення, як випливає з назви. Визначено він як 64-бітове число, наймолодші біти якого знову мають магію "сервер-не сервер", а решту - Unix timestamp, включаючи дробову частину, зрушений на 32 біти вліво. Тобто. мітка часу по суті (і повідомлення з занадто різним часом будуть відкинуті сервером). З цього виходить, що це ідентифікатор, глобальний для клієнта. При тому, що згадаємо session_id - нам гарантується: Under no circumstances can message meant for one session be sent into a different session. Тобто виходить, що є аж три рівня – сесія, номер у сесії, id повідомлення. Навіщо таке переускладнення, ця таємниця є дуже великою.

Отже, msg_id потрібен для…

RPC: запити, відповіді, помилки. Докази.

Як Ви, можливо, помітили, ніде у схемі немає спеціального типу чи функції «зробити RPC-запит», хоча є відповіді. Адже у нас є content-related повідомлення! Тобто, будь-який повідомлення може бути запитом! Або не бути. Адже у кожного є msg_id. А ось відповіді є:

rpc_result#f35c6d01 req_msg_id:long result:Object = RpcResult;

Ось тут і вказується, яке повідомлення це відповідь. Тому Вам, на верхньому рівні API, доведеться пам'ятати, який номер був у Вашого запиту — гадаю, не треба пояснювати, що робота асинхронна, і водночас у роботі може бути кілька запитів, відповіді на які можуть повернутися у будь-якому порядку? В принципі, з цього, і повідомлень про помилки типу no workers, простежується архітектура, що стоїть за цим: підтримує з Вами TCP-з'єднання сервер — фронтенд-балансувальник, він направляє запити на бекенди і збирає їх назад message_id. Начебто все зрозуміло, логічно і добре.

Так?.. А якщо замислитись? Адже у RPC-відповіді теж є поле msg_id! Чи треба нам кричати серверу «ви не відповідаєте на мою відповідь!»? І так, що там було для підтвердження? Сторінка про повідомлення про повідомлення каже нам, що є

msgs_ack#62d6b459 msg_ids:Vector long = MsgsAck;

і його має робити кожна сторона. Але не завжди! Якщо ви отримали RpcResult, він сам є підтвердженням. Тобто, на Ваш запит сервер може відповісти MsgsAck типу, «я отримав». Може відразу відповісти на RpcResult. Можливо і те, й інше.

І так, Ви таки маєте відповісти на відповідь! Доказом. Інакше сервер вважатиме його недоставленим та вивалить Вам його знову. Навіть після перепід'єднання. Але тут, звісно, ​​питання таймаутів виникне. Розглянемо їх трохи згодом.

А поки що розглянемо можливі помилки виконання запитів.

rpc_error#2144ca19 error_code:int error_message:string = RpcError;

О, вигукне хтось, тут людяніший формат — є рядок! Не поспішайте. Ось перелік помилокале, звичайно, не повний. З нього ми дізнаємося, що код щось на зразок HTTP-помилки (ну зрозуміло, семантика відповідей не дотримується, місцями вони розподілені за кодами абияк), а рядок має вигляд типу ВЕЛИКІ_БУКВИ_І_ЦИФРИ. Наприклад, PHONE_NUMBER_OCCUPIED чи FILE_PART_Х_MISSING. Ну тобто, Вам цей рядок ще доведеться пропарсити. наприклад, FLOOD_WAIT_3600 означатиме, що треба чекати годину, а PHONE_MIGRATE_5, Що телефонному номеру з цим префіксом треба реєструватися в 5-му DC. Адже в нас мова типів, так? Аргумент з рядка нам не потрібний, регулярками обійдуться, чо.

Знову ж таки, на сторінці сервісних повідомлень цього немає, але, як вже звично з цим проектом, інформація може знайтись на іншій сторінці документації. або навести на підозру. По-перше, дивіться, порушення типізації/шарів. RpcError може бути вкладений у RpcResult. Чому не зовні? Що ми не врахували? Відповідно, де гарантія, що RpcError може бути і НЕ вкладений у RpcResult, а бути безпосередньо чи вкладено інший тип?.. А якщо може, чому він верхнього рівня, тобто. у ньому відсутня req_msg_id ? ..

Але продовжимо про сервісні повідомлення. Клієнт може вважати, що сервер довго думає, і зробити такий чудовий запит:

rpc_drop_answer#58e4a740 req_msg_id:long = RpcDropAnswer;

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

Наркоманія: статуси повідомлень про повідомлення

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

Починається вона безневинно, з підтверджень. Далі нам розповідають про

bad_msg_notification#a7eff811 bad_msg_id:long bad_msg_seqno:int error_code:int = BadMsgNotification;
bad_server_salt#edab447b bad_msg_id:long bad_msg_seqno:int error_code:int new_server_salt:long = BadMsgNotification;

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

  1. З цього випливає, що оригінальне повідомлення втрачено. Потрібно городити якісь черги, розглянемо пізніше.
  2. Що за дивні номери помилок? 16, 17, 18, 19, 20, 32, 33, 34, 35, 48, 64… де решта номерів, Томмі?

Документація стверджує:

Назвою є те, що error_code значення є групованим (error_code >> 4): для прикладу, коди 0x40 - 0x4f відповідають errors в container decomposition.

але, по-перше, зрушення в інший бік, по-друге, все одно, де решта кодів? У голові автора?.. Втім, це дрібниці.

Наркоманія починається в повідомленнях про статус повідомлень та копії повідомлень:

  • Request for Message Status Information
    Якщо ця інша частина не отримує отримані відомості на статуї її outgoing messages for while, it mai explicitly request it from the other party:
    msgs_state_req#da69fb52 msg_ids:Vector long = MsgsStateReq;
  • Informational Message regarding Status of Messages
    msgs_state_info#04deb57d req_msg_id:long info:string = MsgsStateInfo;
    Тут, info is string that contains exactly byte of message status for each message from the incoming msg_ids list:

    • 1 = nothing is known about the message (msg_id too low, thether party може бути forgotten it)
    • 2 = message no received (msg_id falls within range of stored identifiers; however, thether party has certainly no received a message like that)
    • 3 = message no received (msg_id too high; however, thether party has certainly no received it yet)
    • 4 = message received (note that this response is also at the same time a receipt acknowledgment)
    • +8 = message already acknowledged
    • +16 = повідомлення не потребує акцій
    • +32 = RPC Query міститься в повідомленні, виконаному або виконаному абозавершеному виконанні
    • +64 = content-related response to message already generated
    • +128 = інші party knows for fact that message is already received
      Ця відповідь не потребує акціонерів. Це являє собою висновоквідповіді про відповідність msgs_state_req, in and ofitself.
      Зверніть увагу, що якщо цю угоду не повертається, що в іншій частині дня не буде повідомлення, що дає змогу, щоб він був впевнений, що повідомлення може бути простим. Будь-яка інша сторона повинна отримати дві копії повідомлення в той час, як duplicate will be ignored. (Якщо це дуже багато часу, і оригінал msg_id не є тривалим valid, message is to wrapped in msg_copy).
  • Voluntary Communication of Status of Messages
    Її party може бути впевнено, що інші party of status of messages transmitted by other party.
    msgs_all_info#8cc0d131 msg_ids:Vector long info:string = MsgsAllInfo
  • Збільшена Voluntary Communication of Status of One Message
    ...
    msg_detailed_info#276d3ec6 msg_id:long answer_msg_id:long bytes:int status:int = MsgDetailedInfo;
    msg_new_detailed_info#809db6df answer_msg_id:long bytes:int status:int = MsgDetailedInfo;
  • Explicit Request to Re-Send Messages
    msg_resend_req#7d861a08 msg_ids:Vector long = MsgResendReq;
    Remote party immediately responds by re-sending the requested messages […]
  • Explicit Request to Re-Send Answers
    msg_resend_ans_req#8610baeb msg_ids:Vector long = MsgResendReq;
    Remote party immediately responds by re-sending Відповіді to the requested messages […]
  • Message Copies
    У деяких випадках, old message with msg_id that is no longer valid needs to be re-sent. Then, it is wrapped in a copy container:
    msg_copy#e06046b2 orig_message:Message = MessageCopy;
    Після того, як отриманий, повідомлення буде запрограмовано, якщо шухляда не була. Незважаючи на те, що це відомо для певного, що повідомлення orig_message.msg_id було отримано, то новий message не processed (якщо в тому ж часі, це і orig_message.msg_id є зведеним). Value of orig_message.msg_id мусить бути менше, ніж container's msg_id.

Навіть помовчимо про те, що в msgs_state_info знову стирчать вуха недоробленого TL (потрібний був вектор байт, й у молодших двох бітах enum, а старших прапори). Суть у іншому. Хтось розуміє, навіщо все це на практиці у реальному клієнті потрібно?.. Насилу, але можна уявити якусь користь, якщо людина займається налагодженням, причому в інтерактивному режимі — запитати у сервера, що та як. Але тут описуються запити в обидві сторони.

Звідси випливає, що кожна сторона повинна не просто шифрувати та відправляти повідомлення, а й зберігати дані про них самих, про відповіді на них, причому невідома кількість часу. Документація ні таймінги, ні практичну застосовність цих фіч не описує ніяк. Що найдивовижніше, вони справді використовуються в коді офіційних клієнтів! Очевидно, їм повідомили щось, що не увійшло до відкритої документації. Зрозуміти ж із коду, навіщо, вже не так просто, як у випадку TL - це (порівняно) логічно ізольована частина, а шматок, зав'язаний на архітектуру додатка, тобто. вимагатиме значно більше часу на входження до коду програми.

Пінги та таймінги. Черги.

З усього, якщо згадати здогади про архітектуру сервера (розподіл запитів по бекендах), витікає досить сумна річ - незважаючи на всі гарантії доставки що в TCP (або дані доставлені, або Вам повідомлять про розрив, але дані до моменту проблеми будуть доставлені), що підтвердження в самому MTProto гарантій немає. Сервер може запросто втратити або викинути Ваше повідомлення, і нічого з цим зробити не можна, тільки городити милиці різних видів.

І насамперед черги повідомлень. Ну, з одного все було очевидно з самого початку — непідтверджене повідомлення треба зберігати і пересилати. А через який час? А блазень його знає. Можливо, вони наркоманські сервісні повідомлення якось милицями вирішують цю проблему, скажімо, в Telegram Desktop приблизно штуки 4 черги, їм відповідних (може більше, як уже говорилося, для цього треба вникати в його код і архітектуру серйозніше; при цьому ми знаємо, що за зразок його брати не можна, енна кількість типів із схеми MTProto у ньому не використовується).

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

Ну, тобто, Ви розумієте, так? Якщо поверх протоколу, що працює по TCP, доводиться реалізовувати знову TCP — це говорить про дуже погано спроектований протокол.

Ах так, чому потрібно більше однієї черги, і взагалі, що це означає для людини, яка працює з високорівневим API? Дивіться, Ви робите запит, серіалізує його, але відправити його негайно часто не можна. Чому? Тому що відповідь буде по msg_id, який є тимчасовимая мітка, призначення якої краще відкласти на якомога пізніше - раптом сервер відкине через розбіжність часу у нас і в нього (звичайно, ми можемо зробити милицю, що зсуває наш час від справжнього до серверного додаванням дельти, обчисленої з відповідей сервера - офіційні клієнти так і роблять, але цей спосіб грубий і неточний через буферизацію). Тому, коли Ви запитуєте локальний виклик функції з бібліотеки, повідомлення проходить такі стадії:

  1. Лежить в одній черзі і чекає на шифрування.
  2. призначено msg_id і повідомлення лягло в іншу чергу - можливої ​​перенаправлення; відправляємо у сокет.
  3. а) Сервер відповів MsgsAck – повідомлення доставлено, видаляємо з «іншої черги».
    б) Або навпаки, щось йому не сподобалося, він відповів badmsg – пересилаємо з «іншої черги»
    в) Нічого невідомо, треба переслати повідомлення з іншої черги, але невідомо точно, коли.
  4. Сервер нарешті відповів RpcResult - власне відповіддю (або помилкою) - не просто доставлено, а й опрацьовано.

Можливочастково вирішити проблему могло б використання контейнерів. Це коли пачка повідомлень пакується в одне, і сервер відповів підтвердженням на всю відразу, одним msg_id. Але й відкине він цю пачку, якщо щось пішло не так, теж усю.

І тут вступають у дію не технічні міркування. На досвід ми бачили багато милиць, а крім того, зараз побачимо ще приклади поганих порад та архітектури — за таких умов, чи варто довіряти і приймати такі рішення? Питання риторичне (звичайно, ні).

Про що мова? Якщо на тему «наркоманські повідомлення про повідомлення» ще можна спекулювати запереченнями виду «це ви тупі, не зрозуміли наш геніальний задум!» (Так напишіть спочатку документацію, як належить у нормальних людей, з rationale і прикладами обміну пакетів, тоді і поговоримо), то таймінги/таймаути - питання суто практичне і конкретне, тут все давно відомо. А що ж нам каже документація про таймаути?

На сервері зазвичай з'являються повідомлення про отримання повідомлення від клієнта (normally, an RPC query) використовуючи rPC response. Якщо відповідь є тривалий час, а сервер може першу повідомити про визнання, і деякий час, як RPC відповідає його.

Учасники, як правило, зумовлюють передачу повідомлення з сервера (зазвичай, для RPC відповіді), щоб з'ясувати, що з'явиться, щоб продовжити RPC, якщо не буде переміщено до кінця (якщо це генерується, 60-120 секунд продовжується of a message from the server). Хоча, якщо протягом тривалого періоду часу немає ніякої відповіді на дві повідомлення на сервер або якщо є значне число невизначених повідомлень від сервера (до 16 днів), клієнт переміщується на stand-alone acknowledgment.

… Перекладаю: ми самі не знаємо скільки і як треба, ну давайте прикинемо, що нехай буде ось так.

І про пінг:

Ping Messages (PING/PONG)

ping#7abe77ec ping_id:long = Pong;

A response is usually returned to the same connection:

pong#347773c5 msg_id:long ping_id:long = Pong;

Ці messages не потребують акціонерів. A pong is transmitted only in response to a ping while a ping can be initiated by either side.

Deferred Connection Closure + PING

ping_delay_disconnect#f3427b8c ping_id:long disconnect_delay:int = Pong;

Works як ping. У повідомленні, після того, як він був отриманий, сервер дзвінків timer, який буде з'єднуватися з поточним з'єднанням disconnect_delay seconds, після того, як він не отримував новий номер того ж самого типу, який автоматично поміщає всі попередні timers. Якщо клієнти мають ці pings one 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? Все рівно навпаки! Ну, тобто. формально, звісно, ​​обидві сторони можуть пінгувати одна одну. Насправді ж — клієнти користуються милицею ping_delay_disconnect, що зводить на сервері таймер. Ну, вибачте, це не справа клієнта вирішувати, скільки він там хоче жити без пінгу. Серверу, виходячи зі свого навантаження, видно. Але, звичайно, якщо ресурсів не шкода, то самі собі злі буратини, і милиця зійде ...

А як треба було проектувати?

Гадаю, вищенаведені факти досить виразно свідчать про не дуже високу компетенцію команди Telegram/ВКонтакте в галузі транспортного (і нижче) рівня комп'ютерних мереж та їх низьку кваліфікацію у відповідних питаннях.

Чому ж воно таке складне вийшло і чим архітектори Telegram можуть спробувати заперечити? Тим, що вони намагалися зробити сесію, яка переживає розриви TCP-з'єднань, тобто не доставили зараз — доставимо пізніше. Ймовірно, ще спробували зробити UDP-транспорт, щоправда зіткнулися зі складнощами та закинули (тому й у документації порожньо — нема чим похвалитися було). Але через нерозуміння того, як працюють мережі взагалі і TCP зокрема, де можна на нього покластися, а де потрібно робити самому (і як), і спроби поєднати це з криптографією одним пострілом двох зайців — вийшов ось такий кадавр.

А як треба було? Виходячи з того що msg_id є міткою часу, необхідної з криптографічної точки зору для запобігання replay-атакам, помилкою є навішування на нього функції унікального ідентифікатора. Тому без кардинальної зміни поточної архітектури (коли формується потік Updates, це тема високорівневого API для іншої частини цієї серії постів), потрібно було б:

  1. Сервер, що тримає TCP-з'єднання з клієнтом, бере на себе відповідальність — якщо вичитав із сокету, будь ласка, підтвердити, обробити або повернути помилку, жодних втрат. Тоді підтвердженням стає вектор id'ов, а просто «останній отриманий seq_no» — просто число, як і TCP (два числа — свій seq і підтверджений). Адже ми в рамках сесії завжди, чи не так?
  2. Мітка часу для запобігання replay-атак стає окремим полем, а-ля nonce. Перевіряється, але більше нічого іншого не впливає. Досить і uint32 - Якщо у нас сіль змінюється не рідше за кожну півдобу, можна відвести 16 біт на молодші біти цілої частини поточного часу, решта - на дробову частину секунди (як і зараз).
  3. Забирається msg_id З точки зору розрізнення запитів на бекендах є, по-перше, id клієнта, по-друге, id сесії, їх і конкатенуйте. Відповідно, як ідентифікатор запиту достатньо лише seq_no.

Теж не найвдаліший варіант, ідентифікатором міг би служити і повний рандом — так уже робиться у високорівневому API під час надсилання повідомлення, до речі. Краще було б взагалі переробити архітектуру з відносною на абсолютну, але це тему вже для іншої частини, не цієї посади.

API?

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

Увага, зараз буде єдиний у статті приклад на Perl! (Для тих, хто не знайомий з синтаксисом, перший аргумент bless - структура даних об'єкта, другий - його клас):

2019.10.24 12:00:51 $1 = {
'cb' => 'TeleUpd::__ANON__',
'out' => bless( {
'filter' => bless( {}, 'Telegram::ChannelMessagesFilterEmpty' ),
'channel' => bless( {
'access_hash' => '-6698103710539760874',
'channel_id' => '1380524958'
}, 'Telegram::InputPeerChannel' ),
'pts' => '158503',
'flags' => 0,
'limit' => 0
}, 'Telegram::Updates::GetChannelDifference' ),
'req_id' => '6751291954012037292'
};
2019.10.24 12:00:51 $1 = {
'in' => bless( {
'req_msg_id' => '6751291954012037292',
'result' => bless( {
'pts' => 158508,
'flags' => 3,
'final' => 1,
'new_messages' => [],
'users' => [],
'chats' => [
bless( {
'title' => 'Хулиномика',
'username' => 'hoolinomics',
'flags' => 8288,
'id' => 1380524958,
'access_hash' => '-6698103710539760874',
'broadcast' => 1,
'version' => 0,
'photo' => bless( {
'photo_small' => bless( {
'volume_id' => 246933270,
'file_reference' => '
'secret' => '1854156056801727328',
'local_id' => 228648,
'dc_id' => 2
}, 'Telegram::FileLocation' ),
'photo_big' => bless( {
'dc_id' => 2,
'local_id' => 228650,
'file_reference' => '
'secret' => '1275570353387113110',
'volume_id' => 246933270
}, 'Telegram::FileLocation' )
}, 'Telegram::ChatPhoto' ),
'date' => 1531221081
}, 'Telegram::Channel' )
],
'timeout' => 300,
'other_updates' => [
bless( {
'pts_count' => 0,
'message' => bless( {
'post' => 1,
'id' => 852,
'flags' => 50368,
'views' => 8013,
'entities' => [
bless( {
'length' => 20,
'offset' => 0
}, 'Telegram::MessageEntityBold' ),
bless( {
'length' => 18,
'offset' => 480,
'url' => 'https://alexeymarkov.livejournal.com/[url_вырезан].html'
}, 'Telegram::MessageEntityTextUrl' )
],
'reply_markup' => bless( {
'rows' => [
bless( {
'buttons' => [
bless( {
'text' => '???? 165',
'data' => 'send_reaction_0'
}, 'Telegram::KeyboardButtonCallback' ),
bless( {
'data' => 'send_reaction_1',
'text' => '???? 9'
}, 'Telegram::KeyboardButtonCallback' )
]
}, 'Telegram::KeyboardButtonRow' )
]
}, 'Telegram::ReplyInlineMarkup' ),
'message' => 'А вот и новая книга! 
// [текст сообщения вырезан чтоб не нарушать правил Хабра о рекламе]
напечатаю.',
'to_id' => bless( {
'channel_id' => 1380524958
}, 'Telegram::PeerChannel' ),
'date' => 1571724559,
'edit_date' => 1571907562
}, 'Telegram::Message' ),
'pts' => 158508
}, 'Telegram::UpdateEditChannelMessage' ),
bless( {
'pts' => 158508,
'message' => bless( {
'edit_date' => 1571907589,
'to_id' => bless( {
'channel_id' => 1380524958
}, 'Telegram::PeerChannel' ),
'date' => 1571807301,
'message' => 'Почему Вы считаете Facebook плохой компанией? Можете прокомментировать? По-моему, это шикарная компания. Без долгов, с хорошей прибылью, а если решат дивы платить, то и еще могут нехило подорожать.
Для меня ответ совершенно очевиден: потому что Facebook делает ужасный по качеству продукт. Да, у него монопольное положение и да, им пользуется огромное количество людей. Но мир не стоит на месте. Когда-то владельцам Нокии было смешно от первого Айфона. Они думали, что лучше Нокии ничего быть не может и она навсегда останется самым удобным, красивым и твёрдым телефоном - и доля рынка это красноречиво демонстрировала. Теперь им не смешно.
Конечно, рептилоиды сопротивляются напору молодых гениев: так Цукербергом был пожран Whatsapp, потом Instagram. Но всё им не пожрать, Паша Дуров не продаётся!
Так будет и с Фейсбуком. Нельзя всё время делать говно. Кто-то когда-то сделает хороший продукт, куда всё и уйдут.
#соцсети #facebook #акции #рептилоиды',
'reply_markup' => bless( {
'rows' => [
bless( {
'buttons' => [
bless( {
'data' => 'send_reaction_0',
'text' => '???? 452'
}, 'Telegram::KeyboardButtonCallback' ),
bless( {
'text' => '???? 21',
'data' => 'send_reaction_1'
}, 'Telegram::KeyboardButtonCallback' )
]
}, 'Telegram::KeyboardButtonRow' )
]
}, 'Telegram::ReplyInlineMarkup' ),
'entities' => [
bless( {
'length' => 199,
'offset' => 0
}, 'Telegram::MessageEntityBold' ),
bless( {
'length' => 8,
'offset' => 919
}, 'Telegram::MessageEntityHashtag' ),
bless( {
'offset' => 928,
'length' => 9
}, 'Telegram::MessageEntityHashtag' ),
bless( {
'length' => 6,
'offset' => 938
}, 'Telegram::MessageEntityHashtag' ),
bless( {
'length' => 11,
'offset' => 945
}, 'Telegram::MessageEntityHashtag' )
],
'views' => 6964,
'flags' => 50368,
'id' => 854,
'post' => 1
}, 'Telegram::Message' ),
'pts_count' => 0
}, 'Telegram::UpdateEditChannelMessage' ),
bless( {
'message' => bless( {
'reply_markup' => bless( {
'rows' => [
bless( {
'buttons' => [
bless( {
'data' => 'send_reaction_0',
'text' => '???? 213'
}, 'Telegram::KeyboardButtonCallback' ),
bless( {
'data' => 'send_reaction_1',
'text' => '???? 8'
}, 'Telegram::KeyboardButtonCallback' )
]
}, 'Telegram::KeyboardButtonRow' )
]
}, 'Telegram::ReplyInlineMarkup' ),
'views' => 2940,
'entities' => [
bless( {
'length' => 609,
'offset' => 348
}, 'Telegram::MessageEntityItalic' )
],
'flags' => 50368,
'post' => 1,
'id' => 857,
'edit_date' => 1571907636,
'date' => 1571902479,
'to_id' => bless( {
'channel_id' => 1380524958
}, 'Telegram::PeerChannel' ),
'message' => 'Пост про 1С вызвал бурную полемику. Человек 10 (видимо, 1с-программистов) единодушно написали:
// [текст сообщения вырезан чтоб не нарушать правил Хабра о рекламе]
Я бы добавил, что блестящая у 1С дистрибуция, а маркетинг... ну, такое.'
}, 'Telegram::Message' ),
'pts_count' => 0,
'pts' => 158508
}, 'Telegram::UpdateEditChannelMessage' ),
bless( {
'pts' => 158508,
'pts_count' => 0,
'message' => bless( {
'message' => 'Здравствуйте, расскажите, пожалуйста, чем вредит экономике 1С?
// [текст сообщения вырезан чтоб не нарушать правил Хабра о рекламе]
#софт #it #экономика',
'edit_date' => 1571907650,
'date' => 1571893707,
'to_id' => bless( {
'channel_id' => 1380524958
}, 'Telegram::PeerChannel' ),
'flags' => 50368,
'post' => 1,
'id' => 856,
'reply_markup' => bless( {
'rows' => [
bless( {
'buttons' => [
bless( {
'data' => 'send_reaction_0',
'text' => '???? 360'
}, 'Telegram::KeyboardButtonCallback' ),
bless( {
'data' => 'send_reaction_1',
'text' => '???? 32'
}, 'Telegram::KeyboardButtonCallback' )
]
}, 'Telegram::KeyboardButtonRow' )
]
}, 'Telegram::ReplyInlineMarkup' ),
'views' => 4416,
'entities' => [
bless( {
'offset' => 0,
'length' => 64
}, 'Telegram::MessageEntityBold' ),
bless( {
'offset' => 1551,
'length' => 5
}, 'Telegram::MessageEntityHashtag' ),
bless( {
'length' => 3,
'offset' => 1557
}, 'Telegram::MessageEntityHashtag' ),
bless( {
'offset' => 1561,
'length' => 10
}, 'Telegram::MessageEntityHashtag' )
]
}, 'Telegram::Message' )
}, 'Telegram::UpdateEditChannelMessage' )
]
}, 'Telegram::Updates::ChannelDifference' )
}, 'MTProto::RpcResult' )
};
2019.10.24 12:00:51 $1 = {
'in' => bless( {
'update' => bless( {
'user_id' => 2507460,
'status' => bless( {
'was_online' => 1571907651
}, 'Telegram::UserStatusOffline' )
}, 'Telegram::UpdateUserStatus' ),
'date' => 1571907650
}, 'Telegram::UpdateShort' )
};
2019.10.24 12:05:46 $1 = {
'in' => bless( {
'chats' => [],
'date' => 1571907946,
'seq' => 0,
'updates' => [
bless( {
'max_id' => 141719,
'channel_id' => 1295963795
}, 'Telegram::UpdateReadChannelInbox' )
],
'users' => []
}, 'Telegram::Updates' )
};
2019.10.24 13:01:23 $1 = {
'in' => bless( {
'server_salt' => '4914425622822907323',
'unique_id' => '5297282355827493819',
'first_msg_id' => '6751307555044380692'
}, 'MTProto::NewSessionCreated' )
};
2019.10.24 13:24:21 $1 = {
'in' => bless( {
'chats' => [
bless( {
'username' => 'freebsd_ru',
'version' => 0,
'flags' => 5440,
'title' => 'freebsd_ru',
'min' => 1,
'photo' => bless( {
'photo_small' => bless( {
'local_id' => 328733,
'volume_id' => 235140688,
'dc_id' => 2,
'file_reference' => '
'secret' => '4426006807282303416'
}, 'Telegram::FileLocation' ),
'photo_big' => bless( {
'dc_id' => 2,
'file_reference' => '
'volume_id' => 235140688,
'local_id' => 328735,
'secret' => '71251192991540083'
}, 'Telegram::FileLocation' )
}, 'Telegram::ChatPhoto' ),
'date' => 1461248502,
'id' => 1038300508,
'democracy' => 1,
'megagroup' => 1
}, 'Telegram::Channel' )
],
'users' => [
bless( {
'last_name' => 'Panov',
'flags' => 1048646,
'min' => 1,
'id' => 82234609,
'status' => bless( {}, 'Telegram::UserStatusRecently' ),
'first_name' => 'Dima'
}, 'Telegram::User' )
],
'seq' => 0,
'date' => 1571912647,
'updates' => [
bless( {
'pts' => 137596,
'message' => bless( {
'flags' => 256,
'message' => 'Создать джейл с именем покороче ??',
'to_id' => bless( {
'channel_id' => 1038300508
}, 'Telegram::PeerChannel' ),
'id' => 119634,
'date' => 1571912647,
'from_id' => 82234609
}, 'Telegram::Message' ),
'pts_count' => 1
}, 'Telegram::UpdateNewChannelMessage' )
]
}, 'Telegram::Updates' )
};

Так, спеціально не під спойлером - якщо Ви не вчиталися, йдіть і зробіть це!

Oh, wai~~… на що це схоже? Щось дуже знайоме… може, це структура даних типового Web API у JSON, тільки хіба що ще до об'єктів класи причепили?

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

Давайте оцінимо, що нам дали TL+MTProto і які можливі альтернативи. Ну, HTTP, орієнтований на модель «запит-відповідь», підходить погано, але хоча б щось поверх TLS?

Компактна серіалізація. Бачачи цю структуру даних, схожу на JSON, згадується, що є його бінарні варіанти. Відзначимо MsgPack як недостатньо розширюваний, але є, наприклад, CBOR - між іншим, стандарт, описаний в RFC 7049. Примітний він тим, що у ньому визначено тегияк механізм розширення, і серед вже стандартизованих є:

  • 25 + 256 — заміна рядків, що повторюються, на посилання на номер рядка, такий дешевий метод компресії
  • 26 - серіалізований об'єкт Perl з ім'ям класу та аргументами конструктора
  • 27 - серіалізований мовонезалежний об'єкт з ім'ям типу та аргументами конструктора

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

cborlen=1039673 tl_len=1095092

Отже, висновок: є значно простіші формати, не схильні до проблеми збою синхронізації або невідомого ідентифікатора, з порівнянною ефективністю.

Швидке встановлення з'єднання. Мається на увазі нульовий 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-сторінки - все мультиплексується всередині. Тобто, цілком прийнятно, якщо нам не попався зовсім поганий перегін метро.

Щось забув? Пишіть у коментах.

Далі буде!

У другій частині цієї серії постів ми розглянемо не технічні, а організаційні моменти — підходи, ідеологія, інтерфейс, ставлення до користувачів і т.д. Опираючись, втім, на технічну інформацію, що була викладена тут.

У третій частині буде продовження аналізу технічної складової / досвіду розробки. Ви дізнаєтесь, зокрема:

  • продовження свистопляски з різноманіттям TL-типів
  • невідомі речі про канали та супергрупи
  • ніж dialogs гірше roster
  • про абсолютну vs відносну адресацію повідомлень
  • чим відрізняється photo від image
  • як емодзі заважають розмічати текст курсивом

та інші милиці! Stay tuned!

Джерело: habr.com

Додати коментар або відгук