urMQTT: budujeme MQTT broker, ktorý je zároveň operačným systémom
Vývojárske poznámky o urMQTT, unikernel napísanom v Ruste, ktorý je sám osebe MQTT broker: jeden ELF s vlastným TCP/IP, TLS 1.3, brokerom a SQLite, spustený z jedného ISO.
Zariadenia vo vozidlách, na parkoviskách a v skriniach s elektronikou majú jednu spoločnú vlastnosť: posielajú malé správy, zriedka, ale je ich veľa a sú neustále pripojené. Roky ich pripájame na MQTT brokery a roky nás trápi tá istá otázka. Prečo na niečo také jednoduché potrebujeme celý Linux, OpenSSL, runtime nejakého jazyka, SSH, správcu balíkov a až potom broker? Každá z týchto vrstiev prináša vlastné zraniteľnosti, spotrebúva vlastnú pamäť a pridáva ďalšiu vec, ktorá sa môže pokaziť, a flotila zariadení od servera potrebuje len tri veci: aby držal veľa takmer nečinných spojení, aby spoľahlivo overil totožnosť každého zariadenia a aby preniesol správy bez straty.
Tak vznikol urMQTT: unikernel, ktorý je MQTT broker. Jadro, TCP/IP, TLS 1.3, MQTT a vstavaná databáza v jedinom binárnom programe v Ruste, ktorý sa spúšťa z jedného ISO. Žiadne procesy, žiadne systémové volania, žiadny shell. Prvou platformou, na ktorej sa buduje a testuje, je aarch64, a ten istý kód smeruje na x86_64, cez UEFI aj klasický BIOS, a na skutočný hardvér: cieľom je jeden ISO, ktorý sa zavedie na všetkých z nich. Projekt je open source pod licenciou MIT alebo Apache 2.0 a v tomto texte chceme zapísať, kam sme sa dostali, ako je vo vnútri poskladaný a čo sme sa popritom naučili.
Čo presne znamená „unikernel"
Bežný broker je program, ktorý beží na operačnom systéme. urMQTT pod sebou operačný systém nemá: samo sa zavedie cez UEFI firmware a bootloader Limine, nastaví si vlastné tabuľky stránok, naprogramuje kontrolér prerušení a časovač, nájde zariadenia virtio cez PCIe, získa adresu cez DHCP a počúva na jedinom porte. Všetko, čo by inak robilo jadro, ovládače, sieťový stack a aplikácia, je tu jeden ELF, ktorý beží na úrovni EL1.
Porovnanie s typickou inštaláciou hovorí samo za seba:
| Typický broker na Linuxe | urMQTT | |
|---|---|---|
| Vystavené služby | SSH, broker, často aj port pre metriky alebo administráciu | iba TCP 8883 (MQTT cez TLS 1.3 s mTLS) |
| Kód v obraze | jadro, userland, libc, OpenSSL, runtime | jeden ELF v Ruste (plus vendorovaný SQLite) |
| Vzdialená správa | shell, konfiguračné súbory, aktualizácie balíkov | nič. Nová verzia je nový ISO |
| Pamäť na nečinné spojenie | kilobajty (vyrovnávacie pamäte socketov, objekty TLS, heap) | cieľ ≤ 512 B |
| Viacjadrový model | vlákna a zámky alebo jedna slučka udalostí | vlákno na jadro, bez zdieľaného stavu, spracovanie do konca |
| Integrita kódu | sú možné mapovania, ktoré sú zapisovateľné aj spustiteľné | W^X, zamknuté tabuľky stránok, PAC/BTI |
Priority sú v tomto poradí: bezpečnosť, škálovateľnosť, viacjadrový výkon, malá stopa a nemenná inštalácia. Keď sú dva ciele v konflikte, vyhráva ten, ktorý je v tomto zozname vyššie.
Kde sme teraz
Projekt je vo fáze pred-alfa, ale uzavretých je sedemnásť vývojových míľnikov, od M0 po M17. Dnes urMQTT obsluhuje MQTT 3.1.1 cez TLS 1.3 s povinnými klientskymi certifikátmi a stav uchováva v SQLite na surovom disku. Stručne, čo už funguje:
- Jadro: vlastné vektory výnimiek s dekódovanými hláseniami, vlastné tabuľky stránok s vynúteným W^X, ochranné stránky, PAC a BTI tam, kde ich procesor má, GICv3 a GICv2, generický časovač, SMP až do 64 jadier.
- Zisťovanie hardvéru z DTB alebo ACPI do jedného popisu platformy, s identickým výsledkom z oboch zdrojov.
- Ovládače: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi a virtio-rng, a CSPRNG zasiaty hardvérovou entropiou.
- Sieť: vlastný DHCPv4, ARP, ICMP a prísny IPv4, potom vlastný TCP len pre serverovú stranu: SYN cookies, hierarchické koleso časovačov, NewReno, spájanie ACK, bez stavu TIME_WAIT.
- TLS: iba TLS 1.3, povinné mTLS, identita zariadenia z CN alebo SAN URI; podanie rúk robí rustls a po ňom vlastná vrstva záznamov s AES-128-GCM a ChaCha20-Poly1305. Obnovenie relácie lístkami PSK, zoznam odvolaní, riadenie prijatia už pri podaní rúk.
- MQTT: kodek bez kopírovania, fuzzovaný a testovaný s pravými klientmi mosquitto; QoS 0 a 1, zástupné znaky, zachované správy, závet (LWT), trvalé relácie, ACL s obmedzením na
devices/{id}/#, obmedzenia rýchlosti na zariadenie. - Úložisko: SQLite vnútri jadra cez VFS nad surovým diskom, v režime WAL, s kontrolou integrity pri zavádzaní; register zariadení, odvolania, zachované správy, trvalé relácie s frontmi správ pre zariadenia, ktoré sú offline; na každé jadro samostatný telemetrický denník s CRC na záznam a obnovou po páde.
- Prevádzka a dohľad: strážny časovač pre obslužné rutiny, kontrola stavu, obmedzené logovanie, syslog a statsd cez UDP, prijatie, ktoré prežije búrky opätovných pripojení.
- Dodanie: bitovo identické opakované zostavenia overené v CI, ISO a surový GPT obraz disku, podpisy minisign, návody pre KVM, Proxmox a Hetzner.
Každý krok je označený míľnikom, ktorý ho priniesol, a súbor docs/PROGRESS.md v repozitári je jediným zdrojom pravdy o tom, čo naozaj funguje.
Architektúra v troch obrázkoch
Celý návrh je popísaný v docs/ARCHITECTURE.md a dôvody každého dôležitejšieho rozhodnutia sú zapísané ako ADR v docs/DECISIONS.md. Tu je prehľad.
Vrstvy
Zhora nadol: broker (relácie, strom tém, QoS, zachované správy, závety, ACL, obmedzenia), kodek MQTT, TLS, TCP, sieťová vrstva a potom ovládače. Popri tejto hlavnej ceste stojí úložisko (SQLite a telemetrický denník) a pod všetkým správa pamäte, zisťovanie platformy a konfigurácia, ktorá je v čase zostavenia preložená do konštánt, spolu so vstavaným certifikátom CA, serverovým certifikátom a kľúčom. Hypervízor so zariadeniami virtio je jediné, čo je mimo obrazu.
Vlákno na jadro, bez zdieľaného stavu
Každé dátové jadro točí vlastnú slučku s vlastným zásobníkom TCP, stavmi TLS, dielom brokera (relácie a strom tém), vyrovnávacou pamäťou heapu a oddielom telemetrie. Jadrá nezdieľajú žiadny meniteľný stav, iba ohraničené prstence bez zámkov, po jednom pre každý usporiadaný pár jadier. Najvyššie jadro spúšťa SQLite a nič iné.
Sieťová karta má dnes jeden pár front, ktorý drží jadro 0. To každý tok MQTT podľa hashu Toeplitz jeho štvorice nasmeruje na jedno z pracovných jadier a posiela to, čo pracovné jadrá vrátia. Relácia žije na svojom domovskom jadre, určenom hashom identifikátora klienta, takže prevzatie, závety a trvanlivosť nikdy neprekračujú hranice jadier. Spojenie, ktoré pristane na inom jadre, si tam ponechá svoj TCP a TLS, a bežný MQTT preposiela domovskému jadru s riadením toku založeným na kreditoch. Každá smerovaná správa sa rozosiela aj na ostatné diely brokera, z ktorých každý drží repliku zachovaných správ.
Tento model drží pohromade tri pravidlá. Spracovanie do konca: prijatý paket sa spracuje celý na jednom jadre, od TCP cez dešifrovanie TLS na mieste, parsovanie MQTT a smerovanie až po šifrovanú odpoveď, bez frontov úloh a bez kopírovania do aplikačných vyrovnávacích pamätí. Prispôsobivé vzorkovanie: pri záťaži sa fronty na príjem aktívne vzorkujú a v pokoji sa jadro vracia k prerušeniam a WFI; obslužné rutiny prerušení iba potvrdia prerušenie a označia prácu. Strážny časovač: tik 10 ms na jadro zachytí obslužné rutiny, ktoré prekročia svoj rozpočet.
Život jedného PUBLISH
Zariadenie pošle záznam TLS. Sieťová karta ho odovzdá slučke jadra deskriptorom, bez kopírovania. Slučka skontroluje TCP, dešifruje TLS na mieste a parsuje MQTT z požičanej vyrovnávacej pamäte, potom v pamäti skontroluje ACL, obmedzenie rýchlosti a cestu stromom tém. Ak sa iné jadro zaujíma o túto tému, dostane správu na smerovanie s odkazom na obsah a identifikátorom témy; databáza dostane telemetrický záznam alebo aktualizáciu zachovanej správy, v balíku. Odpoveď PUBACK sa zašifruje priamo do odchádzajúcej vyrovnávacej pamäte a spojí sa s TCP ACK, takže zariadeniu odíde jeden segment. Iné jadro stojí správa jedno šifrovanie na odberateľa v okamihu odoslania.
Koľko stojí jedno spojenie
Cieľom je najviac 512 bajtov na nečinné spojenie TLS, s ambíciou 256. Kým spojenie odpočíva, nič sa pre ňu nedrží vo vyrovnávacej pamäti: prijaté dáta sa spracúvajú priamo zo sieťového rámca a vyrovnávacie pamäte na odosielanie, záznamy a MQTT existujú len dovtedy, kým je niečo nedokončené alebo nepotvrdené. Jadro to aj samo meria: presné počítadlá heapu v požadovaných bajtoch aj v blokoch, ktoré zaberajú, zapisované každých desať sekúnd ako heap nad základnou úrovňou na otvorené spojenie.
| Časť | Veľkosť | Ako |
|---|---|---|
| Riadiaci blok TCP | 96 B | zabalené príznaky, 16-bitové RTT/RTO, slot v kolese časovačov, bez vyrovnávacích pamätí v pokoji |
| Slot v tabuľke TCP | 4–8 B | iba index spojenia, tabuľka najviac na polovicu plná |
| Spojenie a stav záznamov TLS 1.3 | 152 B | tajomstvo a poradové číslo na smer (40 B); kľúč a IV sa odvodzujú na záznam |
| Spojenie MQTT | 48 B | identita a závet na heape iba dovtedy, kým existujú |
| Relácia MQTT, horúca podoba | 64 B | jeden odber v riadku, internované identifikátory uzlov |
Odber devices/<id>/cmd/# |
0 uzlov stromu | uložený relatívne voči identifikátoru relácie, spoločný všetkým zariadeniam |
Zmerané pod HVF so štyrmi virtuálnymi jadrami, z toho tri pracovné, pričom každé zariadenie drží spojenie mTLS a jeden odber s QoS 1, vrátane zaokrúhľovania alokátora:
| Spojenia | Bajtov na spojenie |
|---|---|
| 960 | 531 B |
| 9 922 | 494 B |
| 10 197 | 474 B |
Fond sieťových rámcov je pevný, okolo 2 MiB, čo je zhruba 200 B na spojenie pri desaťtisíc spojeniach a okolo 2 B pri milióne. Viac sme zmerať nedokázali: používateľská sieť QEMU na macOS prestala prijímať spojenia pri 10 197, zhruba na hranici 10 240 deskriptorov procesu QEMU. Číslo klesá, keď sa pevné časti rozdeľujú na viac spojení, takže s ich počtom nerastie.
Bezpečnostný model
Bezpečnosť je prvou prioritou a vyhráva všetky konflikty s ostatnými cieľmi. Model má niekoľko vrstiev:
- Jeden port. TCP 8883, iba TLS 1.3, povinné klientske certifikáty podpísané CA vstavanou do obrazu. Identifikátor klienta MQTT sa musí rovnať identifikátoru zariadenia z certifikátu.
- Žiadny vstup. Žiadny shell, konzolový vstup, SSH, administračné API, dynamický kód, SQL dostupný cez sieť ani súborový systém.
- Pamäťová bezpečnosť. Rust, s
forbid(unsafe_code)v protokolových balíkoch; každý blokunsafemá komentár// SAFETY:, ktorý kontroluje lint, a na vonkajší vstup neexistuje panika: clippy v balíkoch na dátovej ceste zakazujeunwrap,expect, indexovanie apanic!. - Zmierňovanie zneužitia. W^X sa vynucuje kódovaním deskriptorov a bitom
SCTLR_EL1.WXNa kontroluje sa pri každom zavedení. Tabuľky stránok sú po zavedení iba na čítanie, bez zapisovateľného aliasu. Dolná polovica adresného priestoru je vypnutá. Každý zásobník má ochranné stránky a výnimky bežia na samostatnom zásobníku. PAC a BTI sa zapínajú, keď ich procesor podporuje, a overujú to dymové testy. - Odolnosť voči zneužitiu. SYN cookies; prijatie odmieta nové spojenia, ešte pred akoukoľvek kryptografiou, keď je heap nízky, keď databáza zaostáva, keď je príliš veľa podaní rúk v priebehu na jadro alebo na zdrojovú adresu, alebo keď je rýchlosť podaní rúk príliš vysoká. Obmedzenia rýchlosti na zariadenie, ohraničené fronty, okno TCP viazané na spätný tlak brokera, obmedzené logovanie, strážny časovač a kontrola stavu.
- Nemennosť. Konfigurácia, CA a serverový kľúč sú vstavané do obrazu. Zmena čohokoľvek znamená nový obraz.
Model hrozieb je zapísaný v docs/SECURITY.md.
Úložisko bez súborového systému
Dáta žijú na samostatnom disku, virtio-blk alebo virtio-scsi, na ktorom nie je súborový systém. Rozloženie je minimálne a vlastné:
LBA 0 [ superblok: magické číslo · verzia rozloženia · uuid · tabuľka oblastí · CRC-32C ]
LBA 8, 16 [ tabuľka veľkostí súborov, sloty A/B (vyhráva najnovší platný) ]
LBA 64 [ SQLite hlavná databáza ½ ][ SQLite WAL ⅛ ][ denník 1 MiB ][ telemetrický denník: 8 oddielov … ]
posledné 4 KiB [ priestor na samokontrolu blokového zariadenia pri zavádzaní ]
SQLite (vendorovaný 3.54.1, s kontrolou odtlačku pri zostavení) je preložený do jadra pomocou clang, bez štandardnej knižnice, a používa pevný heap memsys5. Jeho VFS mapuje tri pevné súbory na oblasti disku. Beží v režime WAL s locking_mode=EXCLUSIVE, takže nepotrebuje index v zdieľanej pamäti, a so synchronous=FULL; ak quick_check pri zavádzaní nájde chybu, systém sa bezpečne zastaví. V databáze sú register zariadení, odvolania, zachované správy a trvalé relácie s frontmi správ QoS 1 pre zariadenia, ktoré nie sú pripojené.
Jadro pre databázu, to s najvyšším číslom, spúšťa SQLite a nič iné. Dátová rovina mu posiela zmeny stavu cez prstenec SPSC bez zámkov; ono ich zbiera do jednej transakcie každých 5 ms alebo každých 512 operácií a potvrdenia vracia druhým prstencom. Prázdny disk sa naformátuje pri prvom zavedení; nič iné sa nikdy nepreformátuje.
Telemetria ide inou cestou. Každý prijatý PUBLISH na témach zo storage.telemetry_topics odchádza do oddielu telemetrického denníka svojho jadra: prstenec segmentov s CRC na záznam, osolený poradovým číslom segmentu, takže sa staré prechody prstenca nikdy znova neobjavia. Jadrá odovzdávajú záznamy databáze vlastným prstencom, bez kopírovania, a ona ich zapisuje každých 5 ms alebo každých 256 KiB. Pri zavádzaní obnova zachová každý zapísaný záznam, odreže nedokončený chvost a pokračuje v novom segmente. Uchovávanie je podľa veľkosti, cez prstenec, aj podľa veku, cez retention_days. Overili sme to s miliónom záznamov a s kill -9 uprostred zápisu.
Čísla
Nástroj na záťaž v tools/loadgen robí tri veci: búrku podaní rúk TLS 1.3 s mTLS a CONNECT, dlhodobé spojenia s PINGREQ, ktoré každú sekundu publikujú telemetriu, a búrky opätovných pripojení. make bench na Apple M4 Pro pod HVF dáva:
| Pracovné jadrá | podania rúk/s v hosťovi | škálovanie | cez používateľskú sieť QEMU (spojení/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
Číslo v hosťovi, kde sú klient aj server v tom istom virtuálnom stroji a každé pracovné jadro beží samo za seba, ukazuje, že dátová rovina škáluje lineárne. Sieťové číslo je obmedzené jednovláknovou používateľskou sieťou QEMU, nie hosťom; aj to je zapísané ako ADR-032.
Búrky opätovných pripojení testujeme tak, že sa všetky zariadenia pripoja naraz, opakujú odmietnuté pokusy so oneskorením a potom všetky spadnú bez DISCONNECT. Parameter heap_limit_mb= vo vývojovom režime donúti prijatie považovať heap za menší, než je, takže sa búrka väčšia než heap dá vyskúšať s málo socketmi. V jednom takom kole s 3 000 zariadeniami sa pripojilo 1 368 za 36 sekúnd a 5 619 pokusov bolo riadne odmietnutých, pri obmedzení 8 MiB; pamäť sa po búrke vrátila.
Ako pracujeme
Projekt vedie písaná špecifikácia, PROMPT.md, s plánom míľnikov, a každý míľnik sa končí tým istým rituálom: make check a make smoke zelené, jeden commit so značkou M<n>, záznam v docs/PROGRESS.md a ADR pre každé rozhodnutie, ktoré nie je zrejmé. Pravidlá sú krátke a nevyjednávame o nich: žiadna panika na vonkajší vstup, unsafe iba v hardvérových a FFI balíkoch a vždy s komentárom, žiadne alokácie na horúcej ceste, komentár ku každému poradiu atomických operácií, každé obmedzenie pochádza z konfigurácie a zámerné dočasné časti padajú nahlas.
CI robí viac, než by sme stihli ručne: fmt, clippy pre hosta aj pre aarch64-unknown-none, typos, actionlint a shellcheck; testy na Linuxe a macOS na arm64; zostavenie jadra pre vývojovú aj produkčnú konfiguráciu s veľkosťou ELF v súhrne; zavedenie pod QEMU so všetkými scenármi dymového testu a uložením sériových záznamov; cargo-deny pre upozornenia, licencie a zdroje; minútu fuzzingu na každý z jedenástich cieľov a v noci desať minút na cieľ s korpusom v cache. K tomu CodeQL, OpenSSF Scorecard, Dependabot a vydanie s pôvodom SLSA. Každá cudzia akcia je pripnutá na plný commit SHA.
Reprodukovateľnosť chápeme doslovne: make repro zostaví HEAD dvakrát v čerstvých pracovných stromoch a porovná ELF, ISO a surový obraz bajt po bajte. Vydania sú podpísané pomocou minisign a artefakty sa dajú overiť cez gh attestation verify. Produkčný obraz obsahuje súkromný kľúč servera, takže samotný ISO je tajomstvo; obrazy pri vydaniach na GitHube sú vývojové, s dočasnými kľúčmi, a slúžia len na vyskúšanie.
Čo sme sa naučili
Niekoľko lekcií, ktoré zmenili plán:
- Vlastný TCP sa napokon oplatil. Prototyp bežal na smoltcp a dobre nám slúžil až do M13. Ale server, ktorý drží státisíce spojení, nepotrebuje klientsky TCP, potrebuje SYN cookies, koleso časovačov a stav s 96 bajtmi. V M14 sme smoltcp nahradili vlastným stackom len pre serverovú stranu, s testami simulovanej straty a fuzzingom segmentov.
- Merajte pamäť zvnútra. Odhady na spojenie boli bezcenné, kým jadro samo nezačalo počítať heap v blokoch alokátora, a nie v požadovaných bajtoch. Až vtedy sme uvideli, kam sa 512 bajtov naozaj podeje, a klesli sme na 494.
- Nástroj na testovanie je súčasť produktu. Najväčším obmedzením nebolo jadro, ale používateľská sieť QEMU a počet efemérnych portov na vývojovom počítači. Pre sto tisíc spojení a viac treba
vmnet-sharedna macOS alebo Linux s KVM, rozhraním tap a viacerými klientskymi adresami. - SQLite bez operačného systému je možný, ale len za prísnych podmienok: pevný heap, exkluzívne zamykanie bez zdieľanej pamäte, VFS, ktorý nevie o súboroch, a prijatie, ktoré nenechá databázu zaostať.
- Búrky opätovných pripojení sú normálny stav, nie výnimka. Keď spadne mobilná sieť a vráti sa, tisíce zariadení klopú súčasne. Preto prijatie odmieta spojenie ešte pred akoukoľvek kryptografiou a každé odmietnuté spojenie stojí takmer nič.
Čo bude ďalej
Práca po M17 je plánovaná v troch smeroch a poradie medzi nimi je zapísané v docs/PLAN.md. Nič z toho ešte nie je postavené.
- Jeden ISO pre viac platforiem. Ten istý súbor
urmqtt-<env>-<verzia>.isoa jeden surový obraz GPT, ktoré sa zavádzajú na aarch64 UEFI, x86_64 UEFI a x86_64 BIOS, vo virtuálnych strojoch aj na skutočnom hardvéri, počnúc jedným počítačom Intel. Naďalej zariadenie so všetkým vnútri, reprodukovateľné a podpísané. - Surový disk, bez súborového systému. Špecifikácia formátu na úrovni bajtov, hostiteľský nástroj
urmqtt-disk, ktorý zdieľa parsery s jadrom, zarovnanie oblastí na 1 MiB, rast disku s dátami, discard pre tenko alokované disky, snímky databázy A/B, kontrola bitov a podľa výberu šifrovanie AES-256-XTS s hardvérovým AES. - Dokázateľne bezpečné. Odvolanie a zoznam povolených zariadení za behu cez podpísaný riadiaci kanál, bez nového obrazu; diferenciálne a konformančné testovanie vlastného TLS a TCP; KASLR, ochrana zásobníka, kCFI, MTE a spevnený alokátor; SQLite izolovaný na úrovni EL0; Secure Boot; SBOM a cargo-vet; a nakoniec balík pre externý audit.
Platformy, ktoré ponúkajú iba zariadenia, ktoré nie sú virtio, ako AWS ENA alebo Azure MANA, zatiaľ nie sú podporované. Raspberry Pi 4 a 5 ako hostiteľ KVM, Hetzner CAX a Oracle Ampere A1 sú zdokumentované a podporované kódom, ale ešte neboli vyskúšané na skutočnej službe.
Vyskúšajte
Repozitár je na github.com/zeljan-alduk/urMQTT. S Rustom s pripnutou nightly sadou nástrojov a QEMU stačia make certs, make run a jeden klient mosquitto na prvý PUBLISH cez mTLS na vlastnom počítači. Upozornenie z úvodu platí aj na konci: toto je pred-alfa a všetko, čo funguje, je napísané v docs/PROGRESS.md. Hlásenia chýb, otázky k návrhu a bezpečnostné hlásenia podľa pokynov v SECURITY.md sú vítané. O každom ďalšom míľniku sa tu ozveme.
Komentáre
Pravidlá komentovania