Critique du protocole et des approches organisationnelles de Telegram. Partie 1, technique : expérience d'écriture d'un client à partir de zéro - TL, MT

Récemment, des articles sur la qualité de Telegram, la brillance et l'expérience des frères Durov dans la construction de systèmes de réseau, etc. ont commencé à apparaître plus souvent sur Habré. Dans le même temps, très peu de gens se sont vraiment immergés dans le dispositif technique - tout au plus, ils utilisent une API Bot basée sur JSON assez simple (et assez différente de MTProto), et acceptent généralement simplement sur la foi tous les éloges et relations publiques qui tournent autour du messager. Il y a presque un an et demi, mon collègue de l'ONG Eshelon Vasily (malheureusement, son compte sur Habré a été effacé avec le brouillon) a commencé à écrire son propre client Telegram en Perl à partir de zéro, et plus tard l'auteur de ces lignes l'a rejoint. Pourquoi Perl, diront immédiatement certains ? Parce que de tels projets existent déjà dans d'autres langues. En fait, ce n'est pas la question, il pourrait y avoir n'importe quelle autre langue où il n'y a pas bibliothèque prête à l'emploi, et par conséquent l'auteur doit aller jusqu'au bout à partir de zéro. De plus, la cryptographie est une question de confiance, mais vérifiez. Avec un produit destiné à la sécurité, vous ne pouvez pas simplement vous fier à une bibliothèque toute faite du fabricant et lui faire aveuglément confiance (cependant, c'est un sujet pour la deuxième partie). Pour le moment, la bibliothèque fonctionne plutôt bien au niveau « moyen » (permet de faire n'importe quelle requête API).

Cependant, il n'y aura pas beaucoup de cryptographie ou de mathématiques dans cette série d'articles. Mais il y aura bien d'autres détails techniques et béquilles architecturales (également utiles pour ceux qui n'écriront pas à partir de zéro, mais utiliseront la bibliothèque dans n'importe quelle langue). L'objectif principal était donc d'essayer d'implémenter le client à partir de zéro. selon la documentation officielle. Autrement dit, supposons que le code source des clients officiels soit fermé (encore une fois, dans la deuxième partie, nous aborderons plus en détail le sujet du fait que cela est vrai il donc), mais, comme autrefois, par exemple, il existe un standard comme RFC - est-il possible d'écrire un client selon la seule spécification, "sans regarder" le code source, qu'il soit officiel (Telegram Desktop, mobile), ou Téléthon non officiel ?

Table des matières:

La documentation... ça existe, non ? Est-ce vrai?..

Des fragments de notes pour cet article ont commencé à être collectés l'été dernier. Tout ce temps sur le site officiel https://core.telegram.org La documentation datait de la couche 23, c'est-à-dire coincé quelque part en 2014 (vous vous souvenez, il n’y avait même pas de chaînes à l’époque ?). Bien sûr, en théorie, cela aurait dû nous permettre d'implémenter un client doté de fonctionnalités à cette époque en 2014. Mais même dans cet état, la documentation était, premièrement, incomplète, et deuxièmement, elle se contredisait par endroits. Il y a un peu plus d'un mois, en septembre 2019, c'était accidentellement On a découvert qu'il y avait une mise à jour importante de la documentation sur le site, pour la toute récente couche 105, avec une note selon laquelle tout doit maintenant être relu. En effet, de nombreux articles ont été révisés, mais beaucoup sont restés inchangés. Par conséquent, lorsque vous lisez les critiques ci-dessous concernant la documentation, vous devez garder à l’esprit que certaines de ces choses ne sont plus pertinentes, mais que d’autres le sont encore. Après tout, 5 ans dans le monde moderne, ce n'est pas seulement une longue période, mais très beaucoup de. Depuis lors (surtout si l'on ne prend pas en compte les sites de géochat abandonnés et relancés depuis lors), le nombre de méthodes API dans le système est passé d'une centaine à plus de deux cent cinquante !

Par où commencer en tant que jeune auteur ?

Peu importe que vous écriviez à partir de zéro ou que vous utilisiez, par exemple, des bibliothèques prêtes à l'emploi comme Téléthon pour Python ou Madeline pour PHP, de toute façon, il vous faudra d'abord enregistrez votre candidature - obtenir les paramètres api_id и api_hash (ceux qui ont travaillé avec l'API VKontakte comprennent immédiatement) par lequel le serveur identifiera l'application. Ce devra faites-le pour des raisons juridiques, mais nous parlerons davantage des raisons pour lesquelles les auteurs de bibliothèques ne peuvent pas le publier dans la deuxième partie. Vous serez peut-être satisfait des valeurs des tests, même si elles sont très limitées - le fait est que vous pouvez désormais vous inscrire un seul application, alors ne vous précipitez pas tête baissée.

Maintenant, d'un point de vue technique, nous devrions être intéressés par le fait qu'après l'enregistrement, nous devrions recevoir des notifications de Telegram concernant les mises à jour de la documentation, du protocole, etc. Autrement dit, on pourrait supposer que le site avec les quais a simplement été abandonné et a continué à fonctionner spécifiquement avec ceux qui ont commencé à gagner des clients, car c'est plus facile. Mais non, rien de tel n’a été observé, aucune information n’est venue.

Et si vous écrivez à partir de zéro, l'utilisation des paramètres obtenus est en fait encore loin. Bien que https://core.telegram.org/ et en parle dans Getting Started tout d'abord, en fait, vous devrez d'abord mettre en œuvre Protocole MTProto - mais si tu croyais disposition selon le modèle OSI en fin de page pour une description générale du protocole, alors c'est complètement en vain.

En fait, avant et après MTProto, à plusieurs niveaux à la fois (comme le disent les réseauteurs étrangers travaillant dans le noyau du système d'exploitation, violation de couche), un sujet important, douloureux et terrible fera obstacle...

Sérialisation binaire : TL (Type Language) et son schéma, ses couches et bien d'autres mots effrayants

Ce sujet est en fait la clé des problèmes de Telegram. Et il y aura beaucoup de mots terribles si vous essayez d'y plonger.

Voici donc le schéma. Si ce mot vous vient à l'esprit, dites : Schéma JSON, Vous avez bien pensé. Le but est le même : un langage pour décrire un ensemble possible de données transmises. C’est là que s’arrêtent les similitudes. Si à partir de la page Protocole MTProto, ou depuis l'arborescence des sources du client officiel, nous allons essayer d'ouvrir un schéma, nous verrons quelque chose comme :

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;

Une personne qui voit cela pour la première fois ne sera intuitivement capable de reconnaître qu'une partie de ce qui est écrit - eh bien, ce sont apparemment des structures (même si où est le nom, à gauche ou à droite ?), il y a des champs dedans, après quoi un type suit après deux points... probablement. Ici, entre crochets, il y a probablement des modèles comme en C++ (en fait, pas tout à fait). Et que signifient tous les autres symboles, points d'interrogation, points d'exclamation, pourcentages, traits dièse (et évidemment ils signifient des choses différentes selon les endroits), parfois présents et parfois non, nombres hexadécimaux - et surtout, comment en sortir droit (qui ne sera pas rejeté par le serveur) flux d'octets ? Il faudra lire la documentation (oui, il y a des liens vers le schéma dans la version JSON à proximité - mais cela ne rend pas les choses plus claires).

Ouvrir la page Sérialisation des données binaires et plongez dans le monde magique des champignons et des mathématiques discrètes, un peu comme Matan en 4ème année. Alphabet, type, valeur, combinateur, combinateur fonctionnel, forme normale, type composite, type polymorphe... et ce n'est que la première page ! La suite vous attend Langue TL, qui, bien qu'il contienne déjà un exemple de demande et de réponse triviales, ne fournit pas du tout de réponse à des cas plus typiques, ce qui signifie que vous devrez parcourir un récit de mathématiques traduit du russe vers l'anglais sur huit autres intégrés des pages !

Les lecteurs familiers avec les langages fonctionnels et l'inférence automatique de types verront bien sûr le langage de description dans ce langage, même à partir de l'exemple, comme beaucoup plus familier, et pourront dire que ce n'est en fait pas mal en principe. Les objections à cela sont les suivantes :

  • oui, objectif ça a l'air bien, mais hélas, elle non accompli
  • La formation dans les universités russes varie même selon les spécialités informatiques - tout le monde n'a pas suivi le cours correspondant
  • Enfin, comme nous le verrons, en pratique c'est ne nécessite pas, puisque seul un sous-ensemble limité du TL décrit est utilisé

Comme dit LionNerd sur le canal #perl dans le réseau IRC FreeNode, qui a essayé d'implémenter une porte de Telegram vers Matrix (la traduction de la citation est inexacte de mémoire) :

C'est comme si quelqu'un avait été initié à la théorie des types pour la première fois, s'était enthousiasmé et avait commencé à essayer de jouer avec, sans vraiment se soucier de savoir si cela était nécessaire dans la pratique.

Voyez par vous-même, si le besoin de types nus (int, long, etc.) comme quelque chose d'élémentaire ne soulève pas de questions - en fin de compte, ils doivent être implémentés manuellement - par exemple, essayons d'en dériver vecteur. C'est en fait tableau, si vous appelez les choses résultantes par leurs noms propres.

Mais avant

Une brève description d'un sous-ensemble de la syntaxe TL pour ceux qui ne lisent pas la documentation officielle

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;

La définition commence toujours designer, puis éventuellement (en pratique - toujours) via le symbole # devrait CRC32 à partir de la chaîne de description normalisée de ce type. Vient ensuite une description des champs ; s’ils existent, le type peut être vide. Tout cela se termine par un signe égal, le nom du type auquel appartient ce constructeur - c'est-à-dire en fait le sous-type. Le gars à droite du signe égal est polymorphe - c'est-à-dire que plusieurs types spécifiques peuvent lui correspondre.

Si la définition apparaît après la ligne ---functions---, alors la syntaxe restera la même, mais le sens sera différent : le constructeur deviendra le nom de la fonction RPC, les champs deviendront des paramètres (enfin, c'est-à-dire qu'il restera exactement la même structure donnée, comme décrit ci-dessous , ce sera simplement la signification attribuée), et le « type polymorphe » - le type du résultat renvoyé. Certes, il restera toujours polymorphe - juste défini dans la section ---types---, mais ce constructeur ne sera « pas pris en compte ». Surcharger les types de fonctions appelées par leurs arguments, c'est à dire Pour une raison quelconque, plusieurs fonctions portant le même nom mais des signatures différentes, comme en C++, ne sont pas fournies dans la TL.

Pourquoi « constructeur » et « polymorphe » si ce n'est pas de la POO ? Eh bien, en fait, il sera plus facile pour quelqu'un d'y penser en termes de POO - un type polymorphe en tant que classe abstraite, et les constructeurs sont ses classes descendantes directes, et final dans la terminologie de plusieurs langues. En fait, bien sûr, ici seulement ressemblances avec de vraies méthodes de constructeur surchargées dans les langages de programmation OO. Puisqu'il ne s'agit ici que de structures de données, il n'y a pas de méthodes (bien que la description ultérieure des fonctions et des méthodes soit tout à fait capable de créer une confusion dans la tête quant à leur existence, mais c'est une autre affaire) - vous pouvez considérer un constructeur comme une valeur de lequel est en construction tapez lors de la lecture d’un flux d’octets.

Comment cela peut-il arriver? Le désérialiseur, qui lit toujours 4 octets, voit la valeur 0xcrc32 - et comprend ce qui va se passer ensuite field1 avec type int, c'est à dire. lit exactement 4 octets, sur celui-ci le champ sus-jacent avec le type PolymorType lire. Voit 0x2crc32 et comprend qu'il y a deux champs plus loin, d'abord long, ce qui signifie que nous lisons 8 octets. Et puis encore un type complexe, qui est désérialisé de la même manière. Par exemple, Type3 pourraient être déclarés dans le circuit dès que deux constructeurs, respectivement, alors ils doivent répondre soit 0x12abcd34, après quoi vous devez lire 4 octets supplémentaires intOu 0x6789cdef, après quoi il n'y aura plus rien. Pour tout le reste, vous devez lever une exception. Quoi qu'il en soit, après cela, nous revenons à la lecture de 4 octets int marges field_c в constructorTwo et avec cela nous finissons de lire notre PolymorType.

Enfin, si tu te fais prendre 0xdeadcrc pour constructorThree, alors tout devient plus compliqué. Notre premier domaine est bit_flags_of_what_really_present avec type # - en fait, c'est juste un alias pour le type nat, signifiant « nombre naturel ». En fait, unsigned int est d'ailleurs le seul cas où des nombres non signés apparaissent dans des circuits réels. Voici donc une construction avec un point d'interrogation, ce qui signifie que ce champ - il ne sera présent sur le fil que si le bit correspondant est défini dans le champ en question (à peu près comme un opérateur ternaire). Supposons donc que ce bit ait été activé, ce qui signifie que nous devons ensuite lire un champ comme Type, qui dans notre exemple a 2 constructeurs. L'un est vide (constitué uniquement de l'identifiant), l'autre possède un champ ids avec type ids:Vector<long>.

Vous pourriez penser que les modèles et les génériques sont parmi les pros de Java. Mais non. Presque. Ce seulement cas d'utilisation de supports angulaires dans des circuits réels, et il est utilisé UNIQUEMENT pour Vector. Dans un flux d'octets, ce seront 4 octets CRC32 pour le type Vector lui-même, toujours le même, puis 4 octets - le nombre d'éléments du tableau, puis ces éléments eux-mêmes.

Ajoutez à cela le fait que la sérialisation se produit toujours en mots de 4 octets, tous les types en sont des multiples - les types intégrés sont également décrits bytes и string avec une sérialisation manuelle de la longueur et cet alignement par 4 - eh bien, cela semble normal et même relativement efficace ? Bien que TL soit considéré comme une sérialisation binaire efficace, au diable, avec l'expansion d'à peu près tout, même les valeurs booléennes et les chaînes à un seul caractère jusqu'à 4 octets, JSON sera-t-il toujours beaucoup plus épais ? Écoutez, même les champs inutiles peuvent être ignorés avec des indicateurs binaires, tout est plutôt bon et même extensible pour le futur, alors pourquoi ne pas ajouter de nouveaux champs facultatifs au constructeur plus tard ?..

Mais non, si vous ne lisez pas ma brève description, mais la documentation complète, et réfléchissez à la mise en œuvre. Premièrement, le CRC32 du constructeur est calculé en fonction de la ligne normalisée de la description textuelle du schéma (supprimer les espaces supplémentaires, etc.) - donc si un nouveau champ est ajouté, la ligne de description du type changera, et donc son CRC32 et , par conséquent, la sérialisation. Et que ferait l’ancien client s’il recevait un champ avec de nouveaux drapeaux définis et qu’il ne savait pas quoi en faire ensuite ?

Deuxièmement, rappelons-nous CRC32, qui est utilisé ici essentiellement comme fonctions de hachage pour déterminer de manière unique quel type est (dé)sérialisé. Nous sommes ici confrontés au problème des collisions - et non, la probabilité n'est pas d'une sur 232, mais bien plus grande. Qui se souvient que CRC32 est conçu pour détecter (et corriger) les erreurs dans le canal de communication et améliore par conséquent ces propriétés au détriment des autres ? Par exemple, il ne se soucie pas de réorganiser les octets : si vous calculez CRC32 à partir de deux lignes, dans la seconde vous échangez les 4 premiers octets avec les 4 octets suivants - ce sera pareil. Lorsque notre entrée est constituée de chaînes de texte de l'alphabet latin (et d'un peu de ponctuation) et que ces noms ne sont pas particulièrement aléatoires, la probabilité d'un tel réarrangement augmente considérablement.

Au fait, qui a vérifié ce qu’il y avait ? vraiment CRC32 ? L'un des premiers codes sources (avant même Waltman) avait une fonction de hachage qui multipliait chaque caractère par le nombre 239, si apprécié de ces gens, ha ha !

Finalement, d'accord, nous avons réalisé que les constructeurs avec un type de champ Vector<int> и Vector<PolymorType> aura un CRC32 différent. Qu’en est-il des performances en ligne ? Et d'un point de vue théorique, est-ce que cela fait partie du type? Disons que nous transmettons un tableau de dix mille nombres, avec Vector<int> tout est clair, la longueur et 40000 XNUMX octets supplémentaires. Et si ceci Vector<Type2>, qui se compose d'un seul champ int et il est seul dans le type - devons-nous répéter 10000xabcdef0 34 4 fois puis XNUMX octets int, ou le langage est capable de l'INDEPENDRE pour nous du constructeur fixedVec et au lieu de 80000 40000 octets, en transférer à nouveau seulement XNUMX XNUMX ?

Ce n'est pas du tout une question théorique vaine - imaginez que vous recevez une liste d'utilisateurs d'un groupe, chacun ayant un identifiant, un prénom, un nom de famille - la différence dans la quantité de données transférées via une connexion mobile peut être significative. C’est précisément l’efficacité de la sérialisation Telegram qui nous est annoncée.

Alors ...

Vector, qui n'a jamais été publié

Si vous essayez de parcourir les pages de description des combinateurs, etc., vous verrez qu'un vecteur (et même une matrice) essaie formellement d'être généré via des tuples de plusieurs feuilles. Mais à la fin, ils oublient, la dernière étape est sautée et une définition d'un vecteur est simplement donnée, qui n'est pas encore liée à un type. Quel est le problème? En langues programmation, en particulier les fonctions fonctionnelles, il est assez typique de décrire la structure de manière récursive - le compilateur avec son évaluation paresseuse comprendra et fera tout lui-même. En langue sérialisation des données ce qu'il faut, c'est l'EFFICACITÉ : il suffit de décrire simplement liste, c'est à dire. structure de deux éléments - le premier est un élément de données, le second est la même structure elle-même ou un espace vide pour la queue (pack (cons) en Lisp). Mais cela nécessitera évidemment chaque L'élément dépense 4 octets supplémentaires (CRC32 dans le cas en TL) pour décrire son type. Un tableau peut également être facilement décrit taille fixe, mais dans le cas d'un tableau de longueur inconnue à l'avance, on s'interrompt.

Par conséquent, comme TL ne permet pas de sortir un vecteur, il a dû être ajouté sur le côté. En fin de compte, la documentation dit :

La sérialisation utilise toujours le même constructeur « vecteur » (const 0x1cb5c415 = crc32 (« vecteur t:Type # [ t ] = Vecteur t ») qui ne dépend pas de la valeur spécifique de la variable de type t.

La valeur du paramètre facultatif t n'intervient pas dans la sérialisation puisqu'elle est dérivée du type de résultat (toujours connu avant la désérialisation).

Regarde de plus près: vector {t:Type} # [ t ] = Vector t - mais nulle part Cette définition elle-même ne dit pas que le premier nombre doit être égal à la longueur du vecteur ! Et ça ne vient de nulle part. C’est une donnée qui doit être gardée à l’esprit et mise en œuvre de vos propres mains. Ailleurs, la documentation mentionne même honnêtement que le type n'est pas réel :

Le pseudotype polymorphe Vector t est un « type » dont la valeur est une séquence de valeurs de tout type t, en boîte ou nue.

... mais ne se concentre pas là-dessus. Lorsque vous, fatigué de parcourir les étirements des mathématiques (peut-être même que vous connaissez grâce à un cours universitaire), décidez d'abandonner et de regarder comment les utiliser dans la pratique, l'impression qui vous reste dans la tête est que c'est sérieux. Mathématiques à la base, elles ont clairement été inventées par Cool People (deux mathématiciens - lauréat de l'ACM), et pas n'importe qui. L'objectif - se montrer - a été atteint.

Au fait, à propos du numéro. Rappelons que # c'est un synonyme nat, entier naturel:

Il existe des expressions de type (expression de type) et les expressions numériques (expression-nat). Cependant, ils sont définis de la même manière.

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

mais dans la grammaire, ils sont décrits de la même manière, c'est-à-dire Cette différence doit encore une fois être mémorisée et mise en œuvre à la main.

Eh bien, oui, les types de modèles (vector<int>, vector<User>) ont un identifiant commun (#1cb5c415), c'est à dire. si vous savez que l'appel est annoncé comme

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

alors vous n'attendez plus seulement un vecteur, mais un vecteur d'utilisateurs. Plus précisément, devrait attendez - dans le code réel, chaque élément, s'il n'est pas un type nu, aura un constructeur, et dans le bon sens, lors de l'implémentation, il serait nécessaire de vérifier - mais nous avons été envoyés exactement dans chaque élément de ce vecteur ce genre? Et s'il s'agissait d'une sorte de PHP, dans lequel un tableau peut contenir différents types dans différents éléments ?

À ce stade, vous commencez à réfléchir : un tel TL est-il nécessaire ? Peut-être que pour le chariot il serait possible d'utiliser un sérialiseur humain, le même protobuf qui existait déjà à l'époque ? C'était la théorie, regardons la pratique.

Implémentations TL existantes dans le code

TL est né dans les entrailles de VKontakte avant même les événements célèbres de la vente des actions de Durov et (sûrement), avant même le début du développement de Telegram. Et en open source code source de la première implémentation vous pouvez trouver beaucoup de béquilles amusantes. Et le langage lui-même y a été implémenté de manière plus complète qu'il ne l'est aujourd'hui dans Telegram. Par exemple, les hachages ne sont pas du tout utilisés dans le schéma (c'est-à-dire un pseudotype intégré (comme un vecteur) avec un comportement déviant). Ou

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

mais considérons, pour être complet, retracer, pour ainsi dire, l'évolution du Géant de la Pensée.

#define ZHUKOV_BYTES_HACK

#ifdef ZHUKOV_BYTES_HACK

/* dirty hack for Zhukov request */

Ou celle-ci, magnifique :

    static const char *reserved_words_polymorhic[] = {

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

      };

Ce fragment concerne des modèles tels que :

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

Il s'agit de la définition d'un type de modèle de hashmap en tant que vecteur de paires int - Type. En C++, cela ressemblerait à ceci :

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

alors voici alpha - mot-clé! Mais ce n'est qu'en C++ qu'on peut écrire T, mais il faut écrire alpha, beta... Mais pas plus de 8 paramètres, c'est là que s'arrête le fantasme. Il semble qu'il était une fois à Saint-Pétersbourg des dialogues comme celui-ci :

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

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

Mais il s’agissait de la première implémentation publiée de TL « en général ». Passons à l'examen des implémentations dans les clients Telegram eux-mêmes.

Un mot à Vasily :

Vasily, [09.10.18 17:07] Surtout, le cul est chaud parce qu'ils ont créé un tas d'abstractions, puis ont enfoncé un boulon dessus et ont couvert le générateur de code avec des béquilles
En conséquence, d'abord depuis dock pilot.jpg
Puis à partir du code dzhekichan.webp

Bien sûr, de la part des personnes familiarisées avec les algorithmes et les mathématiques, nous pouvons nous attendre à ce qu'elles aient lu Aho, Ullmann et soient familières avec les outils qui sont devenus de facto un standard dans l'industrie au fil des décennies pour écrire leurs compilateurs DSL, n'est-ce pas ?

Auteur télégramme-cli est Vitaly Valtman, comme le montre l'apparition du format TLO en dehors de ses limites (cli), membre de l'équipe - maintenant une bibliothèque pour l'analyse TL a été allouée séparément, quelle est son impression analyseur TL? ..

16.12 04:18 Vasily : Je pense que quelqu'un n'a pas maîtrisé lex+yacc
16.12 04:18 Vasily : Je ne peux pas l'expliquer autrement
16.12 04:18 Vasily : eh bien, ou ils ont été payés pour le nombre de lignes en VK
16.12 04:19 Vasily : plus de 3 XNUMX lignes, etc.<censored> au lieu d'un analyseur

Peut-être une exception ? Voyons comment fait Voici le client OFFICIEL - 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);

Plus de 1100 lignes en Python, quelques expressions régulières + des cas particuliers comme un vecteur, qui, bien sûr, est déclaré dans le schéma comme il se doit selon la syntaxe TL, mais ils se sont appuyés sur cette syntaxe pour l'analyser... La question se pose : pourquoi tout cela était-il un miracle ?иC'est plus complexe si personne ne veut l'analyser selon la documentation de toute façon ?!

Au fait... Vous vous souvenez que nous avons parlé de la vérification CRC32 ? Ainsi, dans le générateur de code Telegram Desktop, il existe une liste d'exceptions pour les types dans lesquels le CRC32 calculé ne correspond pas avec celui indiqué sur le schéma !

Vasily, [18.12/22 49:XNUMX] et ici je réfléchirais à la question de savoir si un tel TL est nécessaire
si je voulais jouer avec des implémentations alternatives, je commencerais à insérer des sauts de ligne, la moitié des analyseurs s'arrêteront sur des définitions multilignes
mais aussi tdesktop

Rappelez-vous le point concernant le one-liner, nous y reviendrons un peu plus tard.

D'accord, telegram-cli n'est pas officiel, Telegram Desktop est officiel, mais qu'en est-il des autres ? Qui sait ?.. Dans le code du client Android, il n'y avait aucun analyseur de schéma (ce qui soulève des questions sur l'open source, mais c'est pour la deuxième partie), mais il y avait plusieurs autres morceaux de code amusants, mais plus à leur sujet dans le sous-section ci-dessous.

Quelles autres questions la sérialisation soulève-t-elle en pratique ? Par exemple, ils ont bien sûr fait beaucoup de choses avec des champs de bits et des champs conditionnels :

Vassili : flags.0? true
signifie que le champ est présent et est égal à vrai si l'indicateur est défini

Vassili : flags.1? int
signifie que le champ est présent et doit être désérialisé

Vasily : Connard, ne t'inquiète pas de ce que tu fais !
Vasily : Il y a une mention quelque part dans la doc que true est un type de longueur nulle, mais il est impossible d'assembler quoi que ce soit à partir de leur doc.
Vasily : Dans les implémentations open source, ce n'est pas le cas non plus, mais il y a un tas de béquilles et de supports

Et le Téléthon ? En regardant le sujet de MTProto, un exemple - dans la documentation, il y a des éléments comme celui-ci, mais le signe % il est décrit uniquement comme « correspondant à un type nu donné », c'est-à-dire dans les exemples ci-dessous, il y a soit une erreur, soit quelque chose de non documenté :

Vasily, [22.06.18 18:38] Au même endroit :

msg_container#73f1f8dc messages:vector message = MessageContainer;

Dans un autre :

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

Et ce sont deux grandes différences, dans la vraie vie, une sorte de vecteur nu apparaît

Je n'ai pas vu de définition de vecteur simple et je n'en ai pas trouvé

L'analyse est écrite à la main en téléthon

Dans son diagramme, la définition est commentée msg_container

Encore une fois, la question demeure sur le %. Ce n’est pas décrit.

Vadim Gontcharov, [22.06.18 19:22] et dans tdesktop ?

Vasily, [22.06.18 19:23] Mais leur analyseur TL sur les moteurs ordinaires ne mangera probablement pas cela non plus

// parsed manually

TL est une belle abstraction, personne ne la met complètement en œuvre

Et % n'est pas dans leur version du schéma

Mais ici, la documentation se contredit, donc je ne sais pas

Cela a été trouvé dans la grammaire, ils ont peut-être simplement oublié de décrire la sémantique

Tu as vu le document sur TL, tu ne peux pas le comprendre sans un demi-litre

"Eh bien, disons", dira un autre lecteur, "vous critiquez quelque chose, alors montrez-moi comment cela doit être fait."

Vasily répond : « Quant à l'analyseur, j'aime les choses comme

    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 ) ); }
            ;

d'une manière ou d'une autre, je l'aime mieux que

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;
}

ou

        # 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)

c'est TOUT le lexer :

    ---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];

ceux. plus simple, c’est le moins qu’on puisse dire.

En général, l'analyseur et le générateur de code pour le sous-ensemble réellement utilisé de TL tiennent dans environ 100 lignes de grammaire et environ 300 lignes du générateur (en comptant toutes les lignes). print(le code généré par ), y compris des informations de type pour l'introspection dans chaque classe. Chaque type polymorphe se transforme en une classe de base abstraite vide, et les constructeurs en héritent et disposent de méthodes de sérialisation et de désérialisation.

Manque de types dans le langage de caractères

Une frappe forte est une bonne chose, non ? Non, ce n'est pas un holivar (même si je préfère les langages dynamiques), mais un postulat dans le cadre de TL. Sur cette base, le langage devrait nous fournir toutes sortes de contrôles. Bon, d'accord, peut-être pas lui-même, mais la mise en œuvre, mais il devrait au moins les décrire. Et quel genre d’opportunités souhaitons-nous ?

Tout d’abord, les contraintes. Ici, nous voyons dans la documentation pour le téléchargement de fichiers :

Le contenu binaire du fichier est ensuite divisé en parties. Toutes les pièces doivent avoir la même taille ( taille_part ) et les conditions suivantes doivent être remplies :

  • part_size % 1024 = 0 (divisible par 1 Ko)
  • 524288 % part_size = 0 (512 Ko doivent être divisibles également par part_size)

La dernière partie ne doit pas nécessairement satisfaire à ces conditions, à condition que sa taille soit inférieure à part_size.

Chaque partie doit avoir un numéro de séquence, fichier_part, avec une valeur allant de 0 à 2,999 XNUMX.

Une fois le fichier partitionné, vous devez choisir une méthode pour l'enregistrer sur le serveur. Utiliser télécharger.saveBigFilePart si la taille totale du fichier est supérieure à 10 Mo et télécharger.saveFilePart pour les fichiers plus petits.
[…] l'une des erreurs de saisie de données suivantes peut être renvoyée :

  • FILE_PARTS_INVALID — Nombre de pièces non valide. La valeur n'est pas comprise entre 1..3000

Y a-t-il quelque chose de tout cela dans le diagramme ? Est-ce que cela peut être exprimé d'une manière ou d'une autre en utilisant TL ? Non. Mais excusez-moi, même le Turbo Pascal de grand-père était capable de décrire les types spécifiés gammes. Et il savait encore une chose, maintenant mieux connue sous le nom de enum - un type constitué d'une énumération d'un (petit) nombre fixe de valeurs. Dans des langages comme C-numeric, notez que jusqu'à présent nous n'avons parlé que de types chiffres. Mais il existe aussi des tableaux, des chaînes... par exemple, ce serait bien de décrire que cette chaîne ne peut contenir qu'un numéro de téléphone, non ?

Rien de tout cela n'est dans le TL. Mais il existe, par exemple, dans JSON Schema. Et si quelqu'un d'autre peut argumenter sur la divisibilité de 512 Ko, que cela doit encore être vérifié dans le code, alors assurez-vous que le client a simplement ne pouvait pas envoyer un numéro hors de portée 1..3000 (et l'erreur correspondante n'aurait pas pu se produire) cela aurait été possible, non ?..

À propos, à propos des erreurs et des valeurs de retour. Même ceux qui ont travaillé avec TL brouillent les yeux - nous n'avons pas immédiatement réalisé que chacun une fonction dans TL peut en fait renvoyer non seulement le type de retour décrit, mais également une erreur. Mais cela ne peut en aucun cas être déduit en utilisant le TL lui-même. Bien sûr, c'est déjà clair et rien n'est nécessaire en pratique (même si en fait le RPC peut être fait de différentes manières, nous y reviendrons plus tard) - mais qu'en est-il de la pureté des concepts des mathématiques des types abstraits du monde céleste ?.. J'ai récupéré le remorqueur - alors faites-le correspondre.

Et enfin, qu’en est-il de la lisibilité ? Bon là, en général, j'aimerais la description l'avez-vous bien dans le schéma (dans le schéma JSON, encore une fois, c'est le cas), mais si vous êtes déjà fatigué avec cela, alors qu'en est-il du côté pratique - au moins en regardant trivialement les différences pendant les mises à jour ? Voyez par vous-même sur exemples réels:

-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;

ou

-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;

Cela dépend de chacun, mais GitHub, par exemple, refuse de mettre en évidence les changements dans des lignes aussi longues. Le jeu "trouver 10 différences", et ce que le cerveau voit immédiatement, c'est que le début et la fin dans les deux exemples sont les mêmes, il faut lire fastidieusement quelque part au milieu... À mon avis, ce n'est pas seulement en théorie, mais purement visuellement sale et bâclé.

À propos, à propos de la pureté de la théorie. Pourquoi avons-nous besoin de champs de bits ? Ne semble-t-il pas qu'ils sentir mauvais du point de vue de la théorie des types ? L'explication peut être vue dans les versions antérieures du diagramme. Au début, oui, c’était comme ça, pour chaque éternuement, un nouveau type était créé. Ces rudiments existent encore sous cette forme, par exemple :

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;

Mais imaginez maintenant, si vous avez 5 champs optionnels dans votre structure, alors vous aurez besoin de 32 types pour toutes les options possibles. Explosion combinatoire. Ainsi, la pureté cristalline de la théorie TL s’est une fois de plus brisée face à la dure réalité de la sérialisation.

De plus, dans certains endroits, ces gars-là violent eux-mêmes leur propre typologie. Par exemple, dans MTProto (chapitre suivant), la réponse peut être compressée avec Gzip, tout va bien - sauf que les couches et le circuit sont violés. Encore une fois, ce n’est pas RpcResult lui-même qui a été récolté, mais son contenu. Eh bien, pourquoi faire ça ?... J'ai dû couper une béquille pour que la compression fonctionne n'importe où.

Ou un autre exemple, nous avons découvert une fois une erreur : elle a été envoyée InputPeerUser au lieu de InputUser. Ou vice versa. Mais ça a marché ! Autrement dit, le serveur ne se souciait pas du type. Comment se peut-il? La réponse peut nous être donnée par des fragments de code de 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);

En d’autres termes, c’est là que se fait la sérialisation MANUELLEMENT, pas de code généré ! Peut-être que le serveur est implémenté de la même manière ?.. En principe, cela fonctionnera si cela est fait une fois, mais comment peut-il être pris en charge plus tard lors des mises à jour ? Est-ce pour cela que ce système a été inventé ? Et ici, nous passons à la question suivante.

Gestion des versions. Couches

La raison pour laquelle les versions schématiques sont appelées couches ne peut être spéculée que sur la base de l'historique des schémas publiés. Apparemment, au début, les auteurs pensaient que les choses de base pouvaient être faites en utilisant le schéma inchangé, et seulement lorsque cela était nécessaire, pour des demandes spécifiques, ils indiquaient qu'elles étaient faites en utilisant une version différente. En principe, c'est même une bonne idée - et le nouveau sera, pour ainsi dire, « mixte », superposé à l'ancien. Mais voyons comment cela a été fait. C'est vrai, je n'ai pas pu le regarder dès le début - c'est drôle, mais le schéma de la couche de base n'existe tout simplement pas. Couches commençant par 2. La documentation nous parle d'une fonctionnalité spéciale TL :

Si un client prend en charge la couche 2, le constructeur suivant doit être utilisé :

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

En pratique, cela signifie qu'avant chaque appel API, un int avec la valeur 0x289dd1f6 doit être ajouté avant le numéro de méthode.

Cela semble normal. Mais que s’est-il passé ensuite ? Puis est apparu

invokeWithLayer3#b7475268 query:!X = X;

Alors, quelle est la prochaine étape ? Comme vous pouvez le deviner,

invokeWithLayer4#dea0d430 query:!X = X;

Drôle? Non, il est trop tôt pour rire, pense au fait que chaque une demande d'une autre couche doit être enveloppée dans un type si spécial - si vous les avez toutes différentes, comment pouvez-vous les distinguer autrement ? Et ajouter seulement 4 octets devant est une méthode assez efficace. Donc,

invokeWithLayer5#417a57ae query:!X = X;

Mais il est évident qu’au bout d’un moment, cela deviendra une sorte de bacchanale. Et la solution est venue :

Mise à jour : à partir de la couche 9, méthodes d'assistance invokeWithLayerN peut être utilisé uniquement avec initConnection

Hourra! Après 9 versions, nous sommes finalement arrivés à ce qui se faisait dans les protocoles Internet dans les années 80 : se mettre d'accord sur la version une fois au début de la connexion !

Alors quelle est la prochaine étape ?..

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

Mais maintenant, tu peux encore rire. Ce n'est qu'après 9 couches supplémentaires qu'un constructeur universel avec un numéro de version a finalement été ajouté, qui ne doit être appelé qu'une seule fois au début de la connexion, et la signification des couches semblait avoir disparu, maintenant c'est juste une version conditionnelle, comme partout ailleurs. Problème résolu.

Exactement?..

Vasily, [16.07.18 14:01] Même vendredi, je pensais :
Le téléserveur envoie des événements sans demande. Les requêtes doivent être encapsulées dans InvokeWithLayer. Le serveur n'encapsule pas les mises à jour ; il n'existe aucune structure pour encapsuler les réponses et les mises à jour.

Ceux. le client ne peut pas spécifier la couche dans laquelle il souhaite des mises à jour

Vadim Goncharov, [16.07.18 14:02] InvokeWithLayer n'est-il pas en principe une béquille ?

Vasily, [16.07.18 14:02] C'est le seul moyen

Vadim Gontcharov, [16.07.18 14:02] ce qui signifie essentiellement se mettre d'accord sur la couche au début de la séance

À propos, il s'ensuit que la rétrogradation du client n'est pas fournie

Mises à jour, c'est-à-dire taper Updates dans le schéma, c'est ce que le serveur envoie au client non pas en réponse à une requête API, mais indépendamment lorsqu'un événement se produit. Il s'agit d'un sujet complexe qui sera abordé dans un autre article, mais pour l'instant il est important de savoir que le serveur enregistre les mises à jour même lorsque le client est hors ligne.

Ainsi, si vous refusez d'emballer chaque package pour indiquer sa version, cela conduit logiquement aux problèmes possibles suivants :

  • le serveur envoie des mises à jour au client avant même que celui-ci n'ait informé la version qu'il prend en charge
  • que dois-je faire après la mise à niveau du client ?
  • qui garantiesque l'opinion du serveur sur le numéro de couche ne changera pas au cours du processus ?

Pensez-vous qu'il s'agit d'une spéculation purement théorique, et qu'en pratique cela ne peut pas se produire, car le serveur est écrit correctement (au moins, il est bien testé) ? Ha! Peu importe comment c'est !

C'est exactement ce que nous avons rencontré en août. Le 14 août, il y a eu des messages indiquant que quelque chose était en cours de mise à jour sur les serveurs Telegram... puis dans les logs :

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.

puis plusieurs mégaoctets de traces de pile (enfin, en même temps, la journalisation a été corrigée). Après tout, si quelque chose n’est pas reconnu dans votre TL, c’est binaire par signature, plus loin sur toute la ligne TOUS va, le décodage deviendra impossible. Que faire dans une telle situation ?

Eh bien, la première chose qui vient à l’esprit de chacun est de se déconnecter et de réessayer. N'a pas aidé. Nous recherchons CRC32 sur Google - il s'est avéré qu'il s'agissait d'objets du schéma 73, bien que nous ayons travaillé sur 82. Nous examinons attentivement les journaux - il y a des identifiants de deux schémas différents !

Peut-être que le problème vient uniquement de notre client non officiel ? Non, nous lançons Telegram Desktop 1.2.17 (version fournie dans un certain nombre de distributions Linux), il écrit dans le journal des exceptions : MTP Unexpected type id #b5223b0f lu dans MTPMessageMedia…

Critique du protocole et des approches organisationnelles de Telegram. Partie 1, technique : expérience d'écriture d'un client à partir de zéro - TL, MT

Google a montré qu'un problème similaire était déjà arrivé à l'un des clients non officiels, mais les numéros de version et, par conséquent, les hypothèses étaient différents...

Alors, que devrions-nous faire? Vasily et moi nous sommes séparés : il a essayé de mettre à jour le circuit vers 91, j'ai décidé d'attendre quelques jours et d'essayer sur 73. Les deux méthodes ont fonctionné, mais comme elles sont empiriques, on ne comprend pas le nombre de versions supérieures ou inférieures dont vous avez besoin pour sauter, ou combien de temps vous devez attendre.

Plus tard, j'ai pu reproduire la situation : nous lançons le client, l'éteignons, recompilons le circuit vers une autre couche, redémarrons, récupérons le problème, revenons au précédent - oups, aucune commutation de circuit et le client redémarre pendant un certain temps. quelques minutes aideront. Vous recevrez un mélange de structures de données provenant de différentes couches.

Explication? Comme vous pouvez le deviner à partir de divers symptômes indirects, le serveur est constitué de nombreux processus de différents types sur différentes machines. Très probablement, le serveur chargé de la « mise en mémoire tampon » a mis dans la file d'attente ce que ses supérieurs lui ont donné, et ils l'ont donné selon le schéma en vigueur au moment de la génération. Et jusqu'à ce que cette file d'attente soit «pourrie», on ne pouvait rien y faire.

Peut-être... mais c'est une béquille terrible ?!.. Non, avant de penser à des idées folles, regardons le code des clients officiels. Dans la version Android, nous ne trouvons aucun analyseur TL, mais nous trouvons un fichier volumineux (GitHub refuse de le retoucher) avec (dé)sérialisation. Voici les extraits de code :

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;

ou

    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");

Hmm... ça a l'air sauvage. Mais, probablement, il s’agit de code généré, alors d’accord ?.. Mais il prend certainement en charge toutes les versions ! Certes, on ne sait pas pourquoi tout est mélangé, les discussions secrètes et toutes sortes de _old7 d'une manière ou d'une autre, cela ne ressemble pas à la génération de machines... Mais j'ai surtout été époustouflé par

TL_message_layer104
TL_message_layer104_2
TL_message_layer104_3

Les gars, vous ne pouvez même pas décider ce qu'il y a à l'intérieur d'une couche ?! Bon, d'accord, disons que "deux" ont été sortis avec une erreur, bon, ça arrive, mais TROIS ?.. Tout de suite, encore le même rake ? De quel genre de pornographie s'agit-il, désolé ?

Soit dit en passant, dans le code source de Telegram Desktop, une chose similaire se produit - si tel est le cas, plusieurs commits consécutifs dans le schéma ne modifient pas son numéro de couche, mais corrigent quelque chose. Dans des conditions où il n'existe pas de source officielle de données pour le système, d'où peuvent-elles être obtenues, à l'exception du code source du client officiel ? Et si vous partez de là, vous ne pouvez pas être sûr que le schéma est complètement correct tant que vous n'avez pas testé toutes les méthodes.

Comment cela peut-il même être testé ? J'espère que les fans de tests unitaires, fonctionnels et autres partageront les commentaires.

Bon, regardons un autre morceau de code :

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;

Ce commentaire « créé manuellement » suggère que seule une partie de ce fichier a été écrite manuellement (pouvez-vous imaginer tout le cauchemar de la maintenance ?), et le reste a été généré automatiquement. Cependant, une autre question se pose : celle de savoir si les sources sont disponibles pas complètement (à la manière des blobs GPL dans le noyau Linux), mais c'est déjà un sujet pour la deuxième partie.

Mais ça suffit. Passons au protocole sur lequel s'exécute toute cette sérialisation.

MTProto

Alors, ouvrons description générale и description détaillée du protocole et la première chose sur laquelle nous butons est la terminologie. Et avec une abondance de tout. En général, cela semble être une fonctionnalité propriétaire de Telegram - appeler les choses différemment à différents endroits, ou différentes choses avec un seul mot, ou vice versa (par exemple, dans une API de haut niveau, si vous voyez un pack d'autocollants, ce n'est pas le cas). ce que vous pensiez).

Par exemple, « message » et « session » signifient ici quelque chose de différent de celui dans l'interface client Telegram habituelle. Eh bien, tout est clair avec le message, il pourrait être interprété en termes de POO, ou simplement appelé le mot "paquet" - c'est un niveau de transport bas, il n'y a pas les mêmes messages que dans l'interface, il y a beaucoup de messages de service . Mais la séance... mais avant tout.

couche de transport

La première chose est le transport. Ils nous parleront de 5 options :

  • TCP
  • Prise Web
  • Websocket sur HTTPS
  • HTTP
  • HTTPS

Vasily, [15.06.18 15:04] Il existe également un transport UDP, mais il n'est pas documenté

Et TCP en trois variantes

Le premier est similaire à UDP sur TCP, chaque paquet comprend un numéro de séquence et un crc
Pourquoi lire des documents sur un chariot est-il si pénible ?

Eh bien, ça y est maintenant TCP déjà en 4 variantes:

  • Abrégé
  • Intermédiaire
  • Intermédiaire rembourré
  • Full

Eh bien, ok, intermédiaire rembourré pour MTProxy, cela a été ajouté plus tard en raison d'événements bien connus. Mais pourquoi deux versions supplémentaires (trois au total) quand on pourrait se contenter d'une seule ? Tous les quatre diffèrent essentiellement uniquement par la manière de définir la longueur et la charge utile du MTProto principal, qui sera discuté plus en détail :

  • en abrégé, c'est 1 ou 4 octets, mais pas 0xef, alors le corps
  • en Intermédiaire, il s'agit de 4 octets de longueur et d'un champ, et la première fois que le client doit envoyer 0xeeeeeeee pour indiquer qu'il est intermédiaire
  • en totalité le plus addictif, du point de vue d'un réseauteur : longueur, numéro de séquence, et PAS CELUI qui est principalement MTProto, corps, CRC32. Oui, tout cela est au-dessus de TCP. Ce qui nous fournit un transport fiable sous la forme d’un flux d’octets séquentiel ; aucune séquence n’est nécessaire, en particulier des sommes de contrôle. D'accord, maintenant quelqu'un me objectera que TCP a une somme de contrôle de 16 bits, donc une corruption des données se produit. Génial, mais nous avons en fait un protocole cryptographique avec des hachages de plus de 16 octets, toutes ces erreurs - et même plus - seront détectées par une inadéquation SHA à un niveau supérieur. Il n'y a AUCUN intérêt dans CRC32 en plus de cela.

Comparons l'Abrégé, dans lequel un octet de longueur est possible, avec l'Intermédiaire, qui justifie « Au cas où un alignement des données sur 4 octets serait nécessaire », ce qui est tout à fait absurde. Quoi, on pense que les programmeurs Telegram sont si incompétents qu'ils ne peuvent pas lire les données d'un socket dans un tampon aligné ? Encore faut-il le faire, car la lecture peut vous renvoyer n'importe quel nombre d'octets (et il existe aussi des serveurs proxy, par exemple...). Ou d'un autre côté, pourquoi bloquer Abridged si nous aurons toujours un remplissage important au-dessus de 16 octets - économisez 3 octets parfois ?

On a l'impression que Nikolai Durov aime vraiment réinventer les roues, y compris les protocoles réseau, sans réelle nécessité pratique.

Autres options de transport, incl. Web et MTProxy, nous n'envisagerons pas maintenant, peut-être dans un autre article, s'il y a une demande. A propos de ce même MTProxy, rappelons seulement maintenant que peu après sa sortie en 2018, les fournisseurs ont vite appris à le bloquer, destiné à contourner le blocagePar Taille du paquet! Et aussi le fait que le serveur MTProxy écrit (encore une fois par Waltman) en C était trop lié aux spécificités de Linux, même si cela n'était pas du tout requis (Phil Kulin le confirmera), et qu'un serveur similaire en Go ou en Node.js le ferait. tenir en moins d’une centaine de lignes.

Mais nous tirerons des conclusions sur les connaissances techniques de ces personnes à la fin de la section, après avoir examiné d'autres questions. Pour l'instant, passons à la couche 5 OSI, session - sur laquelle ils ont placé la session MTProto.

Clés, messages, sessions, Diffie-Hellman

Ils ne l'ont pas placé là tout à fait correctement... Une session n'est pas la même session qui est visible dans l'interface sous Sessions actives. Mais dans l'ordre.

Critique du protocole et des approches organisationnelles de Telegram. Partie 1, technique : expérience d'écriture d'un client à partir de zéro - TL, MT

Nous avons donc reçu une chaîne d'octets de longueur connue de la couche de transport. Il s'agit soit d'un message crypté, soit d'un texte en clair - si nous en sommes encore au stade de l'accord clé et que nous le faisons réellement. De quel ensemble de concepts appelés « clés » parlons-nous ? Clarifions ce problème pour l'équipe Telegram elle-même (je m'excuse d'avoir traduit ma propre documentation de l'anglais avec un cerveau fatigué à 4 heures du matin, il était plus facile de laisser certaines phrases telles quelles) :

Il existe deux entités appelées Session - un dans l'interface utilisateur des clients officiels sous « sessions en cours », où chaque session correspond à un appareil/OS entier.
Le second - Session MTProto, qui contient le numéro de séquence du message (dans un sens de bas niveau), et qui peut durer entre différentes connexions TCP. Plusieurs sessions MTProto peuvent être installées en même temps, par exemple pour accélérer le téléchargement de fichiers.

Entre ces deux brainstorming il y a une notion autorisation. Dans le cas dégénéré, on peut dire que Session d'interface utilisateur est le même que autorisation, mais hélas, tout est compliqué. Regardons:

  • L'utilisateur du nouvel appareil génère d'abord clé d'authentification et le limite au compte, par exemple via SMS - c'est pourquoi autorisation
  • C'est arrivé à l'intérieur du premier Session MTProto, qui a session_id à l'intérieur de vous-même.
  • A cette étape, la combinaison autorisation и session_id pourrait être appelé instance - ce mot apparaît dans la documentation et le code de certains clients
  • Ensuite, le client peut ouvrir certains Sessions MTProto sous le même clé d'authentification - au même DC.
  • Puis, un jour, le client devra demander le dossier à un autre DC - et pour ce DC un nouveau sera généré clé d'authentification !
  • Pour informer le système que ce n'est pas un nouvel utilisateur qui s'inscrit, mais le même autorisation (Session d'interface utilisateur), le client utilise des appels API auth.exportAuthorization à la maison DC auth.importAuthorization dans le nouveau DC.
  • Tout est pareil, plusieurs peuvent être ouverts Sessions MTProto (chacun avec son propre session_id) à ce nouveau DC, sous son clé d'authentification.
  • Enfin, le client peut souhaiter un Perfect Forward Secrecy. Chaque clé d'authentification était permanent clé - par DC - et le client peut appeler auth.bindTempAuthKey pour utilisation temporaire clé d'authentification - et encore, un seul temp_auth_key par DC, commun à tous Sessions MTProto à ce DC.

On notera que sel (et les futurs sels) en font également partie clé d'authentification ceux. partagé entre tous Sessions MTProto au même DC.

Que signifie « entre différentes connexions TCP » ? Donc cela signifie quelque chose comme cookie d'autorisation sur un site Web - il persiste (survit) à de nombreuses connexions TCP à un serveur donné, mais un jour il tourne mal. Contrairement à HTTP, dans MTProto, les messages au sein d'une session sont numérotés et confirmés de manière séquentielle ; s'ils entrent dans le tunnel, la connexion est interrompue - après avoir établi une nouvelle connexion, le serveur enverra gentiment dans cette session tout ce qu'il n'a pas livré lors de la précédente. Connexion TCP.

Cependant, les informations ci-dessus sont résumées après plusieurs mois d’enquête. En attendant, implémentons-nous notre client à partir de zéro ? - revenons au début.

Alors générons auth_key sur Versions Diffie-Hellman de Telegram. Essayons de comprendre la documentation...

Vasily, [19.06.18 20:05] data_with_hash := SHA1(data) + data + (tous octets aléatoires) ; de telle sorte que la longueur soit égale à 255 octets ;
chiffré_data := RSA(data_with_hash, server_public_key); un nombre de 255 octets (big endian) est élevé à la puissance requise sur le module requis, et le résultat est stocké sous la forme d'un nombre de 256 octets.

Ils ont de la drogue DH

Ne ressemble pas à la DH d'une personne en bonne santé
Il n'y a pas deux clés publiques dans dx

Eh bien, à la fin, cela a été réglé, mais il reste un résidu - la preuve du travail effectué par le client montre qu'il a pu factoriser le numéro. Type de protection contre les attaques DoS. Et la clé RSA n’est utilisée qu’une seule fois dans un sens, essentiellement pour le chiffrement new_nonce. Mais même si cette opération apparemment simple réussira, à quoi devrez-vous faire face ?

Vasily, [20.06.18/00/26 XNUMX:XNUMX] Je n'ai pas encore eu accès à la demande appid

J'ai envoyé cette demande à DH

Et, sur le quai de transport, il est indiqué qu'il peut répondre avec 4 octets d'un code d'erreur. C'est tout

Eh bien, il m'a dit -404, et alors ?

Alors je lui ai dit : « Attrape tes conneries chiffrées avec une clé de serveur avec une empreinte comme ça, je veux DH », et il a répondu par un stupide 404.

Que penseriez-vous de cette réponse du serveur ? Ce qu'il faut faire? Il n’y a personne à qui demander (mais nous en reparlerons dans la deuxième partie).

Ici tout l'intérêt se fait sur le quai

Je n'ai rien d'autre à faire, je rêvais juste de convertir des chiffres d'avant en arrière

Deux nombres 32 bits. Je les ai emballés comme tout le monde

Mais non, ces deux-là doivent d'abord être ajoutés à la ligne comme BE

Vadim Gontcharov, [20.06.18 15:49] et à cause de cela 404 ?

Vasily, [20.06.18 15:49] OUI !

Vadim Gontcharov, [20.06.18 15:50] donc je ne comprends pas ce qu'il peut "n'a pas trouvé"

Vassili, [20.06.18 15:50] sur

Je n'ai pas trouvé une telle décomposition en facteurs premiers%)

Nous n'avons même pas géré le rapport d'erreurs

Vasily, [20.06.18 20:18] Oh, il y a aussi MD5. Déjà trois hachages différents

L'empreinte digitale de la clé est calculée comme suit :

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

SHA1 et sha2

Alors disons-le auth_key nous avons reçu une taille de 2048 bits en utilisant Diffie-Hellman. Et après? Nous découvrons ensuite que les 1024 bits inférieurs de cette clé ne sont utilisés d’aucune façon… mais réfléchissons-y pour l’instant. A cette étape, nous avons un secret partagé avec le serveur. Un analogue de la session TLS a été établi, ce qui est une procédure très coûteuse. Mais le serveur ne sait toujours rien de qui nous sommes ! Pas encore, en fait. autorisation. Ceux. si vous pensiez en termes de « mot de passe de connexion », comme vous l'avez fait autrefois dans ICQ, ou du moins de « clé de connexion », comme dans SSH (par exemple, sur certains gitlab/github). Nous en avons reçu un anonyme. Que se passe-t-il si le serveur nous dit « ces numéros de téléphone sont gérés par un autre DC » ? Ou encore « votre numéro de téléphone est banni » ? Le mieux que nous puissions faire est de conserver la clé dans l’espoir qu’elle sera utile et qu’elle ne pourrira pas d’ici là.

D'ailleurs, nous l'avons « reçu » avec des réserves. Par exemple, faisons-nous confiance au serveur ? Et si c'était faux ? Des contrôles cryptographiques seraient nécessaires :

Vasily, [21.06.18 17:53] Ils proposent aux clients mobiles de vérifier la primalité d'un numéro à 2 kbits%)

Mais ce n'est pas clair du tout, nafeijoa

Vasily, [21.06.18 18:02] Le document ne dit pas quoi faire s'il s'avère que ce n'est pas simple

Pas dit. Voyons ce que fait le client Android officiel dans ce cas ? UN c'est ce que (et oui, tout le dossier est intéressant) - comme on dit, je vais juste laisser ceci ici :

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

Non, bien sûr, il est toujours là certains Il existe des tests pour la primalité d'un nombre, mais personnellement je n'ai plus suffisamment de connaissances en mathématiques.

OK, nous avons le passe-partout. Pour vous connecter, c'est-à-dire Pour envoyer des requêtes, vous devez effectuer un cryptage supplémentaire à l'aide d'AES.

La clé du message est définie comme les 128 bits du milieu du SHA256 du corps du message (y compris la session, l'ID du message, etc.), y compris les octets de remplissage, précédés de 32 octets extraits de la clé d'autorisation.

Vasily, [22.06.18 14:08] Moyenne, salope, bits

Reçu auth_key. Tous. Au-delà d’eux… ce n’est pas clair dans le document. N'hésitez pas à étudier le code open source.

Notez que MTProto 2.0 nécessite de 12 à 1024 16 octets de remplissage, toujours sous la condition que la longueur du message résultant soit divisible par XNUMX octets.

Alors, combien de rembourrage devriez-vous ajouter ?

Et oui, il y a aussi un 404 en cas d'erreur

Si quelqu'un étudiait attentivement le diagramme et le texte de la documentation, il remarquait qu'il n'y avait pas de MAC là-bas. Et cet AES est utilisé dans un certain mode IGE qui n'est utilisé nulle part ailleurs. Bien sûr, ils écrivent à ce sujet dans leur FAQ... Ici, par exemple, la clé du message elle-même est également le hachage SHA des données déchiffrées, utilisé pour vérifier l'intégrité - et en cas de non-concordance, la documentation pour une raison quelconque recommande de les ignorer en silence (mais qu'en est-il de la sécurité, et s'ils nous brisent ?).

Je ne suis pas cryptographe, peut-être qu'il n'y a rien de mal à ce mode dans ce cas d'un point de vue théorique. Mais je peux clairement citer un problème pratique, en utilisant Telegram Desktop comme exemple. Il crypte le cache local (tous ces D877F783D5D3EF8C) de la même manière que les messages dans MTProto (uniquement dans ce cas la version 1.0), c'est-à-dire d'abord la clé du message, puis les données elles-mêmes (et quelque part à part le principal gros auth_key 256 octets, sans quoi msg_key inutile). Ainsi, le problème devient perceptible sur les fichiers volumineux. À savoir, vous devez conserver deux copies des données – cryptées et décryptées. Et s'il y a des mégaoctets, ou du streaming vidéo, par exemple ?.. Les schémas classiques avec MAC après le texte chiffré vous permettent de lire le flux et de le transmettre immédiatement. Mais avec MTProto vous devrez premier cryptez ou déchiffrez l'intégralité du message, puis transférez-le ensuite sur le réseau ou sur le disque. Par conséquent, dans les dernières versions de Telegram Desktop dans le cache de user_data Un autre format est également utilisé - avec AES en mode CTR.

Vasily, [21.06.18 01:27] Oh, j'ai découvert ce qu'est IGE : IGE était la première tentative de « mode de cryptage d'authentification », à l'origine pour Kerberos. Il s'agissait d'une tentative infructueuse (elle n'offre pas de protection d'intégrité) et a dû être supprimée. Ce fut le début d’une recherche de 20 ans pour un mode de cryptage d’authentification qui fonctionne, qui a récemment abouti à des modes comme OCB et GCM.

Et maintenant les arguments du côté du chariot :

L'équipe derrière Telegram, dirigée par Nikolai Durov, est composée de six champions de l'ACM, dont la moitié sont titulaires d'un doctorat en mathématiques. Il leur a fallu environ deux ans pour déployer la version actuelle de MTProto.

Ca c'est drôle. Deux ans au niveau inférieur

Ou tu pourrais juste prendre ça

D'accord, disons que nous avons effectué le cryptage et d'autres nuances. Est-il enfin possible d'envoyer des requêtes sérialisées en TL et de désérialiser les réponses ? Alors quoi et comment envoyer ? Ici, disons, la méthode initConnexion, c'est peut-être ça ?

Vasily, [25.06.18 18:46] Initialise la connexion et enregistre les informations sur l'appareil et l'application de l'utilisateur.

Il accepte app_id, device_model, system_version, app_version et lang_code.

Et quelques requêtes

Documentation comme toujours. N'hésitez pas à étudier l'open source

Si tout était à peu près clair avec EnsureWithLayer, alors qu'est-ce qui ne va pas ici ? Il s'avère que, disons que nous avons - le client avait déjà quelque chose à demander au serveur - il y a une requête que nous voulions envoyer :

Vasily, [25.06.18 19:13] À en juger par le code, le premier appel est enveloppé dans cette merde, et la merde elle-même est enveloppée dans Invocatewithlayer

Pourquoi initConnection ne pourrait-il pas être un appel distinct, mais doit-il être un wrapper ? Oui, il s'est avéré que cela doit être fait à chaque fois au début de chaque session, et non une fois, comme pour la clé principale. Mais! Il ne peut pas être appelé par un utilisateur non autorisé ! Nous avons maintenant atteint le stade où cela est applicable celui-ci page de documentation - et cela nous dit que...

Seule une petite partie des méthodes API est disponible pour les utilisateurs non autorisés :

  • auth.sendCode
  • auth.resendCode
  • compte.getPassword
  • auth.checkPassword
  • auth.checkTéléphone
  • auth.signUp
  • auth.signIn
  • auth.importAuthorization
  • aide.getConfig
  • help.getNearestDc
  • help.getAppUpdate
  • help.getCdnConfig
  • langpack.getLangPack
  • langpack.getStrings
  • langpack.getDifference
  • langpack.getLanguages
  • langpack.getLanguage

Le tout premier d'entre eux, auth.sendCode, et il y a cette première demande chérie dans laquelle nous envoyons api_id et api_hash, et après quoi nous recevons un SMS avec un code. Et si nous nous trouvons dans le mauvais DC (les numéros de téléphone dans ce pays sont desservis par un autre, par exemple), alors nous recevrons une erreur avec le numéro du DC souhaité. Pour savoir à quelle adresse IP par numéro DC vous devez vous connecter, aidez-nous help.getConfig. À une certaine époque, il n'y avait que 5 inscriptions, mais après les célèbres événements de 2018, ce nombre a considérablement augmenté.

Rappelons maintenant que nous sommes arrivés à cette étape sur le serveur de manière anonyme. N'est-il pas trop coûteux d'obtenir simplement une adresse IP ? Pourquoi ne pas faire cela, ainsi que d'autres opérations, dans la partie non chiffrée de MTProto ? J’entends l’objection : « comment s’assurer que ce n’est pas RKN qui répondra avec de fausses adresses ? Nous rappelons à cela qu'en général, les clients officiels Les clés RSA sont intégrées, c'est à dire. peux-tu juste signe cette information. En fait, cela est déjà fait pour les informations sur le contournement du blocage que les clients reçoivent via d'autres canaux (logiquement, cela ne peut pas être fait dans MTProto lui-même ; vous devez également savoir où vous connecter).

D'ACCORD. A ce stade d'autorisation du client, nous ne sommes pas encore autorisés et n'avons pas enregistré notre demande. Nous voulons juste voir pour l'instant ce que le serveur répond aux méthodes disponibles pour un utilisateur non autorisé. Et ici…

Vassili, [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;

Dans le schéma, le premier vient en second

Dans le schéma tdesktop, la troisième valeur est

Oui, depuis, bien sûr, la documentation a été mise à jour. Même si cela pourrait bientôt redevenir inutile. Comment un développeur débutant devrait-il le savoir ? Peut-être que si vous enregistrez votre candidature, ils vous informeront ? Vasily l'a fait, mais hélas, ils ne lui ont rien envoyé (encore une fois, nous en reparlerons dans la deuxième partie).

...Vous avez remarqué que nous sommes déjà passés d'une manière ou d'une autre à l'API, c'est-à-dire au niveau suivant et vous avez manqué quelque chose dans le sujet MTProto ? Pas de surprise:

Vasily, [28.06.18 02:04] Mm, ils fouillent dans certains algorithmes sur e2e

Mtproto définit des algorithmes de chiffrement et des clés pour les deux domaines, ainsi qu'une petite structure wrapper

Mais ils mélangent constamment différents niveaux de la pile, il n'est donc pas toujours clair où se termine mtproto et où commence le niveau suivant.

Comment se mélangent-ils ? Eh bien, voici la même clé temporaire pour PFS, par exemple (d'ailleurs, Telegram Desktop ne peut pas le faire). Il est exécuté par une requête API auth.bindTempAuthKey, c'est à dire. du niveau supérieur. Mais en même temps, cela interfère avec le cryptage au niveau inférieur - après cela, par exemple, vous devez recommencer initConnection etc., ce n'est pas juste demande normale. Ce qui est également spécial, c'est que vous ne pouvez avoir qu'UNE seule clé temporaire par DC, bien que le champ auth_key_id dans chaque message vous permet de changer la clé au moins à chaque message, et que le serveur a le droit "d'oublier" la clé temporaire à tout moment - la documentation ne dit pas quoi faire dans ce cas... eh bien, pourquoi pourrait-il n'as-tu pas plusieurs clés, comme pour un jeu de futurs sels, et ?..

Il y a quelques autres choses à noter concernant le thème MTProto.

Messages, msg_id, msg_seqno, confirmations, pings dans la mauvaise direction et autres particularités

Pourquoi avez-vous besoin de les connaître ? Parce qu'ils « fuient » à un niveau supérieur et que vous devez en être conscient lorsque vous travaillez avec l'API. Supposons que msg_key ne nous intéresse pas ; le niveau inférieur a tout déchiffré pour nous. Mais à l’intérieur des données décryptées, nous avons les champs suivants (également la longueur des données, donc nous savons où se trouve le remplissage, mais ce n’est pas important) :

  • sel - int64
  • session_id - int64
  • message_id — int64
  • seq_no - int32

Rappelons qu'il n'existe qu'un seul sel pour l'ensemble du DC. Pourquoi savoir pour elle ? Pas seulement parce qu'il y a une demande get_future_salts, qui vous indique quels intervalles seront valides, mais aussi parce que si votre sel est « pourri », alors le message (demande) sera tout simplement perdu. Le serveur signalera bien entendu le nouveau sel en émettant new_session_created - mais avec l'ancien, vous devrez le renvoyer d'une manière ou d'une autre, par exemple. Et ce problème affecte l’architecture de l’application.

Le serveur est autorisé à abandonner complètement les sessions et à répondre de cette manière pour de nombreuses raisons. Au fait, qu’est-ce qu’une session MTProto côté client ? Ce sont deux nombres session_id и seq_no messages au cours de cette session. Eh bien, et la connexion TCP sous-jacente, bien sûr. Disons que notre client ne sait toujours pas faire beaucoup de choses, il s’est déconnecté et reconnecté. Si cela s'est produit rapidement - l'ancienne session s'est poursuivie dans la nouvelle connexion TCP, augmentez seq_no plus loin. Si cela prend beaucoup de temps, le serveur pourrait le supprimer, car de son côté il y a aussi une file d'attente, comme nous l'avons découvert.

Que devrait-il être seq_no? Oh, c'est une question délicate. Essayez de comprendre honnêtement ce que cela voulait dire :

Message lié au contenu

Un message nécessitant un accusé de réception explicite. Ceux-ci incluent tous les messages de l'utilisateur et de nombreux messages de service, pratiquement tous à l'exception des conteneurs et des accusés de réception.

Numéro de séquence du message (msg_seqno)

Un nombre de 32 bits égal à deux fois le nombre de messages « liés au contenu » (ceux nécessitant un accusé de réception, et notamment ceux qui ne sont pas des conteneurs) créés par l'expéditeur avant ce message et ensuite incrémenté de un si le message en cours est un message lié au contenu. Un conteneur est toujours généré après l'intégralité de son contenu ; par conséquent, son numéro de séquence est supérieur ou égal aux numéros de séquence des messages qu'il contient.

De quel genre de cirque s'agit-il avec un incrément de 1, puis un autre de 2 ?.. Je soupçonne qu'au départ, ils voulaient dire « le bit le moins significatif pour ACK, le reste est un nombre », mais le résultat n'est pas tout à fait le même - en particulier, il sort, peut être envoyé certains confirmations ayant le même seq_no! Comment? Eh bien, par exemple, le serveur nous envoie quelque chose, l'envoie, et nous restons nous-mêmes silencieux, ne répondant que par des messages de service confirmant la réception de ses messages. Dans ce cas, nos confirmations sortantes auront le même numéro de sortant. Si vous êtes familier avec TCP et pensez que cela semble quelque peu sauvage, mais cela ne semble pas très sauvage, car dans TCP seq_no ne change pas, mais la confirmation va à seq_no de l'autre côté, je m'empresserai de vous contrarier. Les confirmations sont fournies dans MTProto PAS sur seq_no, comme dans TCP, mais par msg_id !

Qu'est-ce que c'est msg_id, le plus important de ces domaines ? Un identifiant de message unique, comme son nom l'indique. Il est défini comme un nombre de 64 bits, dont les bits les plus bas ont à nouveau la magie « serveur-pas-serveur », et le reste est un horodatage Unix, y compris la partie fractionnaire, décalée de 32 bits vers la gauche. Ceux. l'horodatage en soi (et les messages avec des heures trop différentes seront rejetés par le serveur). Il s'ensuit qu'il s'agit en général d'un identifiant global pour le client. Compte tenu de cela - rappelons-nous session_id - nous avons la garantie : En aucun cas un message destiné à une session ne peut être envoyé dans une autre session.. Autrement dit, il s'avère qu'il existe déjà trois niveau - session, numéro de session, identifiant du message. Pourquoi une telle complication, ce mystère est très grand.

ainsi, msg_id nécessaire pour...

RPC : requêtes, réponses, erreurs. Confirmation.

Comme vous l'avez peut-être remarqué, il n'y a aucun type ou fonction spéciale « faire une requête RPC » nulle part dans le diagramme, bien qu'il y ait des réponses. Après tout, nous avons des messages liés au contenu ! C'est, tout le message pourrait être une demande ! Ou ne pas être. Après tout, chaque il est msg_id. Mais il y a des réponses :

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

C'est ici qu'il est indiqué à quel message il s'agit d'une réponse. Par conséquent, au niveau supérieur de l'API, vous devrez vous rappeler quel était le numéro de votre demande - je pense qu'il n'est pas nécessaire d'expliquer que le travail est asynchrone, et qu'il peut y avoir plusieurs demandes en cours en même temps, dont les réponses peuvent être renvoyées dans n'importe quel ordre ? En principe, à partir de cela et des messages d'erreur comme aucun travailleur, l'architecture derrière cela peut être retracée : le serveur qui maintient une connexion TCP avec vous est un équilibreur frontal, il transmet les requêtes aux backends et les récupère via message_id. Il semble que tout ici soit clair, logique et bon.

Oui ?.. Et si vous y réfléchissiez ? Après tout, la réponse RPC elle-même possède également un champ msg_id! Devons-nous crier au serveur « vous ne répondez pas à ma réponse ! » ? Et oui, qu'y avait-il à propos des confirmations ? À propos de la page messages sur les messages nous dit ce que c'est

msgs_ack#62d6b459 msg_ids:Vector long = MsgsAck;

et cela doit être fait par chaque partie. Mais pas toujours! Si vous avez reçu un RpcResult, il sert lui-même de confirmation. Autrement dit, le serveur peut répondre à votre demande avec MsgsAck, comme « Je l'ai reçu ». RpcResult peut répondre immédiatement. Cela pourrait être les deux.

Et oui, encore faut-il répondre à la réponse ! Confirmation. Dans le cas contraire, le serveur le considérera comme non livrable et vous le renverra. Même après reconnexion. Mais ici, bien sûr, se pose la question des délais d’attente. Regardons-les un peu plus tard.

En attendant, examinons les erreurs possibles d'exécution des requêtes.

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

Oh, s'exclamera quelqu'un, voici un format plus humain - il y a une ligne ! Prenez votre temps. Ici liste des erreurs, mais bien sûr pas complet. De là, nous apprenons que le code est quelque chose comme Des erreurs HTTP (enfin, bien sûr, la sémantique des réponses n'est pas respectée, à certains endroits elles sont réparties aléatoirement parmi les codes), et la ligne ressemble à CAPITAL_LETTERS_AND_NUMBERS. Par exemple, PHONE_NUMBER_OCCUPIED ou FILE_PART_Х_MISSING. Eh bien, vous aurez toujours besoin de cette ligne analyser. Par exemple, FLOOD_WAIT_3600 cela signifie que vous devrez attendre une heure, et PHONE_MIGRATE_5, qu'un numéro de téléphone avec ce préfixe doit être enregistré au 5ème DC. Nous avons un langage de type, n'est-ce pas ? Nous n'avons pas besoin d'un argument d'une chaîne, les arguments normaux feront l'affaire, d'accord.

Encore une fois, ce n'est pas sur la page des messages de service, mais, comme c'est déjà habituel avec ce projet, les informations peuvent être trouvées sur une autre page de documentation. Ou jeter des soupçons. Tout d'abord, regardez, saisie/violation de calque - RpcError peut être imbriqué dans RpcResult. Pourquoi pas dehors ? Qu'est-ce qu'on n'a pas pris en compte ?. Dès lors, où est la garantie que RpcError NE PEUT PAS être intégré dans RpcResult, mais être directement ou imbriqué dans un autre type ?.. Et si ce n'est pas possible, pourquoi n'est-il pas au niveau supérieur, c'est-à-dire ça manque req_msg_id ? ..

Mais continuons avec les messages de service. Le client peut penser que le serveur réfléchit depuis longtemps et faire cette merveilleuse demande :

rpc_drop_answer#58e4a740 req_msg_id:long = RpcDropAnswer;

Il y a trois réponses possibles à cette question, qui recoupent à nouveau le mécanisme de confirmation ; essayer de comprendre ce qu'elles devraient être (et quelle est la liste générale des types qui ne nécessitent pas de confirmation) est laissé au lecteur comme devoir (remarque : les informations contenues dans le code source de Telegram Desktop n'est pas complet).

Toxicomanie : statuts des messages

En général, de nombreux endroits dans TL, MTProto et Telegram en général laissent un sentiment d'entêtement, mais par politesse, tact et autres compétences douces Nous avons poliment gardé le silence à ce sujet et censuré les obscénités des dialogues. Cependant, cet endroitОla majeure partie de la page concerne messages sur les messages C’est choquant même pour moi, qui travaille depuis longtemps avec des protocoles réseau et qui ai vu des vélos plus ou moins tordus.

Cela commence inoffensivement, avec des confirmations. Ensuite, ils nous parlent

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;

Eh bien, tous ceux qui commencent à travailler avec MTProto devront y faire face ; dans le cycle « corrigé - recompilé - lancé », obtenir des erreurs numériques ou du sel qui a réussi à se détériorer lors des modifications est une chose courante. Cependant, il y a deux points ici :

  1. Cela signifie que le message original est perdu. Nous devons créer des files d’attente, nous y reviendrons plus tard.
  2. Quels sont ces étranges numéros d’erreur ? 16, 17, 18, 19, 20, 32, 33, 34, 35, 48, 64... où sont les autres chiffres, Tommy ?

La documentation indique :

L'intention est que les valeurs error_code soient regroupées (error_code >> 4) : par exemple, les codes 0x40 — 0x4f correspondent à des erreurs de décomposition du conteneur.

mais, premièrement, un déplacement dans l’autre sens, et deuxièmement, peu importe, où sont les autres codes ? Dans la tête de l'auteur ?.. Cependant, ce sont des bagatelles.

La dépendance commence dans les messages sur les statuts des messages et les copies des messages :

  • Demande d'informations sur l'état du message
    Si l'une des parties n'a pas reçu d'information sur l'état de ses messages sortants depuis un certain temps, elle peut la demander explicitement à l'autre partie :
    msgs_state_req#da69fb52 msg_ids:Vector long = MsgsStateReq;
  • Message d'information concernant l'état des messages
    msgs_state_info#04deb57d req_msg_id:long info:string = MsgsStateInfo;
    Ici, info est une chaîne qui contient exactement un octet d'état du message pour chaque message de la liste msg_ids entrant :

    • 1 = on ne sait rien du message (msg_id trop faible, l'autre partie l'a peut-être oublié)
    • 2 = message non reçu (msg_id se situe dans la plage des identifiants stockés ; cependant, l'autre partie n'a certainement pas reçu un message de ce type)
    • 3 = message non reçu (msg_id trop élevé ; cependant, l'autre partie ne l'a certainement pas encore reçu)
    • 4 = message reçu (à noter que cette réponse est aussi en même temps un accusé de réception)
    • +8 = message déjà reconnu
    • +16 = message ne nécessitant pas d'accusé de réception
    • +32 = requête RPC contenue dans le message en cours de traitement ou traitement déjà terminé
    • +64 = réponse liée au contenu au message déjà généré
    • +128 = l'autre partie sait pertinemment que le message est déjà reçu
      Cette réponse ne nécessite pas d'accusé de réception. Il s'agit d'un accusé de réception du msgs_state_req pertinent, en soi.
      Notez que s'il s'avère soudainement que l'autre partie ne dispose pas d'un message qui semble lui avoir été envoyé, le message peut simplement être renvoyé. Même si l'autre partie devait recevoir deux copies du message en même temps, la copie sera ignorée. (Si trop de temps s'est écoulé et que le msg_id d'origine n'est plus valide, le message doit être enveloppé dans msg_copy).
  • Communication volontaire de l'état des messages
    Chaque partie peut volontairement informer l'autre partie de l'état des messages transmis par l'autre partie.
    msgs_all_info#8cc0d131 msg_ids:Vector long info:string = MsgsAllInfo
  • Communication volontaire étendue du statut d'un 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;
  • Demande explicite de renvoyer des messages
    msg_resend_req#7d861a08 msg_ids:Vector long = MsgResendReq;
    La partie distante répond immédiatement en renvoyant les messages demandés […]
  • Demande explicite de renvoyer les réponses
    msg_resend_ans_req#8610baeb msg_ids:Vector long = MsgResendReq;
    Le correspondant répond immédiatement en renvoyant réponses aux messages demandés […]
  • Copies des messages
    Dans certaines situations, un ancien message avec un msg_id qui n'est plus valide doit être renvoyé. Ensuite, il est enveloppé dans un conteneur de copie :
    msg_copy#e06046b2 orig_message:Message = MessageCopy;
    Une fois reçu, le message est traité comme si le wrapper n'était pas là. Cependant, s'il est certain que le message orig_message.msg_id a été reçu, alors le nouveau message n'est pas traité (tandis qu'en même temps, lui et orig_message.msg_id sont reconnus). La valeur de orig_message.msg_id doit être inférieure à celle de msg_id du conteneur.

Gardons même le silence sur ce que msgs_state_info encore une fois, les oreilles du TL inachevé dépassent (nous avions besoin d'un vecteur d'octets, et dans les deux bits inférieurs il y avait une énumération, et dans les deux bits supérieurs il y avait des drapeaux). Le problème est différent. Est-ce que quelqu'un comprend pourquoi tout cela est en pratique ? chez un vrai client nécessaire?.. Avec difficulté, mais on peut imaginer un certain avantage si une personne est engagée dans le débogage et en mode interactif - demande au serveur quoi et comment. Mais ici les demandes sont décrites aller-retour.

Il s'ensuit que chaque partie doit non seulement crypter et envoyer des messages, mais également stocker des données sur elle-même, sur ses réponses, pendant une durée indéterminée. La documentation ne décrit ni les délais ni l'applicabilité pratique de ces fonctionnalités. pas du tout. Le plus étonnant, c'est qu'ils sont effectivement utilisés dans le code des clients officiels ! Apparemment, on leur a dit quelque chose qui ne figurait pas dans la documentation publique. Comprendre à partir du code pourquoi, n'est plus aussi simple que dans le cas de TL - ce n'est pas une partie (relativement) logiquement isolée, mais une partie liée à l'architecture de l'application, c'est-à-dire il faudra beaucoup plus de temps pour comprendre le code de l’application.

Pings et horaires. Files d'attente.

De tout, si l'on se souvient des suppositions sur l'architecture du serveur (répartition des requêtes sur les backends), il s'ensuit une chose plutôt triste - malgré toutes les garanties de livraison en TCP (soit les données sont livrées, soit vous serez informé de l'écart, mais les données seront livrées avant que le problème ne se produise), que les confirmations dans MTProto lui-même - aucune garantie. Le serveur peut facilement perdre ou rejeter votre message, et on ne peut rien y faire, il suffit d'utiliser différents types de béquilles.

Et tout d'abord, les files d'attente de messages. Eh bien, avec une chose, tout était évident dès le début : un message non confirmé doit être stocké et renvoyé. Et après quelle heure ? Et le bouffon le connaît. Peut-être que ces messages de service dépendants résolvent d'une manière ou d'une autre ce problème avec des béquilles, disons, dans Telegram Desktop, il y a environ 4 files d'attente qui leur correspondent (peut-être plus, comme déjà mentionné, pour cela, vous devez vous plonger plus sérieusement dans son code et son architecture ; en même temps temps, nous savons qu'il ne peut pas être pris comme échantillon ; un certain nombre de types du schéma MTProto n'y sont pas utilisés).

Pourquoi cela arrive-t-il? Les programmeurs du serveur n'ont probablement pas été en mesure d'assurer la fiabilité au sein du cluster, ni même la mise en mémoire tampon sur l'équilibreur frontal, et ont transféré ce problème au client. Par désespoir, Vasily a essayé de mettre en œuvre une option alternative, avec seulement deux files d'attente, en utilisant des algorithmes de TCP - mesurant le RTT vers le serveur et ajustant la taille de la « fenêtre » (dans les messages) en fonction du nombre de demandes non confirmées. Autrement dit, une heuristique aussi grossière pour évaluer la charge du serveur est le nombre de nos requêtes qu'il peut traiter en même temps et ne pas perdre.

Eh bien, c'est vrai, vous comprenez, n'est-ce pas ? Si vous devez à nouveau implémenter TCP par-dessus un protocole fonctionnant sur TCP, cela indique un protocole très mal conçu.

Oh oui, pourquoi avez-vous besoin de plus d'une file d'attente, et qu'est-ce que cela signifie de toute façon pour une personne travaillant avec une API de haut niveau ? Écoutez, vous faites une demande, vous la sérialisez, mais souvent vous ne pouvez pas l'envoyer immédiatement. Pourquoi? Parce que la réponse sera msg_id, ce qui est temporaireаJe suis un label dont il est préférable de reporter l'attribution au plus tard possible - au cas où le serveur la rejetterait en raison d'un décalage horaire entre nous et lui (bien sûr, nous pouvons fabriquer une béquille qui décale notre temps du présent au serveur en ajoutant un delta calculé à partir des réponses du serveur (les clients officiels le font, mais c'est grossier et inexact en raison de la mise en mémoire tampon). Ainsi, lorsque vous effectuez une requête avec un appel de fonction locale depuis la bibliothèque, le message passe par les étapes suivantes :

  1. Il se trouve dans une file d'attente et attend le chiffrement.
  2. Nommé msg_id et le message est allé dans une autre file d'attente - transfert possible ; envoyer à la socket.
  3. a) Le serveur a répondu MsgsAck - le message a été livré, nous le supprimons de « l'autre file d'attente ».
    b) Ou vice versa, il n'a pas aimé quelque chose, il a répondu badmsg - renvoyer depuis "une autre file d'attente"
    c) On ne sait rien, le message doit être renvoyé depuis une autre file d'attente - mais on ne sait pas exactement quand.
  4. Le serveur a finalement répondu RpcResult - la réponse réelle (ou erreur) - non seulement livrée, mais également traitée.

Peut-être, l'utilisation de conteneurs pourrait résoudre en partie le problème. C'est à ce moment-là qu'un ensemble de messages sont regroupés en un seul et que le serveur a répondu par une confirmation à tous en même temps, en un seul. msg_id. Mais il rejettera également ce pack, en cas de problème, dans son intégralité.

Et c’est à ce stade que des considérations non techniques entrent en jeu. Par expérience, nous avons vu de nombreuses béquilles, et en plus, nous verrons désormais davantage d'exemples de mauvais conseils et d'architecture - dans de telles conditions, vaut-il la peine de faire confiance et de prendre de telles décisions ? La question est rhétorique (bien sûr que non).

De quoi parle-t-on? Si sur le thème des « messages de drogue sur les messages », vous pouvez encore spéculer avec des objections du type « vous êtes stupide, vous n'avez pas compris notre brillant plan ! (donc écrivez d'abord la documentation, comme les gens normaux devraient le faire, avec une justification et des exemples d'échange de paquets, puis nous en parlerons), puis les timings/timeouts sont une question purement pratique et spécifique, tout ici est connu depuis longtemps. Que nous dit la documentation sur les délais d'attente ?

Un serveur accuse généralement réception d'un message d'un client (normalement, une requête RPC) à l'aide d'une réponse RPC. Si une réponse tarde à arriver, un serveur peut d'abord envoyer un accusé de réception, et un peu plus tard, la réponse RPC elle-même.

Un client accuse normalement réception d'un message provenant d'un serveur (généralement une réponse RPC) en ajoutant un accusé de réception à la prochaine requête RPC s'il n'est pas transmis trop tard (s'il est généré, disons, 60 à 120 secondes après la réception). d'un message du serveur). Cependant, si pendant une longue période il n'y a aucune raison d'envoyer des messages au serveur ou s'il y a un grand nombre de messages sans accusé de réception en provenance du serveur (par exemple, plus de 16), le client transmet un accusé de réception autonome.

... Je traduis : nous ne savons pas nous-mêmes combien et comment nous en avons besoin, alors supposons que ce sera comme ça.

Et à propos des pings :

Messages ping (PING/PONG)

ping#7abe77ec ping_id:long = Pong;

Une réponse est généralement renvoyée à la même connexion :

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

Ces messages ne nécessitent pas d'accusé de réception. Un pong est transmis uniquement en réponse à un ping alors qu'un ping peut être initié par l'un ou l'autre côté.

Fermeture de connexion différée + PING

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

Fonctionne comme un ping. De plus, après réception de ce message, le serveur démarre un temporisateur qui fermera la connexion actuelle, déconnecter_delay quelques secondes plus tard, à moins qu'il ne reçoive un nouveau message du même type qui réinitialise automatiquement tous les temporisateurs précédents. Si le client envoie ces pings une fois toutes les 60 secondes, par exemple, il peut définir Disconnect_delay égal à 75 secondes.

Êtes-vous fou?! Dans 60 secondes, le train entrera en gare, déposera et récupérera des passagers, puis perdra à nouveau le contact dans le tunnel. Dans 120 secondes, pendant que vous l’entendez, il en arrivera à un autre et la connexion sera très probablement interrompue. Eh bien, il est clair d'où viennent les jambes - "J'ai entendu une sonnerie, mais je ne sais pas où elle se trouve", il y a l'algorithme de Nagl et l'option TCP_NODELAY, destinée au travail interactif. Mais excusez-moi, conservez sa valeur par défaut - 200 millisecondes Si vous voulez vraiment représenter quelque chose de similaire et économiser sur quelques paquets possibles, alors remettez-le de 5 secondes, ou quel que soit le délai d'expiration du message « L'utilisateur est en train de taper... » maintenant. Mais pas plus.

Et enfin, les pings. C'est-à-dire vérifier l'activité de la connexion TCP. C'est drôle, mais il y a environ 10 ans, j'ai écrit un texte critique sur le messager du dortoir de notre faculté - les auteurs ont également pingé le serveur depuis le client, et non l'inverse. Mais les étudiants de 3ème année sont une chose, et un bureau international en est une autre, non ?

Tout d’abord, un petit programme éducatif. Une connexion TCP, en l’absence d’échange de paquets, peut durer des semaines. C'est à la fois bon et mauvais, selon le but recherché. C'est bien si vous aviez une connexion SSH ouverte au serveur, vous vous êtes levé de l'ordinateur, avez redémarré le routeur, êtes retourné chez vous - la session via ce serveur n'a pas été déchirée (vous n'avez rien tapé, il n'y avait pas de paquets) , C'est pratique. C'est mauvais s'il y a des milliers de clients sur le serveur, chacun occupant des ressources (bonjour Postgres !), et l'hôte du client a peut-être redémarré il y a longtemps - mais nous n'en saurons rien.

Les systèmes de chat/IM tombent dans le deuxième cas pour une raison supplémentaire : les statuts en ligne. Si l'utilisateur « tombe », vous devez en informer ses interlocuteurs. Sinon, vous vous retrouverez avec une erreur commise par les créateurs de Jabber (et corrigée pendant 20 ans) - l'utilisateur s'est déconnecté, mais ils continuent de lui écrire des messages, croyant qu'il est en ligne (qui étaient également complètement perdus dans ces quelques minutes avant que la déconnexion ne soit découverte). Non, l'option TCP_KEEPALIVE, que de nombreuses personnes qui ne comprennent pas comment fonctionnent les minuteries TCP, ajoutent de manière aléatoire (en définissant des valeurs sauvages comme des dizaines de secondes), n'aidera pas ici - vous devez vous assurer que non seulement le noyau du système d'exploitation de la machine de l'utilisateur est vivante, mais fonctionne également normalement, capable de répondre, et l'application elle-même (pensez-vous qu'elle ne peut pas se bloquer ? Telegram Desktop sur Ubuntu 18.04 s'est figé pour moi plus d'une fois).

C'est pourquoi tu dois faire un ping serveur client, et non l'inverse - si le client fait cela, si la connexion est interrompue, le ping ne sera pas délivré, l'objectif ne sera pas atteint.

Que voit-on sur Telegram ? C'est exactement le contraire ! Eh bien, c'est vrai. Formellement, bien sûr, les deux parties peuvent se contacter. En pratique, les clients utilisent une béquille ping_delay_disconnect, qui règle la minuterie sur le serveur. Eh bien, excusez-moi, ce n'est pas au client de décider combien de temps il souhaite y vivre sans ping. Le serveur, en fonction de sa charge, sait mieux. Mais, bien sûr, si les ressources ne vous dérangent pas, alors vous serez votre propre méchant Pinocchio, et une béquille fera l'affaire...

Comment aurait-il dû être conçu ?

Je pense que les faits ci-dessus indiquent clairement que l'équipe Telegram/VKontakte n'est pas très compétente dans le domaine du transport (et au niveau inférieur) des réseaux informatiques et ses faibles qualifications dans les domaines concernés.

Pourquoi cela s’est-il avéré si compliqué et comment les architectes de Telegram peuvent-ils essayer de s’y opposer ? Le fait qu'ils aient essayé de créer une session qui survit aux ruptures de connexion TCP, c'est-à-dire ce qui n'a pas été livré maintenant, nous le livrerons plus tard. Ils ont probablement aussi essayé de réaliser un transport UDP, mais ils ont rencontré des difficultés et l'ont abandonné (c'est pourquoi la documentation est vide - il n'y avait pas de quoi se vanter). Mais en raison d'un manque de compréhension du fonctionnement des réseaux en général et de TCP en particulier, de l'endroit où vous pouvez vous y fier et de l'endroit où vous devez le faire vous-même (et comment), et d'une tentative de combiner cela avec la cryptographie, « deux oiseaux avec une pierre», voilà le résultat.

Comment était-ce nécessaire ? Basé sur le fait que msg_id est un horodatage nécessaire d'un point de vue cryptographique pour empêcher les attaques par rejeu, c'est une erreur de lui attacher une fonction d'identifiant unique. Par conséquent, sans changer fondamentalement l'architecture actuelle (lorsque le flux de mises à jour est généré, il s'agit d'un sujet API de haut niveau pour une autre partie de cette série d'articles), il faudrait :

  1. Le serveur détenant la connexion TCP avec le client assume la responsabilité - s'il a lu depuis le socket, veuillez accuser réception, traiter ou renvoyer une erreur, aucune perte. Ensuite, la confirmation n'est pas un vecteur d'identifiants, mais simplement "le dernier seq_no reçu" - juste un nombre, comme dans TCP (deux nombres - votre séquence et celle confirmée). Nous sommes toujours en séance, n’est-ce pas ?
  2. L'horodatage destiné à empêcher les attaques par relecture devient un champ distinct, de temps en temps. C'est vérifié, mais cela n'affecte rien d'autre. Assez et uint32 - si notre sel change au moins toutes les demi-journées, nous pouvons allouer 16 bits aux bits de poids faible d'une partie entière de l'heure actuelle, le reste - à une fraction de seconde (comme maintenant).
  3. Supprimé msg_id du tout - du point de vue de la distinction des requêtes sur les backends, il y a, d'une part, l'identifiant client, et d'autre part, l'identifiant de session, les concaténer. Par conséquent, une seule chose suffit comme identifiant de demande seq_no.

Ce n'est pas non plus l'option la plus efficace : un caractère aléatoire complet pourrait servir d'identifiant - cela est d'ailleurs déjà fait dans l'API de haut niveau lors de l'envoi d'un message. Il serait préférable de refaire complètement l'architecture du relatif à l'absolu, mais c'est un sujet pour une autre partie, pas pour cet article.

API ?

Ta-daam ! Ainsi, après avoir parcouru un chemin semé de douleur et de béquilles, nous avons finalement pu envoyer toutes les requêtes au serveur et recevoir toutes les réponses, ainsi que recevoir des mises à jour du serveur (pas en réponse à une requête, mais elle-même nous envoie, comme PUSH, si quelqu'un c'est plus clair comme ça).

Attention, il y aura désormais le seul exemple en Perl dans l'article ! (pour ceux qui ne connaissent pas la syntaxe, le premier argument de bless est la structure des données de l'objet, le second est sa classe) :

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' )
};

Oui, ce n’est pas volontairement un spoiler – si vous ne l’avez pas encore lu, n’hésitez pas et faites-le !

Oh, attends ~~... à quoi ça ressemble ? Quelque chose de très familier... c'est peut-être la structure de données d'une API Web typique en JSON, sauf que les classes sont également attachées aux objets ?..

Voilà donc comment ça se passe... De quoi s'agit-il, camarades ?.. Tant d'efforts - et nous nous sommes arrêtés pour nous reposer là où les programmeurs Web commence à peine?..JSON sur HTTPS ne serait-il pas plus simple ?! Qu’avons-nous obtenu en échange ? L’effort en valait-il la peine ?

Évaluons ce que TL+MTProto nous a apporté et quelles alternatives sont possibles. Eh bien, HTTP, qui se concentre sur le modèle requête-réponse, ne convient pas, mais au moins quelque chose en plus de TLS ?

Sérialisation compacte. En voyant cette structure de données, similaire à JSON, je me souviens qu'il en existe des versions binaires. Marquons MsgPack comme insuffisamment extensible, mais il existe, par exemple, CBOR - d'ailleurs, un standard décrit dans RFC 7049. Il est remarquable par le fait qu'il définit balises, comme mécanisme d'expansion, et parmi déjà standardisé il y a:

  • 25 + 256 - remplacement des lignes répétées par une référence au numéro de ligne, une méthode de compression si bon marché
  • 26 - objet Perl sérialisé avec nom de classe et arguments de constructeur
  • 27 - objet sérialisé indépendant du langage avec nom de type et arguments de constructeur

Eh bien, j'ai essayé de sérialiser les mêmes données dans TL et dans CBOR avec le packaging de chaînes et d'objets activé. Le résultat a commencé à varier en faveur du CBOR quelque part à partir d'un mégaoctet :

cborlen=1039673 tl_len=1095092

ainsi, sortie: Il existe des formats sensiblement plus simples qui ne sont pas soumis au problème d'échec de synchronisation ou d'identifiant inconnu, avec une efficacité comparable.

Établissement de connexion rapide. Cela signifie zéro RTT après reconnexion (lorsque la clé a déjà été générée une fois) - applicable dès le tout premier message MTProto, mais avec quelques réserves - frapper le même sel, la session n'est pas pourrie, etc. Que nous propose TLS à la place ? Citation sur le sujet :

Lors de l'utilisation de PFS dans TLS, les tickets de session TLS (RFC 5077) pour reprendre une session chiffrée sans renégocier les clés et sans stocker les informations de clé sur le serveur. Lors de l'ouverture de la première connexion et de la création des clés, le serveur crypte l'état de la connexion et le transmet au client (sous forme de ticket de session). En conséquence, lorsque la connexion est rétablie, le client renvoie un ticket de session, comprenant la clé de session, au serveur. Le ticket lui-même est crypté avec une clé temporaire (clé de ticket de session), qui est stockée sur le serveur et doit être distribuée entre tous les serveurs frontaux traitant SSL dans les solutions en cluster.[10]. Ainsi, l'introduction d'un ticket de session peut violer PFS si les clés temporaires du serveur sont compromises, par exemple lorsqu'elles sont stockées pendant une longue période (OpenSSL, nginx, Apache les stockent par défaut pendant toute la durée du programme ; les sites populaires utilisent la clé pendant plusieurs heures, voire plusieurs jours).

Ici, le RTT n'est pas nul, vous devez échanger au moins ClientHello et ServerHello, après quoi le client peut envoyer des données avec Finished. Mais ici, nous devons nous rappeler que nous n'avons pas le Web, avec son tas de connexions nouvellement ouvertes, mais un messager dont la connexion est souvent une et des requêtes plus ou moins longues et relativement courtes vers des pages Web - tout est multiplexé. intérieurement. Autrement dit, c’est tout à fait acceptable si nous ne rencontrons pas un très mauvais tronçon de métro.

Vous avez oublié autre chose ? Écrivez dans les commentaires.

À suivre!

Dans la deuxième partie de cette série d'articles, nous examinerons non pas des questions techniques, mais des questions organisationnelles - approches, idéologie, interface, attitude envers les utilisateurs, etc. Cependant, sur la base des informations techniques présentées ici.

La troisième partie continuera à analyser la composante technique / l'expérience de développement. Vous apprendrez notamment :

  • poursuite du pandémonium avec la variété des types TL
  • choses inconnues sur les chaînes et les supergroupes
  • pourquoi les dialogues sont pires que la liste
  • à propos de l'adressage absolu ou relatif des messages
  • quelle est la différence entre photo et image
  • comment les emoji interfèrent avec le texte en italique

et autres béquilles ! Restez à l'écoute!

Source: habr.com

Ajouter un commentaire