Critică la adresa protocolului și a abordărilor organizaționale ale Telegramului. Partea 1, tehnică: experiență de scriere a unui client de la zero - TL, MT

Recent, pe Habré au început să apară mai des postări despre cât de bun este Telegram, cât de strălucitor și de experiență sunt frații Durov în construirea de sisteme de rețea etc. În același timp, foarte puțini oameni s-au cufundat cu adevărat în dispozitivul tehnic - cel mult folosesc un API Bot bazat pe JSON destul de simplu (și foarte diferit de MTProto) și, de obicei, acceptă doar pe credință toate acele laude și PR care gravitează în jurul mesagerului. În urmă cu aproape un an și jumătate, colegul meu de la NPO Echelon Vasily (din păcate, contul lui de pe Habré a fost șters odată cu proiectul) a început să scrie de la zero propriul client Telegram în Perl, iar mai târziu s-a alăturat și autorul acestor rânduri. De ce Perl, vor întreba unii imediat? Pentru că există deja astfel de proiecte în alte limbi. De fapt, acesta nu este ideea, ar putea exista orice altă limbă în care bibliotecă terminatăși, în consecință, autorul trebuie să meargă până la capăt de la zero. Mai mult, criptografia este un astfel de lucru - ai încredere, dar verifică. Cu un produs axat pe securitate, nu vă puteți baza doar pe biblioteca gata făcută a unui furnizor și nu vă puteți crede orbește (cu toate acestea, acesta este un subiect pentru mai multe în a doua parte). În acest moment, biblioteca funcționează destul de bine la nivelul „mijloc” (vă permite să faceți orice solicitări API).

Cu toate acestea, nu va exista prea multă criptografie și matematică în această serie de postări. Dar vor fi multe alte detalii tehnice și cârje arhitecturale (va fi de folos și celor care nu vor scrie de la zero, dar vor folosi biblioteca în orice limbă). Deci, scopul principal a fost să încercăm să implementăm clientul de la zero conform documentatiei oficiale. Adică, să presupunem că codul sursă al clienților oficiali este închis (din nou, în a doua parte, vom dezvălui mai detaliat subiectul despre ce este acesta cu adevărat se întâmplă așa), dar, ca pe vremuri, de exemplu, există un standard precum RFC - este posibil să scrieți un client numai conform specificațiilor, „fără să aruncați o privire” în codul sursă, chiar și oficial (Telegram Desktop, mobil ), chiar Telethon neoficial?

Cuprins:

Documentatie... este acolo? Este adevarat?..

Fragmente de note pentru acest articol au început să fie colectate vara trecută. În tot acest timp pe site-ul oficial https://core.telegram.org documentația a fost de la Layer 23, i.e. blocat undeva în 2014 (ți minte, pe atunci nici măcar canale nu existau încă?). Desigur, în teorie, acest lucru ar fi trebuit să facă posibilă implementarea unui client cu funcționalitate la acel moment în 2014. Dar chiar și în această stare, documentația era, în primul rând, incompletă și, în al doilea rând, pe alocuri s-a contrazis. În urmă cu puțin peste o lună, în septembrie 2019, a fost accidental s-a constatat că site-ul are o actualizare mare a documentației, pentru un Layer 105 complet proaspăt, cu o notă că acum totul trebuie citit din nou. Într-adevăr, multe articole au fost revizuite, dar multe au rămas neschimbate. Prin urmare, când citiți criticile de mai jos despre documentație, ar trebui să aveți în vedere că unele dintre aceste lucruri nu mai sunt relevante, dar unele sunt încă destul de bune. La urma urmei, 5 ani în lumea modernă nu sunt doar mulți, ci foarte mult. De atunci (mai ales dacă nu țineți cont de geochat-urile aruncate și reînviate de atunci), numărul de metode API din schemă a crescut de la o sută la mai mult de două sute cincizeci!

De unde începi ca tânăr scriitor?

Nu contează dacă scrii de la zero sau folosești, de exemplu, biblioteci gata făcute precum Telethon pentru Python sau Madeline pentru PHP, în orice caz, mai întâi vei avea nevoie înregistrați-vă aplicația - obțineți parametri api_id и api_hash (cei care au lucrat cu API-ul VKontakte înțeleg imediat) prin care serverul va identifica aplicația. Acest avea din motive legale, dar vom vorbi mai multe despre motivul pentru care autorii bibliotecii nu o pot publica în partea a doua. Poate că veți fi mulțumit de valorile de testare, deși sunt foarte limitate - adevărul este că acum vă puteți înregistra pe numărul dvs. doar unul aplicare, așa că nu vă grăbiți.

Acum, din punct de vedere tehnic, ar fi trebuit să ne intereseze faptul că după înregistrare ar trebui să primim notificări de la Telegram despre actualizări ale documentației, protocolului etc. Adică, s-ar putea presupune că site-ul cu docuri a fost pur și simplu „notat” și a continuat să lucreze special cu cei care au început să-și facă clienți, pentru că. e mai usor. Dar nu, nu s-a observat așa ceva, nu a venit nicio informație.

Și dacă scrieți de la zero, atunci utilizarea parametrilor primiți este de fapt încă departe. Cu toate că https://core.telegram.org/ și vorbește despre ele mai întâi în Noțiuni de bază, de fapt, mai întâi trebuie să implementați Protocolul MTProto - dar dacă crezi layout conform modelului OSI la sfârșitul paginii descrierii generale a protocolului, apoi complet în zadar.

De fapt, atât înainte de MTProto, cât și după, la mai multe niveluri simultan (după cum spun rețelei străini care lucrează în nucleul sistemului de operare, încălcarea stratului), un subiect mare, dureros și teribil va sta în cale...

Serializare binară: TL (Type Language) și schema sa, și straturi și multe alte cuvinte înfricoșătoare

Acest subiect, de fapt, este cheia problemelor Telegram. Și vor fi multe cuvinte groaznice dacă încerci să aprofundezi în el.

Deci, schema. Dacă vă amintiți acest cuvânt, spuneți: Schema JSONAi crezut bine. Scopul este același: un limbaj care să descrie un posibil set de date transmise. Aici, de fapt, se termină asemănarea. Dacă din pagină Protocolul MTProto, sau din arborele sursă al clientului oficial, vom încerca să deschidem o schemă, vom vedea ceva de genul:

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;

O persoană care vede acest lucru pentru prima dată va recunoaște intuitiv doar o parte din ceea ce este scris - ei bine, acestea sunt aparent structuri (deși unde este numele, în stânga sau în dreapta?), Există câmpuri în ele, după care tipul trece prin colon... probabil. Aici, între paranteze unghiulare, există probabil șabloane ca în C ++ (de fapt, nu chiar). Și ce înseamnă toate celelalte simboluri, semne de întrebare, semne de exclamare, procente, zăbrele (și, evident, înseamnă lucruri diferite în locuri diferite), prezente undeva, dar nu undeva, numere hexazecimale - și cel mai important, cum să obții de la asta dreapta (care nu va fi respins de server) flux de octeți? Trebuie să citiți documentația (Da, există link-uri către schema în versiunea JSON în apropiere - dar acest lucru nu face mai clar).

Deschiderea paginii Serializarea datelor binare și cufundați-vă în lumea magică a ciupercilor și a matematicii discrete, ceva asemănător cu matan în anul 4. Alfabet, tip, valoare, combinator, combinator funcțional, formă normală, tip compozit, tip polimorf... și asta e doar prima pagină! Următorul te așteaptă Limbaj TL, care, deși conține deja un exemplu de cerere și răspuns banal, nu oferă deloc un răspuns la cazuri mai tipice, ceea ce înseamnă că va trebui să treci prin repovestirea matematicii traduse din rusă în engleză pe alte opt imbricate. pagini!

Cititorii familiarizați cu limbaje funcționale și inferența de tip automat, desigur, au văzut în acest limbaj descrieri, chiar și dintr-un exemplu, mult mai familiar, și pot spune că, în general, acest lucru nu este rău în principiu. Obiecțiile la aceasta sunt:

  • Da, scop suna bine, dar vai nu este arhivat
  • educația în universitățile rusești variază chiar și între specialitățile IT - nu toată lumea citește cursul corespunzător
  • În cele din urmă, după cum vom vedea, în practică este nu este necesar, deoarece este utilizat doar un subset limitat chiar și din TL care a fost descris

După cum am spus LeonNerd pe canal #perl pe rețeaua FreeNode IRC, încercând să implementeze o poartă de la Telegram la Matrix (traducerea citatului este inexactă din memorie):

Se simte ca cineva care a fost introdus în teoria tipurilor pentru prima dată s-a entuziasmat și a început să încerce să se joace cu ea, fără să-i pese cu adevărat dacă era necesar în practică.

Vedeți singuri dacă nevoia de tipuri goale (int, long etc.) ca ceva elementar nu ridică întrebări - până la urmă trebuie implementate manual - de exemplu, să încercăm să derivăm din ele vector. Adică, de fapt, matrice, dacă numiți lucrurile rezultate prin numele lor proprii.

Dar înainte

Scurtă descriere a unui subset al sintaxei TL pentru cei care nu... citesc documentația oficială

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;

Începe întotdeauna definiția proiectant, după care, opțional (în practică, întotdeauna) prin simbol #CRC32 din șirul de descriere normalizat de tipul dat. Urmează descrierea câmpurilor, dacă acestea sunt - tipul poate fi gol. Totul se termină cu un semn egal, numele tipului căruia îi aparține constructorul dat - adică, de fapt, subtipul -. Tipul din dreapta semnului egal este polimorfă - adică poate corespunde mai multor tipuri specifice.

Dacă definiția apare după linie ---functions---, atunci sintaxa va rămâne aceeași, dar sensul va fi diferit: constructorul va deveni numele funcției RPC, câmpurile vor deveni parametri (ei bine, adică va rămâne exact aceeași structură dată așa cum este descrisă mai jos, va fi doar sensul dat), iar „tipul polimorf” este tipul rezultatului returnat. Adevărat, va rămâne în continuare polimorf - tocmai definit în secțiune ---types---, iar acest constructor nu va fi luat în considerare. Tastați supraîncărcări ale funcțiilor apelate după argumentele lor, de ex. din anumite motive, mai multe funcții cu același nume, dar cu o semnătură diferită, ca în C++, nu sunt furnizate în TL.

De ce „constructor” și „polimorf” dacă nu este OOP? Ei bine, de fapt, va fi mai ușor pentru cineva să se gândească la asta în termeni de POO - un tip polimorf ca o clasă abstractă, iar constructorii sunt clasele sale descendente directe, în plus. final în terminologia unui număr de limbi. De fapt, desigur, aici similitudine cu metode reale de constructoare supraîncărcate în limbaje de programare OO. Deoarece aici există doar structuri de date, nu există metode (deși descrierea funcțiilor și metodelor de mai jos este destul de capabilă să creeze confuzie în cap cu privire la ceea ce sunt acestea, dar este vorba despre altceva) - vă puteți gândi la un constructor ca la un valoare din care fiind construită tastați când citiți un flux de octeți.

Cum se întâmplă asta? Deserializatorul, care citește întotdeauna 4 octeți, vede valoarea 0xcrc32 - și înțelege ce se va întâmpla în continuare field1 cu tip int, adică citește exact 4 octeți, pe acest câmp de deasupra cu tip PolymorType citit. Vede 0x2crc32 și înțelege că există două domenii mai departe, în primul rând long, deci citim 8 octeți. Și apoi din nou un tip complex, care este deserializat în același mod. De exemplu, Type3 ar putea fi declarate în schemă de îndată ce doi constructori, respectiv, trebuie să se întâlnească în continuare 0x12abcd34, după care trebuie să citiți încă 4 octeți intSau 0x6789cdef, după care nu va mai fi nimic. Orice altceva - trebuie să aruncați o excepție. În orice caz, după aceea revenim la citirea a 4 octeți int domenii field_c в constructorTwo și pe asta terminăm de citit PolymorType.

În cele din urmă, dacă este prins 0xdeadcrc pentru constructorThree, atunci lucrurile se complică. Primul nostru domeniu bit_flags_of_what_really_present cu tip # - de fapt, acesta este doar un alias pentru tip natadică „număr natural”. Adică, de fapt, unsigned int este singurul caz, apropo, când numere fără semn se găsesc în circuite reale. Deci, următoarea este o construcție cu un semn de întrebare, ceea ce înseamnă că acesta este câmpul - va fi prezent pe fir numai dacă bitul corespunzător este setat în câmpul referit (aproximativ ca un operator ternar). Deci, să presupunem că acest bit a fost activat, atunci trebuie să citiți un câmp ca Type, care în exemplul nostru are 2 constructori. Unul este gol (constă doar dintr-un identificator), celălalt are un câmp ids cu tip ids:Vector<long>.

Ai putea crede că atât șabloanele, cât și genericele sunt bune sau Java. Dar nu. Aproape. Acest singurul cazul parantezelor unghiulare în circuite reale și este folosit NUMAI pentru Vector. Într-un flux de octeți, acesta va fi 4 CRC32 octeți pentru tipul Vector în sine, întotdeauna la fel, apoi 4 octeți - numărul de elemente ale matricei și apoi aceste elemente în sine.

Adăugați la aceasta faptul că serializarea are loc întotdeauna în cuvinte de 4 octeți, toate tipurile sunt multipli ale acesteia - sunt descrise și tipurile încorporate bytes и string cu serializarea manuală a lungimii și această aliniere cu 4 - ei bine, pare să sune normal și chiar relativ eficient? Deși se pretinde că TL este o serializare binară eficientă, dar la naiba cu ele, cu extinderea a orice, chiar și a valorilor booleene și a șirurilor cu un singur caracter de până la 4 octeți, va fi JSON încă mult mai gros? Uite, chiar și câmpurile inutile pot fi sărite de indicatori de biți, totul este în regulă și chiar extensibil pentru viitor, ai adăugat noi câmpuri opționale la constructor mai târziu?...

Dar nu, dacă citiți nu descrierea mea scurtă, ci documentația completă și vă gândiți la implementare. În primul rând, CRC32 al constructorului este calculat de șirul normalizat de descriere a textului schemei (eliminați spațiul alb suplimentar, etc.) - deci, dacă se adaugă un câmp nou, șirul de descriere a tipului se va schimba și, prin urmare, CRC32 și, în consecință, serializarea. Și ce ar face vechiul client dacă ar primi un câmp cu steaguri noi setate, dar nu știa ce să facă cu ele în continuare? ..

În al doilea rând, să ne amintim CRC32, care este folosit aici în esență ca funcții hash pentru a determina în mod unic ce tip este (de)serializat. Aici ne confruntăm cu problema coliziunilor - și nu, probabilitatea nu este una la 232, ci mult mai mult. Cine și-a amintit că CRC32 este proiectat să detecteze (și să corecteze) erorile în canalul de comunicare și, în consecință, să îmbunătățească aceste proprietăți în detrimentul altora? De exemplu, nu îi pasă de permutarea octeților: dacă numărați CRC32 din două linii, în a doua vei schimba primii 4 octeți cu următorii 4 octeți - va fi la fel. Când avem șiruri de text din alfabetul latin (și puțină punctuație) ca intrare, iar aceste nume nu sunt deosebit de aleatorii, probabilitatea unei astfel de permutări este mult crescută.

Apropo, cine a verificat ce era acolo într-adevăr CRC32? Într-una dintre sursele timpurii (chiar înainte de Waltman) exista o funcție hash care înmulțea fiecare caracter cu numărul 239, atât de îndrăgit de acești oameni, ha ha!

În cele din urmă, bine, ne-am dat seama că constructorii cu un tip de câmp Vector<int> и Vector<PolymorType> va avea CRC32 diferit. Și cum rămâne cu prezentarea pe linie? Iar din punct de vedere teoretic, devine parte a tipului? Să presupunem că trecem o matrice de zece mii de numere, ei bine, cu Vector<int> totul este clar, lungimea și încă 40000 de octeți. Și dacă aceasta Vector<Type2>, care constă dintr-un singur câmp int și este singurul din tip - trebuie să repetăm ​​10000xabcdef0 de 34 de ori și apoi de 4 octeți int, sau limbajul poate să ne afișeze acest lucru din constructor fixedVec și în loc de 80000 de octeți, transferați din nou doar 40000?

Aceasta nu este deloc o întrebare teoretică inactivă - imaginați-vă că obțineți o listă de utilizatori de grup, fiecare dintre care are un id, prenume, nume - diferența în cantitatea de date transferate printr-o conexiune mobilă poate fi semnificativă. Eficiența serializării Telegram este cea care ni se face publicitate.

Asa de…

Vector, care nu a putut fi dedus

Dacă încercați să vă plimbați prin paginile de descriere ale combinatorilor și despre, veți vedea că un vector (și chiar o matrice) încearcă în mod formal să deducă mai multe foi prin tupluri. Dar în cele din urmă sunt ciocăniți, pasul final este sărit și se dă pur și simplu definiția unui vector, care, de asemenea, nu este legat de un tip. Ce se întâmplă aici? În limbi programare, în special cele funcționale, este destul de tipic să descrii structura recursiv - compilatorul cu evaluarea sa leneșă va înțelege totul și o va face. În limbaj serializarea datelor dar este nevoie de EFICIENTA: este suficient sa descrii simplu listă, adică o structură din două elemente - primul este un element de date, al doilea este aceeași structură în sine sau un spațiu gol pentru coadă (pachet (cons) în Lisp). Dar acest lucru va necesita evident fiecare elementul cheltuiește în plus 4 octeți (CRC32 în cazul TL) pentru a-și descrie tipul. Este ușor să descrii o matrice marime fixa, dar în cazul unui tablou cu o lungime necunoscută anterior, ne despărțim.

Deci, deoarece TL nu vă permite să scoateți un vector, acesta a trebuit să fie adăugat în lateral. În cele din urmă documentația spune:

Serializarea folosește întotdeauna același constructor „vector” (const 0x1cb5c415 = crc32(„vector t:Type # [ t ] = Vector t”) care nu este dependent de valoarea specifică a variabilei de tip t.

Valoarea parametrului opțional t nu este implicată în serializare, deoarece este derivată din tipul rezultat (cunoscut întotdeauna înainte de deserializare).

Priveste mai atent: vector {t:Type} # [ t ] = Vector t - dar nicăieri definiția în sine nu spune că primul număr trebuie să fie egal cu lungimea vectorului! Și nu urmează de nicăieri. Acesta este un dat pe care trebuie să îl țineți cont și să îl puneți în aplicare cu mâinile. În altă parte, documentația chiar menționează sincer că tipul este fals:

Pseudotipul polimorf Vector t este un „tip” a cărui valoare este o succesiune de valori de orice tip t, fie casete, fie goale.

… dar nu se concentrează asupra ei. Când tu, obosit să treci prin întinderea matematicii (poate chiar cunoscută de la un curs universitar), te decizi să punctezi și să urmărești cum să lucrezi efectiv cu ea în practică, impresia îți rămâne în cap: aici Serious Mathematics se bazează pe , evident Cool People (doi matematicieni -câștigător al ACM), și nu oricine. Scopul - a face stropi - a fost atins.

Apropo, despre număr. Amintiți-vă # este un sinonim nat, numar natural:

Există expresii de tip (tipexpr) și expresii numerice (nat-expr). Cu toate acestea, ele sunt definite în același mod.

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

dar în gramatică sunt descrise în același mod, adică. această diferență din nou trebuie reținută și pusă în aplicare manual.

Ei bine, da, tipuri de șabloane (vector<int>, vector<User>) au un identificator comun (#1cb5c415), adică dacă știți că apelul este declarat ca

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

atunci așteptați nu doar un vector, ci și un vector de utilizatori. Mai precis, asteapta - in cod real, fiecare element, daca nu de tip bare, va avea un constructor, iar intr-un sens bun in implementare ar fi necesar sa verificam - si am fost trimisi exact in fiecare element al acestui vector acel tip? Și dacă ar fi fost un fel de PHP, în care matricea poate conține diferite tipuri în diferite elemente?

În acest moment, începi să te întrebi – este nevoie de un astfel de TL? Poate pentru cărucior s-ar putea folosi serializatorul uman, același protobuf care exista deja atunci? Era teorie, să ne uităm la practică.

Implementări TL existente în cod

TL s-a născut în măruntaiele VKontakte chiar înainte de binecunoscutele evenimente cu vânzarea acțiunii lui Durov și (cu siguranță), chiar înainte de dezvoltarea Telegramului. Și în sursă deschisă sursele primei implementări poți găsi o mulțime de cârje amuzante. Și limbajul în sine a fost implementat acolo mai pe deplin decât este acum în Telegram. De exemplu, hashurile nu sunt folosite deloc în schemă (adică pseudotipul încorporat (ca un vector) cu comportament deviant). Sau

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

dar să luăm în considerare de dragul completității tabloul, pentru a urmări, ca să spunem așa, evoluția Uriașului Gândirii.

#define ZHUKOV_BYTES_HACK

#ifdef ZHUKOV_BYTES_HACK

/* dirty hack for Zhukov request */

Sau asta frumoasa:

    static const char *reserved_words_polymorhic[] = {

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

      };

Acest fragment este despre șabloane, cum ar fi:

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

Aceasta este definiția tipului de șablon hashmap, ca vector de perechi int - Type. În C++ ar arăta cam așa:

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

asa de, alpha - cuvânt cheie! Dar doar în C++ poți scrie T, dar trebuie să scrii alpha, beta... Dar nu mai mult de 8 parametri, fantezia s-a terminat pe theta. Deci, se pare că odată la Sankt Petersburg au existat aproximativ astfel de dialoguri:

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

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

Dar a fost vorba despre prima implementare a TL „în general”. Să trecem la luarea în considerare a implementărilor în clienții Telegram actuali.

Cuvântul lui Vasile:

Vasily, [09.10.18 17:07] Cel mai mult, fundul este fierbinte din cauza faptului că au stricat o grămadă de abstracții, apoi au bătut un șurub peste ele și au acoperit codificatorul cu cârje
Ca urmare, mai întâi de pe docuri pilot.jpg
Apoi din codul jekichan.webp

Desigur, de la oameni familiarizați cu algoritmi și matematică, ne putem aștepta să fi citit Aho, Ullman și să fie familiarizați cu instrumentele standard ale industriei de facto pentru a-și scrie compilatoarele DSL de-a lungul deceniilor, nu? ..

Autor telegram-cli este Vitaliy Valtman, după cum se poate înțelege din apariția formatului TLO în afara limitelor sale (cli), un membru al echipei - acum biblioteca pentru analizarea TL este alocată separatcare este impresia ei Analizor TL? ..

16.12 04:18 Vasily: după părerea mea, cineva nu a stăpânit lex + yacc
16.12 04:18 Vasily: altfel nu pot explica
16.12 04:18 Vasily: bine, sau au fost plătiți pentru numărul de linii în VK
16.12 04:19 Vasily: 3k+ rânduri ale altora<censored> în loc de un parser

Poate o excepție? Să vedem cum face acesta este clientul OFICIAL — Telegram Desktop:

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

1100+ linii în Python, câteva expresii regulate + cazuri speciale de tip vector, care, desigur, este declarat în schemă așa cum ar trebui să fie conform sintaxei TL, dar l-au pus pe această sintaxă, o analizează mai mult ... Întrebarea este, de ce să te deranjezi cu tot acest miracolиmai puf, dacă oricum nimeni nu o să-l analizeze conform documentației?!

Apropo... Îți amintești că am vorbit despre verificarea CRC32? Deci, în generatorul de cod Telegram Desktop există o listă de excepții pentru acele tipuri în care CRC32 calculat nu se potrivește asa cum este indicat in diagrama!

Vasily, [18.12 22:49] și aici ar trebui să te gândești dacă este nevoie de un astfel de TL
dacă aș vrea să mă încurc cu implementări alternative, aș începe să inserez întreruperi de linie, jumătate dintre analizatori se vor întrerupe pe definiții cu mai multe linii
tdesktop, totuși, de asemenea

Amintiți-vă punctul despre one-liners, vom reveni la el puțin mai târziu.

Bine, telegram-cli este neoficial, Telegram Desktop este oficial, dar ce se întâmplă cu celelalte? Și cine știe?... În codul clientului Android, nu a existat deloc un parser de schemă (ceea ce ridică întrebări despre sursa deschisă, dar aceasta este pentru a doua parte), dar au existat câteva alte bucăți amuzante de cod, dar despre ele în subsecțiunea de mai jos.

Ce alte întrebări ridică serializarea în practică? De exemplu, au greșit, desigur, cu câmpuri de biți și câmpuri condiționate:

vasili: flags.0? true
înseamnă că câmpul este prezent și adevărat dacă steagul este setat

vasili: flags.1? int
înseamnă că câmpul este prezent și trebuie deserializat

Vasily: Măgar, nu te arde, ce faci!
Vasily: Undeva în document se menționează că adevărat este un tip gol de lungime zero, dar este nerealist să colectezi ceva din documentele lor.
Vasily: Nici în implementările deschise nu există așa ceva, dar există o mulțime de cârje și recuzită

Ce zici de un Telethon? Privind în viitor pe tema MTProto, un exemplu - există astfel de piese în documentație, dar semnul % este descris doar ca „corespunzător tipului gol dat”, i.e. în exemplele de mai jos, fie o eroare, fie ceva nedocumentat:

Vasily, [22.06.18/18/38 XNUMX:XNUMX] Într-un singur loc:

msg_container#73f1f8dc messages:vector message = MessageContainer;

Într-un alt mod:

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

Și acestea sunt două diferențe mari, în viața reală apare un fel de vector gol

Nu am văzut definiții simple de vector și nu am întâlnit-o

Analiză scrisă în telethon de mână

Schema lui a comentat definiția msg_container

Din nou, întrebarea rămâne despre%. Nu este descris.

Vadim Goncharov, [22.06.18/19/22 XNUMX:XNUMX] și în tdesktop?

Vasily, [22.06.18/19/23 XNUMX:XNUMX] Dar analizatorul lor TL de pe regulatoare probabil că nici nu îl va mânca

// parsed manually

TL este o abstractizare frumoasă, nimeni nu o implementează complet

Și nu există nici un % în versiunea lor a schemei

Dar aici documentația se contrazice, deci xs

S-a găsit în gramatică, puteau doar să uite să descrie semantica

Ei bine, ați văzut docul pe TL, nu vă puteți da seama fără jumătate de litru

„Ei bine, să spunem”, va spune un alt cititor, „critici totul, așa că arată-l așa cum ar trebui.”

Vasily răspunde: „în ceea ce privește analizatorul, am nevoie de lucruri de genul

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

cumva mai mult decât

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

sau

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

acesta este TOTUL 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];

acestea. mai simplu este să o spunem blând.”

În general, în cele din urmă, analizatorul și generatorul de cod pentru subsetul de TL utilizat efectiv se potrivesc în aproximativ 100 de linii de gramatică și ~ 300 de linii ale generatorului (inclusiv toate printcodul generat), inclusiv bunuri de tip, informații de tip pentru introspecție în fiecare clasă. Fiecare tip polimorf este transformat într-o clasă de bază abstractă goală, iar constructorii moștenesc de la ea și au metode de serializare și deserializare.

Lipsa tipurilor în limbajul tipului

Tastarea puternică este bună, nu? Nu, acesta nu este un holivar (deși prefer limbaje dinamice), ci un postulat în TL. Pe baza ei, limba ar trebui să ne ofere tot felul de verificări. Ei bine, bine, nu el, ci implementarea, dar ar trebui măcar să le descrie. Și ce oportunități ne dorim?

În primul rând, constrângerile. Aici vedem în documentația pentru încărcarea fișierelor:

Conținutul binar al fișierului este apoi împărțit în părți. Toate piesele trebuie să aibă aceeași dimensiune ( dimensiunea_parte ) și trebuie îndeplinite următoarele condiții:

  • part_size % 1024 = 0 (divizibil cu 1KB)
  • 524288 % part_size = 0 (512KB trebuie să fie divizibil uniform la dimensiunea_parte)

Ultima parte nu trebuie să îndeplinească aceste condiții, cu condiția ca dimensiunea sa să fie mai mică decât part_size.

Fiecare parte ar trebui să aibă un număr de ordine, file_part, cu o valoare cuprinsă între 0 și 2,999.

După ce fișierul a fost partiționat, trebuie să alegeți o metodă de salvare pe server. utilizare upload.saveBigFilePart în cazul în care dimensiunea completă a fișierului este mai mare de 10 MB și upload.saveFilePart pentru fișiere mai mici.
[…] poate fi returnată una dintre următoarele erori de introducere a datelor:

  • FILE_PARTS_INVALID - Număr nevalid de piese. Valoarea nu este între 1..3000

Sunt prezente vreuna dintre acestea în schemă? Este cumva exprimabil prin intermediul TL? Nu. Dar scuzați-mă, chiar și de modă veche Turbo Pascal a fost capabil să descrie tipurile date de intervale. Și ar mai putea face un lucru, acum mai cunoscut ca enum - un tip constând dintr-o enumerare a unui număr fix (mic) de valori. În limbi precum C - numeric, ține cont, până acum am vorbit doar despre tipuri. numerele. Dar există și matrice, șiruri... de exemplu, ar fi bine să descriem că acest șir poate conține doar un număr de telefon, nu?

Nimic din toate acestea nu este în TL. Dar există, de exemplu, în schema JSON. Și dacă altcineva poate obiecta cu privire la divizibilitatea a 512 KB că acest lucru trebuie încă verificat în cod, atunci asigurați-vă că clientul pur și simplu nu putea trimite numărul în afara intervalului 1..3000 (și eroarea corespunzătoare nu ar fi putut apărea) ar fi posibil, nu? ..

Apropo, despre erori și valori returnate. Ochiul este încețoșat chiar și pentru cei care au lucrat cu TL - nu ne-am dat seama imediat că fiecare o funcție din TL poate returna de fapt nu numai tipul de returnare descris, ci și o eroare. Dar acest lucru nu este deductibil prin intermediul TL în sine. Desigur, este oricum de înțeles și nafig nu este necesar în practică (deși, de fapt, RPC se poate face în moduri diferite, vom reveni la asta) - dar ce zici de Puritatea conceptelor de Matematică a Tipurilor Abstracte din cele cerești. lumea? .. Am luat remorcherul - deci potriviți.

Și, în sfârșit, cum rămâne cu lizibilitatea? Ei bine, acolo, în general, aș dori descriere să fie corect în schemă (din nou, este în schema JSON), dar dacă este deja încordat, atunci cum rămâne cu partea practică - cel puțin este banal să urmăriți diferențele în timpul actualizărilor? Vedeți singur la exemple reale:

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

sau

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

Cuiva îi place, dar GitHub, de exemplu, refuză să evidențieze schimbările în rândurile atât de lungi. Jocul „găsește 10 diferențe” și ceea ce vede imediat creierul este că începuturile și sfârșiturile sunt aceleași în ambele exemple, trebuie să citești plictisitor undeva la mijloc ... După părerea mea, acest lucru nu este doar în teorie, dar arată pur vizual murdară și neîngrijită.

Apropo, despre puritatea teoriei. De ce sunt necesare câmpuri de biți? Nu se pare că miros rău din punct de vedere al teoriei tipurilor? O explicație poate fi văzută în versiunile anterioare ale schemei. La început, da, așa a fost, a fost creat un nou tip pentru fiecare strănut. Aceste rudimente sunt încă acolo sub această formă, de exemplu:

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;

Dar acum imaginați-vă că dacă aveți 5 câmpuri opționale în structura dvs., atunci aveți nevoie de 32 de tipuri pentru toate opțiunile posibile. explozie combinatorie. Așa că puritatea cristalină a teoriei TL s-a izbit încă o dată de fundul de fontă al realității dure a serializării.

În plus, pe alocuri acești tipi încalcă propria tastare. De exemplu, în MTProto (capitolul următor), răspunsul poate fi comprimat de Gzip, totul este sensibil - cu excepția încălcării straturilor și a schemei. Odată, și nu a cules RpcResult în sine, ci conținutul său. Ei bine, de ce să fac asta?... A trebuit să tai o cârjă pentru ca compresia să funcționeze oriunde.

Sau alt exemplu, am găsit odată o eroare - trimisă InputPeerUser în loc de InputUser. Sau vice versa. Dar a funcționat! Adică serverului nu i-a păsat de tip. Cum poate fi aceasta? Răspunsul, probabil, va fi solicitat de fragmente de cod din 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);

Cu alte cuvinte, aici se face serializarea MANUAL, cod nu este generat! Poate că serverul este implementat într-un mod similar?... În principiu, acest lucru va funcționa dacă se face o dată, dar cum îl puteți susține mai târziu cu actualizări? Nu pentru asta a fost schema? Și apoi trecem la următoarea întrebare.

Versiune. Straturi

De ce versiunile de schemă sunt numite straturi poate fi ghicit doar pe baza istoricului schemelor publicate. Se pare că, la început, autorilor li s-a părut că lucrurile de bază pot fi făcute pe o schemă neschimbată și numai acolo unde este necesar, indică cererilor specifice că acestea sunt făcute conform unei versiuni diferite. În principiu, chiar și o idee bună - și noul va, așa cum spunea, „se amestecă”, stratul pe cel vechi. Dar să vedem cum s-a făcut. Adevărat, nu a fost posibil să se uite de la bun început - este amuzant, dar schema stratului de bază pur și simplu nu există. Straturile au început la 2. Documentația ne vorbește despre o caracteristică specială TL:

Dacă un client acceptă Stratul 2, atunci trebuie utilizat următorul constructor:

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

În practică, aceasta înseamnă că înainte de fiecare apel API, un int cu valoarea 0x289dd1f6 trebuie adăugat înainte de numărul metodei.

Sună OK. Dar ce s-a întâmplat mai departe? Apoi a venit

invokeWithLayer3#b7475268 query:!X = X;

Deci, ce urmează? Așa cum este ușor de ghicit

invokeWithLayer4#dea0d430 query:!X = X;

Amuzant? Nu, e prea devreme să râzi, gândește-te la ce fiecare o solicitare dintr-un alt strat trebuie să fie înfășurată într-un tip atât de special - dacă le aveți pe toate diferite, cum altfel să le distingeți? Și adăugarea a doar 4 octeți în față este o metodă destul de eficientă. Asa de

invokeWithLayer5#417a57ae query:!X = X;

Dar este evident că după un timp va deveni niște bacanale. Și a venit soluția:

Actualizare: Începând cu Stratul 9, metode de ajutor invokeWithLayerN poate fi folosit împreună cu initConnection

Ura! După 9 versiuni, am ajuns în sfârșit la ceea ce se făcea în protocoalele de internet încă din anii 80 - negocierea versiunii o dată la începutul conexiunii!

Deci ce urmează?...

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

Și acum poți râde. Abia după alte 9 straturi, a fost adăugat în sfârșit un constructor universal cu un număr de versiune, care trebuie apelat o singură dată la începutul conexiunii, iar semnificația din straturi pare să fi dispărut, acum este doar o versiune condiționată, ca oriunde altundeva. Problema rezolvata.

Dreapta?..

Vasily, [16.07.18/14/01 XNUMX:XNUMX PM] Vineri m-am gândit:
Teleserverul trimite evenimente fără o solicitare. Solicitările trebuie să fie împachetate în InvokeWithLayer. Serverul nu împachetează actualizări, nu există nicio structură pentru împachetarea răspunsurilor și actualizărilor.

Acestea. clientul nu poate specifica stratul în care dorește actualizări

Vadim Goncharov, [16.07.18/14/02 XNUMX:XNUMX] InvokeWithLayer nu este o cârja în principiu?

Vasily, [16.07.18/14/02 XNUMX:XNUMX PM] Aceasta este singura cale

Vadim Goncharov, [16.07.18/14/02 XNUMX:XNUMX] care ar trebui să însemne în esență stratificare la începutul sesiunii

Apropo, de aici rezultă că nu este furnizat un downgrade pentru client

Actualizări, adică tip Updates în schemă, acesta este ceea ce serverul trimite clientului nu ca răspuns la o solicitare API, ci pe cont propriu atunci când are loc un eveniment. Acesta este un subiect complex care va fi discutat într-o altă postare, dar deocamdată este important de știut că serverul acumulează Actualizări chiar și atunci când clientul este offline.

Astfel, atunci când refuză să împacheteze fiecare pachet pentru a indica versiunea acestuia, prin urmare apar următoarele probleme posibile:

  • serverul trimite actualizări către client înainte ca acesta să spună ce versiune acceptă
  • ce ar trebui făcut după upgrade-ul clientului?
  • care garanțiică opinia serverului despre numărul stratului nu se va schimba în acest proces?

Credeți că aceasta este o gândire pur teoretică, iar în practică acest lucru nu se poate întâmpla, deoarece serverul este scris corect (în orice caz, este bine testat)? Ha! Indiferent cât de!

Exact asta ne-am lovit în august. Pe 14 august, au apărut mesaje că se actualizează ceva pe serverele Telegram... și apoi în jurnalele:

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.

și apoi câțiva megaocteți de urme de stivă (bine, în același timp, înregistrarea a fost remediată). La urma urmei, dacă ceva nu a fost recunoscut în TL-ul tău - este binar prin semnături, mai departe în flux TOATE merge, decodarea va deveni imposibilă. Ce să faci într-o astfel de situație?

Ei bine, primul lucru care îi vine în minte este să se deconecteze și să încerce din nou. Nu a ajutat. Am căutat pe Google CRC32 - acestea s-au dovedit a fi obiecte din schema 73, deși am lucrat la schema 82. Ne uităm cu atenție la jurnalele - există identificatori din două scheme diferite!

Poate că problema este doar în clientul nostru neoficial? Nu, rulăm Telegram Desktop 1.2.17 (versiunea furnizată cu un număr de distribuții Linux), scrie în jurnalul de excepții: MTP Id-ul tip neașteptat #b5223b0f citit în MTPMessageMedia...

Critică la adresa protocolului și a abordărilor organizaționale ale Telegramului. Partea 1, tehnică: experiență de scriere a unui client de la zero - TL, MT

Google a arătat că o problemă similară s-a întâmplat deja unuia dintre clienții neoficiali, dar apoi numerele de versiune și, în consecință, ipotezele au fost diferite...

Deci ce să fac? Eu și Vasily ne-am despărțit: el a încercat să actualizeze schema la 91, am decis să aștept câteva zile și să încerc să ajung la 73. Ambele metode au funcționat, dar, deoarece sunt empirice, nu se înțelege câte versiuni trebuie să sari în sus. sau jos, nici cât timp trebuie să așteptați.

Mai târziu, am reușit să reproduc situația: pornim clientul, îl oprim, recompilăm schema într-un alt strat, repornim, reparăm problema, revenim la cea anterioară - hopa, fără schimbarea schemei și repornirea clientului pentru mai multe minutele vor ajuta. Veți primi un amestec de structuri de date din diferite straturi.

Explicaţie? După cum puteți ghici din diferitele simptome indirecte, serverul constă din multe tipuri diferite de procese pe diferite mașini. Cel mai probabil, cel din serverele care se ocupă de „buffering” a pus în coadă ce i-au dat cei mai înalți și l-au dat în schema care era la momentul generarii. Și până când această coadă nu a fost „putrezită”, nu s-a mai putut face nimic.

Doar dacă... dar asta e o cârjă groaznică?!.. Nu, înainte de a ne gândi la idei nebunești, să ne uităm la codul clienților oficiali. În versiunea Android, nu găsim niciun parser TL, dar găsim un fișier voluminos (github refuză să-l coloreze) cu (de)serializare. Iată fragmentele de cod:

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;

sau

    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... pare o nebunie. Dar, probabil, acesta este un cod generat, atunci bine? .. Dar cu siguranță acceptă toate versiunile! Adevărat, nu este clar de ce totul este amestecat într-o singură grămadă, discuții secrete și tot felul de _old7 nu seamănă cumva cu generarea de mașini... Cu toate acestea, mai ales de la care am înnebunit

TL_message_layer104
TL_message_layer104_2
TL_message_layer104_3

Băieți, nu vă puteți decide chiar într-un singur strat?! Ei bine, „doi”, să zicem, au fost eliberați cu o eroare, ei bine, se întâmplă, dar TREI? .. Imediat din nou pe aceeași greblă? Ce fel de pornografie este asta, scuze?...

Apropo, un lucru similar se întâmplă în sursele Telegram Desktop - dacă da, și mai multe comite la rând la schemă nu îi schimbă numărul stratului, ci fixează ceva. În condițiile în care nu există o sursă oficială de date pentru schemă, de unde o pot obține, cu excepția surselor oficiale ale clienților? Și o luați de acolo, nu puteți fi sigur că schema este în întregime corectă până când testați toate metodele.

Cum poate fi testat acest lucru? Sper că fanii testelor unitare, funcționale și de altă natură vor împărtăși în comentarii.

Bine, să ne uităm la o altă bucată de cod:

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;

Acest comentariu „creat manual” sugerează că doar o parte a acestui fișier este scrisă de mână (vă puteți imagina coșmarul de întreținere?), iar restul este generat de mașină. Totuși, atunci apare o altă întrebare - că sursele sunt disponibile nu in totalitate (la blobs sub GPL în nucleul Linux), dar acesta este deja un subiect pentru a doua parte.

Dar destul. Să trecem la protocolul pe deasupra căruia se urmărește toată această serializare.

MT Proto

Deci haideți să deschidem descriere generala и descrierea detaliată a protocolului iar primul lucru de care ne împiedicăm este terminologia. Și cu abundență de toate. În general, aceasta pare a fi o marcă comercială a Telegramului - să numiți lucruri în diferite locuri în moduri diferite sau lucruri diferite într-un singur cuvânt sau invers (de exemplu, într-un API de nivel înalt dacă vedeți un pachet de stickere - acest lucru nu este ceea ce credeai).

De exemplu, „mesaj” (mesaj) și „sesiune” (sesiune) - aici înseamnă ceva diferit decât în ​​interfața obișnuită a clientului Telegram. Ei bine, totul este clar cu mesajul, ar putea fi interpretat în termeni de POO sau pur și simplu numit cuvântul „pachet” - acesta este un nivel scăzut, de transport, nu există aceleași mesaje ca în interfață, sunt multe a celor de serviciu. Dar sesiunea... dar pe primul loc.

strat de transport

Primul lucru este transportul. Ni se vor spune despre 5 variante:

  • TCP
  • websocket
  • Websocket prin HTTPS
  • HTTP
  • HTTPS

Vasily, [15.06.18/15/04 XNUMX:XNUMX] Și există și transport UDP, dar nu este documentat

Și TCP în trei variante

Primul este similar cu UDP peste TCP, fiecare pachet include un număr de secvență și un crc
De ce este atât de dureros să citești docuri pe un cărucior?

Ei bine, acum TCP deja în 4 variante:

  • abreviat
  • Intermediar
  • intermediar captusit
  • Complet

Ok, intermediar căptușit pentru MTProxy, acesta a fost adăugat ulterior din cauza evenimentelor cunoscute. Dar de ce încă două versiuni (trei în total), când una ar putea face? Toate cele patru diferă în esență doar în modul de setare a lungimii și a sarcinii utile a MTProto-ului principal real, care va fi discutat în continuare:

  • în Abreviat este de 1 sau 4 octeți, dar nu 0xef, apoi body
  • în Intermediar este de 4 octeți de lungime și un câmp, iar prima dată clientul trebuie să trimită 0xeeeeeeee pentru a indica că este Intermediar
  • in Full, cel mai captivant, din punctul de vedere al unui networker: lungime, numar de secventa, si NU CEL care este practic MTProto, body, CRC32. Da, toate acestea prin TCP. Ceea ce ne oferă un transport fiabil sub forma unui flux serial de octeți, nu sunt necesare secvențe, în special sume de control. Bine, acum voi fi obiectat că TCP are o sumă de control de 16 biți, deci se produce coruperea datelor. Grozav, cu excepția faptului că avem de fapt un protocol criptografic cu hashuri mai lungi de 16 octeți, toate aceste erori - și chiar mai multe - vor fi prinse de o nepotrivire SHA la un nivel superior. Nu are rost în CRC32 în privința asta.

Să comparăm Abreviat, unde este posibil un octet de lungime, cu Intermediate, care justifică „În cazul în care este nevoie de alinierea datelor pe 4 octeți”, ceea ce este destul de prostie. Ce, se crede că programatorii Telegram sunt atât de neîndemânatici încât nu pot citi datele din socket într-un buffer aliniat? Mai trebuie să faceți acest lucru, deoarece citirea vă poate returna orice număr de octeți (și există și servere proxy, de exemplu...). Sau, pe de altă parte, de ce să vă deranjați cu Abridged dacă încă mai avem umpluturi mari de la 16 octeți în partea de sus - economisiți 3 octeți uneori ?

Avem impresia că lui Nikolai Durov îi place foarte mult să inventeze biciclete, inclusiv protocoale de rețea, fără o nevoie practică reală.

Alte opțiuni de transport, incl. Web și MTProxy, nu vom lua în considerare acum, poate într-o altă postare, dacă există o cerere. Ne vom aminti doar acum despre acest MTProxy că, la scurt timp după lansarea sa în 2018, furnizorii au învățat rapid să îl blocheze exact, destinat blocare bypassDe dimensiunea pachetului! Și, de asemenea, faptul că serverul MTProxy scris (din nou de Waltman) în C a fost legat în mod inutil de specificul Linux, deși nu era deloc necesar (va confirma Phil Kulin) și că un server similar fie pe Go, fie pe Node.js se potrivesc mai puțin de o sută de linii.

Dar vom trage concluzii despre alfabetizarea tehnică a acestor oameni la sfârșitul secțiunii, după ce am luat în considerare alte aspecte. Deocamdată, să trecem la al 5-lea strat OSI, sesiune - pe care au plasat sesiunea MTProto.

Chei, mesaje, sesiuni, Diffie-Hellman

L-au pus acolo nu în întregime corect... Sesiunea nu este sesiunea care este vizibilă în interfață sub Sesiuni active. Dar în ordine.

Critică la adresa protocolului și a abordărilor organizaționale ale Telegramului. Partea 1, tehnică: experiență de scriere a unui client de la zero - TL, MT

Aici am primit un șir de octeți de lungime cunoscută de la stratul de transport. Acesta este fie un mesaj criptat, fie text simplu - dacă suntem încă în etapa de negociere a cheii și chiar o facem. Despre care din multitudinea de concepte numite „cheie” vorbim? Să clarificăm această problemă pentru echipa Telegram în sine (îmi cer scuze că am tradus propria documentație din engleză fie într-un creier obosit la 4 dimineața, a fost mai ușor să las câteva fraze așa cum sunt):

Există două entități numite sesiune - unul în UI-ul clienților oficiali sub „sesiuni curente”, unde fiecare sesiune corespunde unui întreg dispozitiv/OS.
Al doilea este Sesiunea MTProto, care are un număr de secvență de mesaj (într-un sens de nivel scăzut) și care poate dura între diferite conexiuni TCP. Mai multe sesiuni MTProto pot fi configurate în același timp, de exemplu, pentru a accelera descărcarea fișierelor.

Între acestea două Sesiunile este conceptul autorizare. În cazul degenerat, se poate spune că sesiune UI este la fel ca autorizareDar, din păcate, e complicat. Ne uitam:

  • Utilizatorul de pe noul dispozitiv generează mai întâi auth_key și îl leagă de cont, de exemplu, prin SMS - de aceea autorizare
  • Sa întâmplat în interiorul primei Sesiunea MTProto, care are session_id în interiorul tău.
  • La acest pas, combinația autorizare и session_id ar putea fi numit instanță - acest cuvant se gaseste in documentatia si codul unor clienti
  • Apoi, clientul poate deschide unele sesiuni MTProto sub acelasi auth_key - la același DC.
  • Apoi, într-o zi, clientul trebuie să solicite un fișier de la un alt DC - iar pentru acest DC va fi generat unul nou auth_key !
  • Pentru a spune sistemului că acesta nu este un utilizator nou care se înregistrează, ci același autorizare (sesiune UI), clientul folosește apeluri API auth.exportAuthorization în DC acasă auth.importAuthorization în noul DC.
  • Totuși, pot fi mai multe deschise sesiuni MTProto (fiecare cu a lui session_id) la acest nou DC, sub lui auth_key.
  • În cele din urmă, clientul poate dori Perfect Forward Secrecy. Fiecare auth_key a fost permanent cheie - pe DC - iar clientul poate suna auth.bindTempAuthKey pentru utilizare temporar auth_key - și din nou, doar unul temp_auth_key per DC, comun tuturor sesiuni MTProto la acest DC.

Rețineți că sare (și săruri viitoare) tot unul pe auth_key acestea. împărțită între toți sesiuni MTProto la același DC.

Ce înseamnă „între diferite conexiuni TCP”? Înseamnă că asta ceva asemănător cu cookie de autorizare pe un site web - persistă (supraviețuiește) multor conexiuni TCP la acest server, dar într-o zi se va defecta. Doar spre deosebire de HTTP, în MTProto, mesajele sunt numerotate și confirmate secvențial în interiorul sesiunii, au intrat în tunel, conexiunea a fost întreruptă - după stabilirea unei noi conexiuni, serverul va trimite cu amabilitate tot ce nu a livrat în această sesiune în precedenta. Conexiune TCP.

Cu toate acestea, informațiile de mai sus sunt o stoarcere după multe luni de litigii. Între timp, implementăm clientul nostru de la zero? - Să ne întoarcem la început.

Deci generăm auth_key pe versiuni ale lui Diffie-Hellman de la Telegram. Să încercăm să înțelegem documentația...

Vasily, [19.06.18/20/05 1:255] data_with_hash := SHAXNUMX(date) + data + (orice octeți aleatori); astfel încât lungimea să fie egală cu XNUMX de octeți;
date_criptate := RSA(date_with_hash, server_public_key); un număr de 255 de octeți (big endian) este ridicat la puterea necesară peste modulul necesar, iar rezultatul este stocat ca un număr de 256 de octeți.

Au niște droguri DH

Nu arată ca DH al unei persoane sănătoase
Nu există două chei publice în dx

Ei bine, în cele din urmă, ne-am dat seama, dar sedimentul a rămas - o dovadă a muncii este făcută de către client că a reușit să factorizeze numărul. Tip de protecție împotriva atacurilor DoS. Și cheia RSA este folosită o singură dată într-o singură direcție, în esență pentru criptare new_nonce. Dar în timp ce această operațiune aparent simplă reușește, cu ce va trebui să faci față?

Vasily, [20.06.18/00/26 XNUMX:XNUMX] Nu am ajuns încă la cererea de aplicație

Am trimis o cerere la DH

Și, în andocarea de pe transport scrie că poate răspunde cu 4 octeți din codul de eroare. Si asta e

Ei bine, mi-a spus -404, deci ce?

Iată-mă la el: „prinde-ți efigna criptată cu cheia de server cu o amprentă de așa și așa, vreau DH”, și răspunde prostesc 404

Ce părere ai despre un astfel de răspuns de server? Ce să fac? Nu există pe cine să întreb (dar mai multe despre asta în partea a doua).

Aici tot interesul în doc este de făcut

Nu am altceva de făcut, am visat doar să convertesc numere înainte și înapoi

Două numere de 32 de biți. Le-am împachetat ca toți ceilalți

Dar nu, de acești doi aveți nevoie mai întâi într-o linie ca BE

Vadim Goncharov, [20.06.18/15/49 404:XNUMX] și din cauza asta XNUMX?

Vasily, [20.06.18/15/49 XNUMX:XNUMX] DA!

Vadim Goncharov, [20.06.18/15/50 XNUMX:XNUMX] deci nu înțeleg ce poate „nu a găsit”

Vasily, [20.06.18 15:50] despre

Nu am găsit o astfel de descompunere în simpli divizori%)

Nici măcar raportarea erorilor nu a fost stăpânită

Vasily, [20.06.18/20/18 5:XNUMX] Oh, există și MDXNUMX. Deja trei hashuri diferite

Amprenta cheii este calculată după cum urmează:

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

SHA1 și sha2

Deci hai sa punem auth_key Am obținut 2048 de biți conform lui Diffie-Hellman. Ce urmeaza? Apoi aflăm că cei 1024 de biți inferiori ai acestei chei nu sunt folosiți în niciun fel... dar să ne gândim la asta pentru moment. La acest pas, avem un secret partajat cu serverul. A fost stabilit un analog al unei sesiuni TLS, o procedură foarte costisitoare. Dar serverul nu știe încă nimic despre cine suntem! Nu încă, de fapt autorizare. Acestea. dacă te-ai gândit în termeni de „login-parolă”, așa cum era în ICQ, sau cel puțin „login-key”, ca în SSH (de exemplu, pe unele gitlab / github). Am devenit anonimi. Și dacă serverul ne răspunde „aceste numere de telefon sunt deservite de un alt DC”? Sau chiar „numărul tău de telefon este interzis”? Cel mai bun lucru pe care îl putem face este să salvăm cheia în speranța că va fi în continuare utilă și nu va fi putrezită până atunci.

Apropo, noi l-am „primit” cu rezerve. De exemplu, avem încredere în server? Este fals? Avem nevoie de verificări criptografice:

Vasily, [21.06.18/17/53 2:XNUMX PM] Ei oferă clienților de telefonie mobilă să verifice un număr de XNUMX kbit pentru simplitate%)

Dar nu e clar deloc, nafeijoa

Vasily, [21.06.18/18/02 XNUMX:XNUMX] Docul nu spune ce să facă dacă s-a dovedit a nu fi simplu

Nu spus. Să vedem ce face clientul oficial pentru Android în acest caz? A asta e ceea ce (și da, întreg fișierul este interesant acolo) - așa cum se spune, îl voi lăsa aici:

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

Nu, desigur acolo unele există verificări pentru simplitatea unui număr, dar personal nu mai am cunoștințe suficiente la matematică.

Bine, avem cheia principală. Pentru a vă autentifica, de ex. trimite cereri, este necesar să se efectueze o criptare suplimentară, folosind deja AES.

Cheia de mesaj este definită ca cei 128 de biți din mijloc ai SHA256 ai corpului mesajului (inclusiv sesiune, ID-ul mesajului etc.), inclusiv octeții de completare, prepuși de 32 de octeți prelevați din cheia de autorizare.

Vasily, [22.06.18/14/08 XNUMX:XNUMX] Cățele medii

A primit auth_key. Toate. Mai departe ei... nu se vede clar din docuri. Simțiți-vă liber să studiați codul open source.

Rețineți că MTProto 2.0 necesită de la 12 la 1024 de octeți de umplutură, tot cu condiția ca lungimea mesajului rezultat să fie divizibilă cu 16 octeți.

Deci, câtă umplutură să pui?

Și da, și aici, 404 în caz de eroare

Dacă cineva a studiat cu atenție diagrama și textul documentației, a observat că acolo nu există MAC. Și acel AES este folosit într-un mod IGE care nu este folosit nicăieri altundeva. Ei, desigur, scriu despre asta în întrebările frecvente... Aici, de exemplu, cheia mesajului în sine este, în același timp, hash-ul SHA al datelor decriptate folosit pentru a verifica integritatea - iar în cazul unei nepotriviri, documentația pentru un motiv oarecare recomandă ignorarea lor în tăcere (dar cum rămâne cu securitatea, ne sparge brusc?).

Nu sunt criptograf, poate in acest mod in acest caz nu este nimic in neregula din punct de vedere teoretic. Dar cu siguranță pot numi o problemă practică, folosind exemplul Telegram Desktop. Criptează memoria cache locală (toate aceste D877F783D5D3EF8C) în același mod ca mesajele din MTProto (doar în acest caz, versiunea 1.0), adică. mai întâi cheia mesajului, apoi datele în sine (și undeva în afară de principalul big auth_key 256 de octeți, fără de care msg_key inutil). Deci, problema devine vizibilă pe fișiere mari. Și anume, trebuie să păstrați două copii ale datelor - criptate și decriptate. Și dacă există megaocteți, sau streaming video, de exemplu? .. Schemele clasice cu MAC după textul cifrat vă permit să îl citiți în flux, transferându-l imediat. Și cu MTProto trebuie la început criptați sau decriptați întregul mesaj, abia apoi transferați-l în rețea sau pe disc. Prin urmare, în cele mai recente versiuni ale Telegram Desktop în memoria cache user_data un alt format este deja folosit - cu AES în modul CTR.

Vasily, [21.06.18/01/27 20:XNUMX AM] Oh, am aflat ce este IGE: IGE a fost prima încercare de „mod de criptare de autentificare”, inițial pentru Kerberos. A fost o încercare eșuată (nu oferă protecție a integrității) și a trebuit să fie eliminată. Acesta a fost începutul unei căutări de XNUMX de ani pentru un mod de criptare de autentificare care funcționează, care a culminat recent cu moduri precum OCB și GCM.

Și acum argumentele din partea căruciorului:

Echipa din spatele Telegramului, condusă de Nikolai Durov, este formată din șase campioni ACM, jumătate dintre ei doctori în matematică. Le-a luat aproximativ doi ani pentru a lansa versiunea actuală a MTProto.

Ce e amuzant. Doi ani până la nivelul inferior

Sau am putea doar să luăm tls

Bine, să presupunem că am făcut criptare și alte nuanțe. Putem trimite în sfârșit solicitări serializate TL și deserializați răspunsurile? Deci, ce ar trebui trimis și cum? Iată metoda initConnectionpoate asta este?

Vasily, [25.06.18/18/46 XNUMX:XNUMX PM] Inițializează conexiunea și salvează informații pe dispozitivul și aplicația utilizatorului.

Acceptă app_id, device_model, system_version, app_version și lang_code.

Și o întrebare

Documentatie ca intotdeauna. Simțiți-vă liber să studiați sursa deschisă

Dacă totul a fost aproximativ clar cu invokeWithLayer, atunci ce este? Se pare că, să presupunem că avem - clientul a avut deja ceva despre care să întrebe serverul - există o solicitare pe care am vrut să o trimitem:

Vasily, [25.06.18/19/13 XNUMX:XNUMX] Judecând după cod, primul apel este învelit în acest gunoi, iar gunoiul în sine este în invokewithlayer

De ce initConnection nu ar putea fi un apel separat, dar trebuie să fie un wrapper? Da, după cum sa dovedit, trebuie făcut de fiecare dată la începutul fiecărei sesiuni și nu o singură dată, ca în cazul cheii principale. Dar! Nu poate fi apelat de un utilizator neautorizat! Aici am ajuns la stadiul în care este aplicabil Aceasta pagina de documentație - și ne spune că...

Doar o mică parte din metodele API sunt disponibile utilizatorilor neautorizați:

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

Chiar primul dintre ei auth.sendCode, și există acea primă cerere prețuită în care vom trimite api_id și api_hash, iar după care primim un SMS cu un cod. Și dacă am ajuns la DC greșit (numerele de telefon ale acestei țări sunt deservite de alta, de exemplu), atunci vom primi o eroare cu numărul DC dorit. Pentru a afla la ce adresă IP trebuie să ne conectăm prin numărul DC, vom fi ajutați de help.getConfig. Odată erau doar 5 înscrieri, dar după binecunoscutele evenimente din 2018, numărul a crescut semnificativ.

Acum să ne amintim că am ajuns în această etapă pe serverul anonim. Nu este prea scump să obții doar o adresă IP? De ce să nu faceți aceasta și alte operațiuni în partea necriptată a MTProto? Aud o obiecție: „cum poți să te asiguri că nu RKN va răspunde cu adrese false?”. Pentru aceasta ne amintim că, de fapt, în clienții oficiali chei RSA încorporate, adică poti doar semn aceasta informatie. De fapt, acest lucru se face deja pentru informații despre ocolirea încuietorilor, pe care clienții le primesc prin alte canale (este logic că acest lucru nu se poate face chiar în MTProto, pentru că mai trebuie să știți unde să vă conectați).

BINE. În această etapă de autorizare a clientului, nu suntem încă autorizați și nu ne-am înregistrat aplicația. Vrem doar să vedem deocamdată ce răspunde serverul la metodele disponibile unui utilizator neautorizat. Si aici…

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

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

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

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

În schemă vine primul, al doilea

În schema tdesktop, a treia valoare este

Da, de atunci, desigur, documentația a fost actualizată. Deși în curând poate deveni din nou irelevant. Și de unde ar trebui să știe un dezvoltator începător? Poate dacă vă înregistrați aplicația, vă vor informa ei? Vasili a făcut asta, dar, din păcate, nu i s-a trimis nimic (din nou, vom vorbi despre asta în a doua parte).

... Ați observat că deja ne-am mutat cumva la API, adică. la nivelul următor și ai ratat ceva în tema MTProto? Nimic surprinzător:

Vasily, [28.06.18/02/04 2:XNUMX AM] Mm, scotocesc printre algoritmii de pe eXNUMXe

Mtproto definește algoritmi și chei de criptare pentru ambele domenii, precum și o structură de înveliș

Dar amestecă în mod constant diferite niveluri de stivă, așa că nu este întotdeauna clar unde s-a încheiat mtproto și unde a început următorul nivel.

Cum se amestecă? Ei bine, aici este aceeași cheie temporară pentru PFS, de exemplu (apropo, Telegram Desktop nu știe cum să o facă). Este executat printr-o solicitare API auth.bindTempAuthKey, adică de la nivelul superior. Dar, în același timp, interferează cu criptarea la nivelul inferior - după aceasta, de exemplu, trebuie să o faceți din nou initConnection etc., asta nu este doar cerere normală. Separat, oferă, de asemenea, că puteți avea doar O cheie temporară pe DC, deși câmpul auth_key_id în fiecare mesaj vă permite să schimbați cheia cel puțin la fiecare mesaj și că serverul are dreptul de a „uita” cheia temporară în orice moment - ce să faceți în acest caz, documentația nu spune ... bine, de ce nu ar fi posibil să avem mai multe chei, ca la un set de săruri viitoare, dar ?..

Există alte câteva lucruri care merită remarcate în tema MTProto.

Mesaje mesaje, msg_id, msg_seqno, recunoașteri, ping-uri în direcția greșită și alte idiosincrazii

De ce trebuie să știi despre ei? Pentru că „scurge” cu un nivel mai sus și trebuie să știți despre ele atunci când lucrați cu API-ul. Să presupunem că nu ne interesează msg_key, nivelul inferior a decriptat totul pentru noi. Dar în interiorul datelor decriptate, avem următoarele câmpuri (de asemenea, lungimea datelor pentru a ști unde este umplutura, dar acest lucru nu este important):

  • sare-int64
  • session_id - int64
  • mesaj_id - int64
  • seq_no-int32

Amintiți-vă că sarea este una pentru întregul DC. De ce știi despre ea? Nu numai pentru că există o cerere get_future_salts, care spune ce intervale vor fi valabile, dar și pentru că dacă sarea ta este „putrezită”, atunci mesajul (cererea) se va pierde pur și simplu. Serverul va raporta desigur noua sare prin emiterea new_session_created - dar cu cel vechi va trebui să retrimiteți cumva, de exemplu. Și această întrebare afectează arhitectura aplicației.

Serverului i se permite să renunțe la sesiuni și să răspundă în acest fel din mai multe motive. De fapt, ce este o sesiune MTProto din partea clientului? Acestea sunt două numere session_id и seq_no mesajele din cadrul acestei sesiuni. Ei bine, și conexiunea TCP de bază, desigur. Să presupunem că clientul nostru încă nu știe să facă o mulțime de lucruri, deconectat, reconectat. Dacă acest lucru s-a întâmplat rapid - vechea sesiune a continuat în noua conexiune TCP, crește seq_no mai departe. Dacă durează mult, serverul l-ar putea șterge, pentru că pe partea lui este și coadă, după cum am aflat.

Ce ar trebui să fie seq_no? Oh, asta e o întrebare dificilă. Încearcă să înțelegi cu sinceritate ce a vrut să spună:

Mesaj legat de conținut

Un mesaj care necesită o confirmare explicită. Acestea includ toate mesajele utilizatorului și multe servicii, practic toate cu excepția containerelor și a confirmărilor.

Numărul secvenței mesajului (msg_seqno)

Un număr de 32 de biți egal cu dublul numărului de mesaje „relative cu conținutul” (cele care necesită confirmare, și în special cele care nu sunt containere) create de expeditor înainte de acest mesaj și ulterior incrementate cu unu dacă mesajul curent este un mesaj legat de conținut. Un container este întotdeauna generat după întregul său conținut; prin urmare, numărul său de ordine este mai mare sau egal cu numerele de ordine ale mesajelor conținute în acesta.

Ce fel de circ este acesta cu un increment de 1 și apoi încă 2? .. Bănuiesc că sensul inițial a fost „bit scăzut pentru ACK, restul este un număr”, dar rezultatul nu este tocmai corect - în special, rezultă că poate fi trimis unele confirmări care au la fel seq_no! Cum? Ei bine, de exemplu, serverul ne trimite ceva, trimite, iar noi înșine tăcem, răspundem doar cu mesaje de confirmare a serviciului despre primirea mesajelor lui. În acest caz, confirmările noastre de ieșire vor avea același număr de ieșire. Dacă sunteți familiarizat cu TCP și credeți că acest lucru sună cam nebunesc, dar pare să nu fie foarte sălbatic, pentru că în TCP seq_no nu se schimbă, iar confirmarea merge la seq_no partea cealaltă – apoi mă grăbesc să supăr. Confirmările vin la MTProto NU pe seq_no, ca în TCP, dar msg_id !

Ce este asta msg_id, cel mai important dintre aceste domenii? ID-ul unic al mesajului, după cum sugerează și numele. Este definit ca un număr de 64 de biți, dintre care biții cei mai puțin semnificativi au din nou magie server-nu-server, iar restul este un marcaj de timp Unix, inclusiv partea fracțională, deplasată cu 32 de biți la stânga. Acestea. marca temporală în sine (și mesajele cu ore prea diferite vor fi respinse de server). Din aceasta rezultă că, în general, acesta este un identificator care este global pentru client. În timp ce - amintește-ți session_id - suntem garantati: În niciun caz un mesaj destinat unei sesiuni nu poate fi trimis într-o sesiune diferită. Adică, se dovedește că există deja trei nivel — sesiune, numărul sesiunii, id-ul mesajului. De ce o astfel de complicație, acest mister este foarte mare.

Astfel, msg_id necesar pentru…

RPC: solicitări, răspunsuri, erori. Confirmări.

După cum probabil ați observat, nu există niciun tip sau funcție specială „efectuați o solicitare RPC” nicăieri în schemă, deși există răspunsuri. La urma urmei, avem mesaje legate de conținut! Acesta este, любое mesajul poate fi o cerere! Sau să nu fii. La urma urmelor, fiecare există msg_id. Și iată răspunsurile:

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

Aici este indicat la ce mesaj acesta este un răspuns. Prin urmare, la nivelul superior al API-ului, va trebui să vă amintiți ce număr avea solicitarea dvs. - cred că nu este necesar să explicați că lucrarea este asincronă și pot exista mai multe solicitări în același timp, răspunsurile la care poate fi returnat in orice ordine? În principiu, din aceasta și din mesaje de eroare precum niciun lucrător, arhitectura din spatele acestui lucru poate fi urmărită: serverul care menține o conexiune TCP cu dvs. este un echilibrator front-end, direcționează cererile către backend și le colectează înapoi. message_id. Totul pare să fie clar, logic și bun aici.

Da?.. Și dacă te gândești bine? La urma urmei, răspunsul RPC în sine are și un câmp msg_id! Trebuie să strigăm la server „nu răspunzi la răspunsul meu!”? Și da, ce a fost acolo cu confirmarea? Despre pagina mesaje despre mesaje ne spune ce este

msgs_ack#62d6b459 msg_ids:Vector long = MsgsAck;

și fiecare parte trebuie să o facă. Dar nu in totdeauna! Dacă primiți un RpcResult, acesta servește ca o confirmare în sine. Adică, serverul poate răspunde la cererea dvs. cu MsgsAck - cum ar fi, „L-am primit”. Poate răspunde imediat la RpcResult. Ar putea fi ambele.

Și da, mai trebuie să răspunzi la răspuns! Confirmare. În caz contrar, serverul îl va considera nelivrat și ți-l va arunca din nou. Chiar și după reconectare. Dar aici, desigur, se va pune problema timeout-urilor. Să ne uităm la ele puțin mai târziu.

Între timp, să luăm în considerare posibilele erori în execuția interogării.

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

Oh, cineva va exclama, iată un format mai uman - există o linie! Nu vă grăbiţi. Aici lista de eroridar cu siguranță nu complet. Din aceasta aflăm că codul este − ceva asemănător cu Erori HTTP (bine, desigur, semantica răspunsurilor nu este respectată, în unele locuri sunt distribuite prin coduri la întâmplare), iar șirul arată ca CAPITAL_LETTERS_AND_NUMBERS. De exemplu, PHONE_NUMBER_OCCUPIED sau FILE_PART_X_MISSING. Ei bine, adică, mai trebuie să faci această linie analiza. De exemplu FLOOD_WAIT_3600 va însemna că trebuie să așteptați o oră și PHONE_MIGRATE_5ca numărul de telefon cu acest prefix să fie înregistrat în al 5-lea DC. Avem un limbaj tip, nu? Nu avem nevoie de un argument din șir, expresiile regulate vor face, cho.

Din nou, acest lucru nu este pe pagina de mesaje de serviciu, dar, așa cum este deja obișnuit cu acest proiect, informațiile pot fi găsite pe altă pagină de documentație. sau trezesc suspiciuni. În primul rând, uite, încălcarea tastării / straturi - RpcError poate fi investit RpcResult. De ce nu afară? Ce nu am luat în calcul?.. În consecință, unde este garanția că RpcError nu poate fi investit RpcResult, dar să fie direct sau imbricat într-un alt tip? îi lipsește req_msg_id ? ..

Dar să continuăm despre mesajele de serviciu. Clientul poate considera că serverul se gândește mult timp și poate face o cerere atât de minunată:

rpc_drop_answer#58e4a740 req_msg_id:long = RpcDropAnswer;

Există trei răspunsuri posibile la acesta, intersectându-se din nou cu mecanismul de confirmare, pentru a încerca să înțelegem care ar trebui să fie acestea (și care este lista de tipuri care nu necesită confirmare în general), cititorul este lăsat ca temă pentru acasă (notă: informațiile din sursele Telegram Desktop nu sunt complete).

Dependență: starea mesajelor

În general, multe locuri din TL, MTProto și Telegram în general lasă un sentiment de încăpățânare, dar din politețe, tact și altele soft skills am tăcut politicos despre asta, iar obscenitățile din dialoguri au fost cenzurate. Totuși, acest locОcea mai mare parte a paginii despre mesaje despre mesaje provoacă șoc chiar și pentru mine, care lucrez de mult timp cu protocoale de rețea și am văzut biciclete cu diferite grade de curbură.

Începe inofensiv, cu confirmări. În continuare, ni se spune despre

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;

Ei bine, toți cei care încep să lucreze cu MTProto vor trebui să le înfrunte, în ciclul „corectat - recompilat - lansat”, obținerea de erori de număr sau sare care s-a putrezit în timpul editărilor este un lucru obișnuit. Cu toate acestea, există două puncte aici:

  1. Rezultă că mesajul original este pierdut. Trebuie să îngrădim niște cozi, vom lua în considerare acest lucru mai târziu.
  2. Care sunt acele numere de eroare ciudate? 16, 17, 18, 19, 20, 32, 33, 34, 35, 48, 64... unde sunt restul numerelor, Tommy?

În documentație se precizează:

Intenția este ca valorile error_code să fie grupate (error_code >> 4): de exemplu, codurile 0x40 - 0x4f corespund erorilor în descompunerea containerului.

dar, în primul rând, o schimbare în cealaltă direcție și, în al doilea rând, nu contează unde sunt restul codurilor? În capul autorului?.. Totuși, acestea sunt fleacuri.

Dependența începe în mesajele de stare a postării și în copiile postate:

  • Solicitați informații despre starea mesajului
    Dacă oricare dintre părți nu a primit informații despre starea mesajelor sale trimise o perioadă de timp, aceasta poate solicita în mod explicit celeilalte părți:
    msgs_state_req#da69fb52 msg_ids:Vector long = MsgsStateReq;
  • Mesaj informativ privind starea mesajelor
    msgs_state_info#04deb57d req_msg_id:long info:string = MsgsStateInfo;
    Aici, info este un șir care conține exact un octet de stare a mesajului pentru fiecare mesaj din lista msg_ids primită:

    • 1 = nu se știe nimic despre mesaj (msg_id prea mic, cealaltă parte poate să fi uitat)
    • 2 = mesaj neprimit (msg_id se încadrează în intervalul de identificatori stocați; totuși, cealaltă parte cu siguranță nu a primit un astfel de mesaj)
    • 3 = mesajul nu a fost primit (msg_id prea mare; totuși, cealaltă parte cu siguranță nu l-a primit încă)
    • 4 = mesaj primit (rețineți că acest răspuns este în același timp și o confirmare de primire)
    • +8 = mesajul a fost deja confirmat
    • +16 = mesaj care nu necesită confirmare
    • +32 = interogare RPC conținută în mesajul în curs de procesare sau procesare deja finalizată
    • +64 = răspuns legat de conținut la mesajul deja generat
    • +128 = cealaltă parte știe cu siguranță că mesajul a fost deja primit
      Acest răspuns nu necesită o confirmare. Este o recunoaștere a msgs_state_req relevante, în sine.
      Rețineți că, dacă se dovedește brusc că cealaltă parte nu are un mesaj care pare că i-a fost trimis, mesajul poate fi pur și simplu retrimis. Chiar dacă cealaltă parte ar trebui să primească două copii ale mesajului în același timp, duplicatul va fi ignorat. (Dacă a trecut prea mult timp, iar msg_id-ul original nu mai este valid, mesajul trebuie împachetat în msg_copy).
  • Comunicarea voluntară a stării mesajelor
    Oricare dintre părți poate informa voluntar cealaltă parte despre starea mesajelor transmise de cealaltă parte.
    msgs_all_info#8cc0d131 msg_ids:Vector long info:string = MsgsAllInfo
  • Comunicarea voluntară extinsă a stării unui mesaj
    ...
    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;
  • Solicitare explicită de retrimitere a mesajelor
    msg_resend_req#7d861a08 msg_ids:Vector long = MsgResendReq;
    Partea de la distanță răspunde imediat prin retrimiterea mesajelor solicitate […]
  • Solicitare explicită de retrimitere a răspunsurilor
    msg_resend_ans_req#8610baeb msg_ids:Vector long = MsgResendReq;
    Partea de la distanță răspunde imediat prin retrimitere răspunsuri la mesajele solicitate […]
  • Copii mesaje
    În unele situații, un mesaj vechi cu un msg_id care nu mai este valid trebuie să fie retrimis. Apoi, este împachetat într-un container de copiere:
    msg_copy#e06046b2 orig_message:Message = MessageCopy;
    Odată primit, mesajul este procesat ca și cum wrapper-ul nu ar fi acolo. Cu toate acestea, dacă se știe cu siguranță că mesajul orig_message.msg_id a fost primit, atunci noul mesaj nu este procesat (în timp ce, în același timp, acesta și orig_message.msg_id sunt confirmate). Valoarea orig_message.msg_id trebuie să fie mai mică decât msg_id al containerului.

Să păstrăm chiar tăcerea în legătură cu faptul că în msgs_state_info din nou, urechile TL neterminate ies în afară (aveam nevoie de un vector de octeți, iar în cei doi biți inferiori ai enum și în steagurile de biți mai vechi). Ideea este altceva. Înțelege cineva de ce toate acestea sunt în practică în client real necesar?.. Cu dificultate, dar vă puteți imagina un beneficiu dacă o persoană este angajată în depanare și într-un mod interactiv - întrebați serverul ce și cum. Dar cererile sunt descrise aici dus-întors.

De aici rezultă că fiecare parte nu trebuie doar să cripteze și să trimită mesaje, ci și să stocheze date despre acestea, despre răspunsurile la acestea și pentru o perioadă de timp necunoscută. Documentația nu descrie momentele sau aplicabilitatea practică a acestor caracteristici. în nici un fel. Ceea ce este cel mai surprinzător este că sunt de fapt folosite în codul clienților oficiali! Se pare că li s-a spus ceva ce nu era inclus în documentația deschisă. Înțelegeți din cod de ce, nu mai este la fel de simplu ca în cazul TL - aceasta nu este o parte (comparativ) izolată logic, ci o piesă legată de arhitectura aplicației, i.e. va necesita mult mai mult timp pentru a înțelege codul aplicației.

Ping-uri și cronometre. Cozile.

Din toate, dacă vă amintiți ipotezele despre arhitectura serverului (distribuirea cererilor pe backend-uri), urmează un lucru destul de plictisitor - în ciuda tuturor garanțiilor de livrare care în TCP (fie datele au fost livrate, fie veți fi informat despre pauză, dar datele vor fi livrate până în momentul problemei), că confirmările în MTProto însuși - fara garantii. Serverul poate pierde sau arunca cu ușurință mesajul dvs. și nu se poate face nimic în acest sens, doar pentru a îngrădi cârjele de diferite tipuri.

Și în primul rând - cozile de mesaje. Ei bine, în primul rând, totul a fost evident încă de la început - un mesaj neconfirmat trebuie stocat și retrimis. Și după ce oră? Iar bufonul îl cunoaște. Poate că acele mesaje de serviciu dependente rezolvă cumva această problemă cu cârje, să zicem, în Telegram Desktop există aproximativ 4 cozi corespunzătoare acestora (poate mai multe, după cum am menționat deja, pentru aceasta trebuie să vă aprofundați în codul și arhitectura sa mai serios; în același timp timp, știm că nu poate fi luată ca probă, un anumit număr de tipuri din schema MTProto nu sunt folosite în ea).

De ce se întâmplă asta? Probabil, programatorii serverului nu au putut asigura fiabilitatea în cluster, sau cel puțin chiar și buffering-ul frontal, și au mutat această problemă către client. Din disperare, Vasily a încercat să implementeze o opțiune alternativă, cu doar două cozi, folosind algoritmi de la TCP - măsurarea RTT către server și ajustarea dimensiunii „ferestrei” (în mesaje) în funcție de numărul de solicitări neconfirmate. Adică, o euristică atât de grosieră pentru estimarea încărcării serverului - câte dintre solicitările noastre poate mesteca în același timp și nu pierde.

Ei bine, asta înseamnă, înțelegi, nu? Dacă trebuie să implementați din nou TCP pe deasupra unui protocol care funcționează peste TCP, acest lucru indică un protocol foarte prost conceput.

Da, de ce este nevoie de mai mult de o coadă și, în general, ce înseamnă asta pentru o persoană care lucrează cu un API de nivel înalt? Uite, faci o cerere, o serializezi, dar de multe ori este imposibil să o trimiți imediat. De ce? Pentru că răspunsul va fi msg_id, care este temporarаSunt o etichetă, a cărei numire este mai bine să o amânăm cât mai târziu posibil - dintr-o dată serverul o va respinge din cauza unei nepotriviri de timp între noi și acesta (desigur, putem face o cârjă care să ne schimbe timpul din prezent la timpul serverului prin adăugarea unei delte calculate din răspunsurile serverului - clienții oficiali fac acest lucru, dar această metodă este brută și inexactă din cauza tamponării). Deci, atunci când faceți o solicitare cu un apel de funcție local din bibliotecă, mesajul trece prin următoarele etape:

  1. Se află în aceeași coadă și așteaptă criptarea.
  2. Numit msg_id iar mesajul a mers la o altă coadă - posibilă redirecționare; trimite la socket.
  3. a) Serverul a răspuns MsgsAck - mesajul a fost livrat, îl ștergem din „cealaltă coadă”.
    b) Sau invers, nu i-a plăcut ceva, a răspuns badmsg - retrimitem din „cealaltă coadă”
    c) Nu se știe nimic, este necesar să retrimiteți mesajul din altă coadă – dar nu se știe exact când.
  4. Serverul a răspuns în sfârșit RpcResult - răspunsul real (sau eroarea) - nu doar livrat, ci și procesat.

Poate, utilizarea containerelor ar putea rezolva parțial problema. Acesta este momentul în care o grămadă de mesaje sunt împachetate într-unul singur, iar serverul a răspuns cu o confirmare tuturor simultan, cu unul msg_id. Dar el va respinge și acest pachet, dacă ceva nu a mers prost, și tot.

Și în acest moment intră în joc considerente non-tehnice. Din experiență, am văzut multe cârje și, în plus, acum vom vedea mai multe exemple de sfaturi proaste și arhitectură - în astfel de condiții, merită să avem încredere și să luăm astfel de decizii? Întrebarea este retorică (desigur că nu).

Despre ce vorbim? Dacă la subiectul „mesaje dependente despre mesaje” poți să specula în continuare cu obiecții precum „ești prost, nu ai înțeles ideea noastră genială!” (deci mai întâi scrieți documentația, așa cum ar trebui oamenii obișnuiți, cu argumente și exemple de schimb de pachete, apoi vom vorbi), apoi timing-urile / timeout-urile sunt o problemă pur practică și specifică, totul se știe de mult aici. Dar ce ne spune documentația despre timeout-uri?

Un server confirmă de obicei primirea unui mesaj de la un client (în mod normal, o interogare RPC) folosind un răspuns RPC. Dacă un răspuns este întârziat, un server poate trimite mai întâi o confirmare de primire și ceva mai târziu, răspunsul RPC în sine.

Un client confirmă în mod normal primirea unui mesaj de la un server (de obicei, un răspuns RPC) adăugând o confirmare la următoarea interogare RPC dacă nu este transmis prea târziu (dacă este generat, să zicem, la 60-120 de secunde de la primire). a unui mesaj de la server). Totuși, dacă pentru o perioadă lungă de timp nu există niciun motiv pentru a trimite mesaje către server sau dacă există un număr mare de mesaje nerecunoscute de la server (să zicem, peste 16), clientul transmite o confirmare de sine stătătoare.

... Traduc: noi înșine nu știm cât și cum este necesar, ei bine, să estimam că să fie așa.

Și despre ping-uri:

Mesaje Ping (PING/PONG)

ping#7abe77ec ping_id:long = Pong;

Un răspuns este de obicei returnat la aceeași conexiune:

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

Aceste mesaje nu necesită confirmare. Un ping este transmis doar ca răspuns la un ping, în timp ce un ping poate fi inițiat de ambele părți.

Închiderea conexiunii amânate + PING

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

Funcționează ca ping. În plus, după ce acesta este primit, serverul pornește un cronometru care va închide conexiunea curentă disconnect_delay câteva secunde mai târziu, cu excepția cazului în care primește un mesaj nou de același tip care resetează automat toate cronometrele anterioare. Dacă clientul trimite aceste ping-uri o dată la 60 de secunde, de exemplu, poate seta disconnect_delay egal cu 75 de secunde.

Ti-ai iesit din minti?! În 60 de secunde, trenul va intra în gară, va lăsa și va prelua pasagerii și va pierde din nou comunicarea în tunel. În 120 de secunde, în timp ce ești afară, el va ajunge la altul și, cel mai probabil, conexiunea se va rupe. Ei bine, este clar de unde cresc picioarele - „Am auzit un sunet, dar nu știu unde este”, există algoritmul lui Nagle și opțiunea TCP_NODELAY, care a fost destinată lucrului interactiv. Dar, scuze, amânați valoarea implicită - 200 Millisecunde. Dacă într-adevăr doriți să prezentați ceva similar și să economisiți pe o posibilă pereche de pachete - ei bine, amânați-o, cel puțin 5 secunde, sau oricare ar fi timpul de expirare a mesajului „Utilizatorul tastează...” acum este egal cu. Dar nu mai mult.

Și în sfârșit, ping-uri. Adică, verificarea vivacității unei conexiuni TCP. E amuzant, dar acum vreo 10 ani am scris un text critic despre mesagerul căminului facultății noastre - acolo autorii au făcut și ping server de la client, și nu invers. Dar studenții din anul trei sunt una, iar un birou internațional este alta, nu?

În primul rând, un mic program educațional. O conexiune TCP, în absența schimbului de pachete, poate dura săptămâni. Acest lucru este atât bun, cât și rău, în funcție de scop. Ei bine, dacă aveai deschisă o conexiune SSH la server, te-ai ridicat de pe computer, ai repornit routerul de alimentare, te-ai întors la locul tău - sesiunea prin acest server nu s-a întrerupt (nu a scris nimic, nu au existat pachete), convenabil. Este rău dacă există mii de clienți pe server, fiecare ocupă resurse (bună ziua Postgres!), iar gazda client poate să fi repornit cu mult timp în urmă - dar nu vom ști despre asta.

Sistemele de chat/IM aparțin celui de-al doilea caz dintr-un alt motiv suplimentar - stările online. Dacă utilizatorul „a căzut”, este necesar să-și informeze interlocutorii despre aceasta. În caz contrar, va fi o greșeală pe care creatorii lui Jabber au făcut-o (și au corectat-o ​​timp de 20 de ani) - utilizatorul s-a deconectat, dar continuă să-i scrie mesaje, crezând că este online (care s-au pierdut și ele complet în aceste câteva minute înainte). s-a descoperit ruptura). Nu, opțiunea TCP_KEEPALIVE, pe care mulți oameni care nu înțeleg cum funcționează cronometrele TCP, apare oriunde (prin setarea valorilor sălbatice, cum ar fi zeci de secunde), nu va ajuta aici - trebuie să vă asigurați că nu numai nucleul sistemului de operare al mașina utilizatorului este vie, dar funcționează și normal, în măsură să răspundă, iar aplicația în sine (credeți că nu poate îngheța? Telegram Desktop pe Ubuntu 18.04 s-a blocat pentru mine în mod repetat).

De aceea ar trebui să dați ping serverului client, și nu invers - dacă clientul face acest lucru, atunci când conexiunea este întreruptă, ping-ul nu va fi livrat, obiectivul nu este atins.

Și ce vedem în Telegram? Totul este exact invers! Ei bine, adică în mod formal, desigur, ambele părți se pot ping reciproc. În practică, clienții folosesc o cârjă ping_delay_disconnect, care pune un cronometru pe server. Ei bine, scuze, nu este treaba clientului să decidă cât timp vrea să locuiască acolo fără ping. Serverul, pe baza încărcării sale, știe mai bine. Dar, desigur, dacă nu vă pare rău pentru resurse, atunci răufăcătorii Pinocchio sunt ei înșiși, iar cârja va cădea...

Cum ar fi trebuit să fie proiectat?

Cred că faptele de mai sus indică destul de clar competența nu foarte ridicată a echipei Telegram / VKontakte în domeniul transportului (și nivelului inferior) al rețelelor de calculatoare și calificarea lor scăzută în chestiuni relevante.

De ce s-a dovedit atât de complicat și cum pot încerca arhitecții Telegram să se opună? Faptul că au încercat să facă o sesiune care supraviețuiește întreruperilor conexiunii TCP, adică ceea ce nu am livrat acum, îl vom livra mai târziu. Probabil că au încercat să facă și transport UDP, deși au întâmpinat dificultăți și l-au abandonat (de aceea documentația este goală - nu era cu ce să se laude). Dar din cauza lipsei de înțelegere a modului în care funcționează rețelele în general și TCP în special, unde vă puteți baza pe ele și unde trebuie să o faceți singur (și cum) și încearcă să combine acest lucru cu criptografie „o singură lovitură din două păsări cu o singură piatră” - s-a dovedit un astfel de cadavru.

Cum ar fi trebuit să fie? Pe baza faptului că msg_id este un marcaj temporal care este necesar din punct de vedere criptografic pentru a preveni atacurile de reluare, este o eroare să îi atașați o funcție de identificare unică. Prin urmare, fără a schimba drastic arhitectura actuală (când se formează firul de Actualizări, acesta este un subiect API de nivel înalt pentru o altă parte a acestei serii de postări), ar trebui să:

  1. Serverul care deține conexiunea TCP la client își asumă responsabilitatea - dacă ați scăzut din socket, vă rugăm să confirmați, să procesați sau să returnați o eroare, fără pierderi. Apoi, confirmarea nu este un vector de id-uri, ci pur și simplu "ultimul primit seq_no" - doar un număr, ca în TCP (două numere - propriul dvs. seq și confirmat). Suntem mereu în sesiune, nu-i așa?
  2. Marca temporală pentru a preveni atacurile de reluare devine un câmp separat, a la nonce. Verificat, dar nimic altceva nu este afectat. Destul și uint32 - dacă sarea noastră se schimbă cel puțin la fiecare jumătate de zi, putem aloca 16 biți biților inferiori ai părții întregi a timpului curent, restul - părții fracționale de secundă (cum este acum).
  3. Retras msg_id la toate - din punctul de vedere al distingerii cererilor pe backend-uri, există, în primul rând, id-ul clientului și, în al doilea rând, id-ul sesiunii și le concatenează. În consecință, ca identificator de cerere, este suficient doar unul seq_no.

De asemenea, nu este cea mai bună opțiune, o aleatorie completă ar putea servi drept identificator - acest lucru se face deja în API-ul de nivel înalt atunci când trimiteți un mesaj, apropo. Ar fi mai bine să refacem arhitectura de la relativ la absolut, dar acesta este un subiect pentru altă parte, nu pentru această postare.

API?

Ta-daam! Așadar, după ce ne-am făcut drum printr-o cale plină de durere și cârje, am reușit în sfârșit să trimitem orice cereri către server și să primim orice răspunsuri la acestea, precum și să primim actualizări de la server (nu ca răspuns la o solicitare, ci ne trimite în sine, cum ar fi PUSH, dacă cineva atât de clar).

Atenție, acum va fi singurul exemplu Perl din articol! (pentru cei care nu sunt familiarizați cu sintaxa, primul argument de binecuvântat este structura de date a obiectului, al doilea este clasa sa):

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

Da, mai ales nu sub spoiler - dacă nu ai citit-o, du-te și fă-o!

Oh, wai~~... cum arată? Ceva foarte familiar... poate aceasta este structura de date a unui API Web tipic în JSON, cu excepția faptului că clasele au fost atașate la obiecte?...

Deci se dovedește... Ce este, tovarăși? .. Atât de mult efort - și ne-am oprit să ne odihnim acolo unde programatorii Web abia incepand?.. Nu ar fi simplu JSON prin HTTPS?! Și ce am primit în schimb? Au meritat aceste eforturi?

Să evaluăm ce ne-a oferit TL+MTProto și ce alternative sunt posibile. Ei bine, HTTP cerere-răspuns este o potrivire proastă, dar măcar ceva în plus față de TLS?

serializare compactă. Văzând această structură de date, similară cu JSON, se reamintește că există variantele sale binare. Să marchem MsgPack ca fiind insuficient extensibil, dar există, de exemplu, CBOR - apropo, standardul descris în RFC 7049. Se remarcă prin faptul că definește Etichete, ca mecanism de extensie, și printre deja standardizate Sunt:

  • 25 + 256 - înlocuirea liniilor duplicate cu o referință de număr de linie, o metodă de compresie atât de ieftină
  • 26 - obiect Perl serializat cu numele clasei și argumentele constructorului
  • 27 - obiect serializat independent de limbaj cu nume de tip și argumente constructoare

Ei bine, am încercat să serializez aceleași date în TL și CBOR cu împachetarea șirurilor de caractere și a obiectelor activate. Rezultatul a început să difere în favoarea CBOR undeva de la un megaoctet:

cborlen=1039673 tl_len=1095092

Astfel, concluzie: Există formate substanțial mai simple care nu sunt supuse eșecului de sincronizare sau problemei de identificare necunoscută, cu o eficiență comparabilă.

Stabilirea rapidă a conexiunii. Aceasta înseamnă zero RTT după reconectare (atunci când cheia a fost deja generată o dată) - aplicabil încă de la primul mesaj MTProto, dar cu unele rezerve - au intrat în aceeași sare, sesiunea nu a mers putredă etc. Ce ne oferă TLS în schimb? Citat înrudit:

Când utilizați PFS în TLS, biletele de sesiune TLS (RFC 5077) pentru a relua sesiunea criptată fără a renegocia cheile și fără a stoca informațiile cheii pe server. La deschiderea primei conexiuni și la generarea cheilor, serverul criptează starea conexiunii și o trimite clientului (sub forma unui bilet de sesiune). În consecință, atunci când conexiunea este reluată, clientul trimite un bilet de sesiune care conține, printre altele, cheia de sesiune înapoi către server. Biletul în sine este criptat cu o cheie temporară (cheie de bilet de sesiune), care este stocată pe server și trebuie distribuită tuturor serverelor frontend care gestionează SSL în soluții cluster.[10]. Astfel, introducerea unui bilet de sesiune poate încălca PFS dacă cheile temporare ale serverului sunt compromise, de exemplu, atunci când sunt stocate pentru o perioadă lungă de timp (OpenSSL, nginx, Apache le stochează în mod implicit pe toată durata în care programul rulează; site-uri populare); folosiți cheia timp de câteva ore, până la zile).

Aici RTT nu este zero, trebuie să schimbi cel puțin ClientHello și ServerHello, după care, împreună cu Finished, clientul poate deja trimite date. Dar aici trebuie amintit că nu avem Web-ul, cu grămada lui de conexiuni nou deschise, ci un mesager, a cărui conexiune este adesea una și mai mult sau mai puțin de lungă durată, cereri relativ scurte pentru pagini Web - totul este multiplexat în interior. Adică este destul de acceptabil, dacă nu am dat peste un tronson de metrou foarte prost.

Ai uitat altceva? Scrieți în comentarii.

Va urma!

În a doua parte a acestei serii de postări, vom lua în considerare problemele organizatorice mai degrabă decât cele tehnice - abordări, ideologie, interfață, atitudine față de utilizatori etc. Pe baza, însă, pe informațiile tehnice care au fost prezentate aici.

Partea a treia va continua analiza componentei tehnice / experienței de dezvoltare. Vei invata in special:

  • continuarea pandemoniului cu varietatea de TL-tipuri
  • lucruri necunoscute despre canale și supergrupuri
  • decât dialogurile este mai rău decât lista
  • despre adresarea mesajelor absolute vs relative
  • care este diferența dintre fotografie și imagine
  • modul în care emoji interferează cu textul în cursiv

si alte carje! Rămâneţi aproape!

Sursa: www.habr.com

Adauga un comentariu