urMQTT: gradimo MQTT broker koji je ujedno i operacijski sustav
Razvojne bilješke o urMQTT-u, unikernelu napisanom u Rustu koji je sam po sebi MQTT broker: jedan ELF s vlastitim TCP/IP-om, TLS-om 1.3, brokerom i SQLite-om, pokrenut s jednog ISO-a.
Uređaji u vozilima, na parkiralištima i u ormarima s elektronikom imaju jednu zajedničku osobinu: šalju male poruke, rijetko, ali ih je puno i stalno su spojeni. Godinama ih spajamo na MQTT brokere, i godinama nas muči isto pitanje. Zašto za nešto tako jednostavno trebamo cijeli Linux, OpenSSL, runtime nekog jezika, SSH, upravitelj paketa i tek onda broker? Svaki od tih slojeva donosi vlastite ranjivosti, troši vlastitu memoriju i dodaje još nešto što se može pokvariti, a flota uređaja od poslužitelja treba samo troje: da drži puno gotovo neaktivnih veza, da svaki uređaj pouzdano autentificira i da poruke prenese bez gubitka.
Tako je nastao urMQTT: unikernel koji jest MQTT broker. Jezgra, TCP/IP, TLS 1.3, MQTT i ugrađena baza podataka u jednom Rust binarnom programu koji se pokreće s jednog ISO-a. Nema procesa, nema sustavskih poziva, nema ljuske. Prva platforma na kojoj se gradi i testira je aarch64, a isti kod ide prema x86_64, po UEFI-ju i klasičnom BIOS-u, i prema pravom hardveru: cilj je jedan ISO koji se podiže na svemu tome. Projekt je otvorenog koda pod licencom MIT ili Apache 2.0, i u ovom tekstu želimo zapisati gdje smo stigli, kako je iznutra složen i što smo usput naučili.
Što točno znači "unikernel"
Uobičajeni broker je program koji radi na operacijskom sustavu. urMQTT nema operacijski sustav ispod sebe: sam se podiže kroz UEFI firmware i Limine bootloader, postavlja vlastite tablice stranica, programira kontroler prekida i tajmer, pronalazi virtio uređaje preko PCIe-a, dobiva adresu DHCP-om i sluša na jednom portu. Sve što bi inače radila jezgra, upravljački programi, mrežni stog i aplikacija, ovdje je jedan ELF koji radi na razini EL1.
Usporedba s tipičnom instalacijom govori sama za sebe:
| Tipičan broker na Linuxu | urMQTT | |
|---|---|---|
| Izloženi servisi | SSH, broker, često i port za metrike ili administraciju | samo TCP 8883 (MQTT preko TLS-a 1.3 s mTLS-om) |
| Kod u slici | jezgra, userland, libc, OpenSSL, runtime | jedan Rust ELF (plus vendorirani SQLite) |
| Udaljeno upravljanje | ljuska, konfiguracijske datoteke, ažuriranja paketa | ništa. Nova verzija je novi ISO |
| Memorija po neaktivnoj vezi | kilobajti (međuspremnici socketa, TLS objekti, heap) | cilj ≤ 512 B |
| Višejezgreni model | niti i brave ili jedna petlja događaja | nit po jezgri, bez dijeljenog stanja, obrada do kraja |
| Integritet koda | moguća su preslikavanja koja su i zapisiva i izvršna | W^X, zaključane tablice stranica, PAC/BTI |
Prioriteti su, tim redom: sigurnost, skalabilnost, višejezgrene performanse, mali otisak i nepromjenjiva instalacija. Kad god su dva cilja u sukobu, pobjeđuje onaj koji je viši na toj listi.
Gdje smo sada
Projekt je u pred-alfa fazi, ali je zatvoreno sedamnaest razvojnih prekretnica, od M0 do M17. Danas urMQTT poslužuje MQTT 3.1.1 preko TLS-a 1.3 s obaveznim klijentskim certifikatima, a stanje čuva u SQLite-u na sirovom disku. Ukratko, što već radi:
- Jezgra: vlastiti vektori iznimaka s dekodiranim izvještajima, vlastite tablice stranica s provedenim W^X-om, zaštitne stranice, PAC i BTI gdje ih procesor ima, GICv3 i GICv2, generički tajmer, SMP do 64 jezgre.
- Otkrivanje hardvera iz DTB-a ili ACPI-ja u jedan opis platforme, s identičnim rezultatom iz oba izvora.
- Upravljački programi: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi i virtio-rng, te CSPRNG zasijan hardverskom entropijom.
- Mreža: vlastiti DHCPv4, ARP, ICMP i strogi IPv4, pa vlastiti TCP samo za poslužiteljsku stranu: SYN kolačići, hijerarhijski kotač tajmera, NewReno, spajanje ACK-ova, bez TIME_WAIT stanja.
- TLS: samo TLS 1.3, obavezni mTLS, identitet uređaja iz CN-a ili SAN URI-ja; rukovanje radi rustls, a nakon njega vlastiti sloj zapisa s AES-128-GCM-om i ChaCha20-Poly1305. Nastavak sjednice PSK ulaznicama, lista opoziva, kontrola prijema već kod rukovanja.
- MQTT: kodek bez kopiranja, fuzziran i testiran s pravim mosquitto klijentima; QoS 0 i 1, džokeri, zadržane poruke, oporuka (LWT), trajne sjednice, ACL s ograničenjem na
devices/{id}/#, ograničenja brzine po uređaju. - Pohrana: SQLite unutar jezgre preko VFS-a nad sirovim diskom, u WAL načinu, s provjerom integriteta pri podizanju; registar uređaja, opozivi, zadržane poruke, trajne sjednice s redovima poruka za uređaje koji su offline; po jezgri zaseban telemetrijski dnevnik s CRC-om po zapisu i oporavkom nakon pada.
- Pogon i nadzor: nadzorni tajmer za obrađivače, zdravstvena provjera, ograničeno logiranje, syslog i statsd preko UDP-a, prijem koji preživljava oluje ponovnih spajanja.
- Isporuka: bitovno identične ponovne izgradnje provjerene u CI-ju, ISO i sirova GPT slika diska, minisign potpisi, upute za KVM, Proxmox i Hetzner.
Svaki je korak označen prekretnicom koja ga je donijela, a datoteka docs/PROGRESS.md u repozitoriju jedini je izvor istine o tome što stvarno radi.
Arhitektura u tri slike
Cijeli dizajn opisan je u docs/ARCHITECTURE.md, a razlozi za svaku važniju odluku zapisani su kao ADR-ovi u docs/DECISIONS.md. Ovdje je pregled.
Slojevi
Od vrha prema dnu: broker (sjednice, stablo tema, QoS, zadržane poruke, oporuke, ACL, ograničenja), MQTT kodek, TLS, TCP, mrežni sloj, pa upravljački programi. Pokraj tog glavnog puta stoji pohrana (SQLite i telemetrijski dnevnik), a ispod svega upravljanje memorijom, otkrivanje platforme i konfiguracija koja je u vrijeme izgradnje prevedena u konstante, zajedno s ugrađenim CA certifikatom, poslužiteljskim certifikatom i ključem. Hipervizor s virtio uređajima jedino je što je izvan slike.
Nit po jezgri, bez dijeljenog stanja
Svaka podatkovna jezgra vrti vlastitu petlju s vlastitim TCP stogom, TLS stanjima, dijelom brokera (sjednice i stablo tema), predmemorijom heapa i particijom telemetrije. Jezgre ne dijele nikakvo promjenjivo stanje, samo ograničene prstenove bez brava, po jedan za svaki uređeni par jezgri. Najviša jezgra pokreće SQLite i ništa drugo.
Mrežna kartica danas ima jedan par redova koji drži jezgra 0. Ona svaki MQTT tok prema Toeplitz hashu njegove četvorke usmjerava na jednu od radnih jezgri i šalje ono što radne jezgre vrate. Sjednica živi na svojoj domaćoj jezgri, određenoj hashom identifikatora klijenta, pa preuzimanje, oporuke i trajnost nikad ne prelaze jezgre. Veza koja sleti na drugu jezgru zadržava tamo svoj TCP i TLS, a običan MQTT prosljeđuje domaćoj jezgri uz kontrolu toka temeljenu na kreditima. Svaka usmjerena poruka razdjeljuje se i na ostale dijelove brokera, od kojih svaki drži repliku zadržanih poruka.
Tri pravila drže taj model na okupu. Obrada do kraja: primljeni paket obrađuje se u cijelosti na jednoj jezgri, od TCP-a preko dešifriranja TLS-a na mjestu, parsiranja MQTT-a i usmjeravanja do šifriranog odgovora, bez redova zadataka i bez kopiranja u aplikacijske međuspremnike. Prilagodljivo ispitivanje: pod opterećenjem se redovi za primanje aktivno ispituju, a u mirovanju se jezgra vraća na prekide i WFI; obrađivači prekida samo potvrde prekid i označe posao. Nadzorni tajmer: otkucaj od 10 ms po jezgri hvata obrađivače koji premaše svoj proračun.
Život jednog PUBLISH-a
Uređaj pošalje TLS zapis. Mrežna kartica ga preda petlji jezgre deskriptorom, bez kopiranja. Petlja provjeri TCP, dešifrira TLS na mjestu i parsira MQTT iz posuđenog međuspremnika, zatim u memoriji provjeri ACL, ograničenje brzine i put kroz stablo tema. Ako drugu jezgru zanima ta tema, dobije poruku za usmjeravanje s referencom na sadržaj i identifikatorom teme; baza dobije telemetrijski zapis ili ažuriranje zadržane poruke, u paketu. Odgovor PUBACK šifrira se izravno u odlazni međuspremnik i spoji s TCP ACK-om, pa uređaju odlazi jedan segment. Drugu jezgru poruka košta jedno šifriranje po pretplatniku u trenutku slanja.
Koliko košta jedna veza
Cilj je najviše 512 bajtova po neaktivnoj TLS vezi, s težnjom prema 256. Dok veza miruje, ništa se za nju ne drži u međuspremniku: primljeni podaci obrađuju se izravno iz mrežnog okvira, a međuspremnici za slanje, zapise i MQTT postoje samo dok je nešto nedovršeno ili nepotvrđeno. Jezgra to i sama mjeri: točni brojači heapa u traženim bajtovima i u blokovima koje zauzimaju, zapisani svakih deset sekundi kao heap iznad osnovne razine po otvorenoj vezi.
| Dio | Veličina | Kako |
|---|---|---|
| TCP kontrolni blok | 96 B | zapakirane zastavice, 16-bitni RTT/RTO, utor u kotaču tajmera, bez međuspremnika u mirovanju |
| Utor u TCP tablici | 4–8 B | samo indeks veze, tablica najviše napola puna |
| Veza i stanje TLS 1.3 zapisa | 152 B | tajna i sekvenca po smjeru (40 B); ključ i IV izvode se po zapisu |
| MQTT veza | 48 B | identitet i oporuka na heapu samo dok postoje |
| MQTT sjednica, vrući oblik | 64 B | jedna pretplata u retku, internirani identifikatori čvorova |
Pretplata devices/<id>/cmd/# |
0 čvorova stabla | pohranjena relativno prema identifikatoru sjednice, zajednička svim uređajima |
Izmjereno pod HVF-om s četiri virtualne jezgre, od toga tri radne, pri čemu svaki uređaj drži mTLS vezu i jednu pretplatu s QoS 1, uključujući zaokruživanje alokatora:
| Veze | Bajtova po vezi |
|---|---|
| 960 | 531 B |
| 9 922 | 494 B |
| 10 197 | 474 B |
Bazen mrežnih okvira fiksnih je oko 2 MiB, što je otprilike 200 B po vezi pri deset tisuća veza i oko 2 B pri milijun. Više od toga nismo mogli izmjeriti: QEMU-ovo korisničko umrežavanje na macOS-u prestalo je prihvaćati veze na 10 197, otprilike na granici od 10 240 deskriptora QEMU procesa. Brojka pada kako se fiksni dijelovi raspodjeljuju na više veza, pa ne raste s njihovim brojem.
Sigurnosni model
Sigurnost je prvi prioritet i dobiva sve sukobe s ostalim ciljevima. Model ima nekoliko slojeva:
- Jedan port. TCP 8883, samo TLS 1.3, obavezni klijentski certifikati potpisani CA-om ugrađenim u sliku. Identifikator MQTT klijenta mora biti jednak identifikatoru uređaja iz certifikata.
- Nema ulaza. Nema ljuske, konzolnog unosa, SSH-a, administracijskog API-ja, dinamičkog koda, SQL-a dostupnog mrežom ni datotečnog sustava.
- Memorijska sigurnost. Rust, s
forbid(unsafe_code)u protokolnim paketima; svakiunsafeblok ima komentar// SAFETY:koji provjerava lint, a na vanjski unos nema panike: clippy u paketima na podatkovnom putu zabranjujeunwrap,expect, indeksiranje ipanic!. - Ublažavanje iskorištavanja. W^X provodi se kodiranjem deskriptora i bitom
SCTLR_EL1.WXNi revidira pri svakom podizanju. Tablice stranica nakon podizanja su samo za čitanje, bez zapisivog aliasa. Donja polovica adresnog prostora je isključena. Svaki stog ima zaštitne stranice, a iznimke rade na zasebnom stogu. PAC i BTI uključuju se kad ih procesor podržava, i to provjeravaju dimni testovi. - Otpornost na zloporabu. SYN kolačići; prijem odbija nove veze, prije ikakve kriptografije, kad je heap nizak, kad baza zaostaje, kad je previše rukovanja u tijeku po jezgri ili po izvornoj adresi ili kad je brzina rukovanja previsoka. Ograničenja brzine po uređaju, ograničeni redovi, TCP prozor vezan uz protutlak brokera, ograničeno logiranje, nadzorni tajmer i zdravstvena provjera.
- Nepromjenjivost. Konfiguracija, CA i poslužiteljski ključ ugrađeni su u sliku. Promjena bilo čega znači novu sliku.
Model prijetnji zapisan je u docs/SECURITY.md.
Pohrana bez datotečnog sustava
Podaci žive na zasebnom disku, virtio-blk ili virtio-scsi, na kojem nema datotečnog sustava. Raspored je minimalan i vlastiti:
LBA 0 [ superblok: magija · verzija rasporeda · uuid · tablica regija · CRC-32C ]
LBA 8, 16 [ tablica veličina datoteka, utori A/B (najnoviji valjani pobjeđuje) ]
LBA 64 [ SQLite glavna baza ½ ][ SQLite WAL ⅛ ][ dnevnik 1 MiB ][ telemetrijski dnevnik: 8 particija … ]
zadnja 4 KiB [ prostor za samoprovjeru blok-uređaja pri podizanju ]
SQLite (vendorirani 3.54.1, s provjerom sažetka pri izgradnji) preveden je u jezgru clangom, bez standardne biblioteke, i koristi fiksni memsys5 heap. Njegov VFS preslikava tri fiksne datoteke na regije diska. Radi u WAL načinu s locking_mode=EXCLUSIVE, pa mu ne treba indeks u dijeljenoj memoriji, i sa synchronous=FULL; ako quick_check pri podizanju nađe grešku, sustav se sigurno zaustavlja. U bazi su registar uređaja, opozivi, zadržane poruke i trajne sjednice s redovima QoS 1 poruka za uređaje koji nisu spojeni.
Jezgra za bazu, ona s najvišim brojem, pokreće SQLite i ništa drugo. Podatkovna ravnina šalje joj promjene stanja preko SPSC prstena bez brava; ona ih skuplja u jednu transakciju svakih 5 ms ili svakih 512 operacija i potvrde vraća drugim prstenom. Prazan disk formatira se pri prvom podizanju; sve drugo se nikad ne formatira preko.
Telemetrija ide drugim putem. Svaki prihvaćeni PUBLISH na temama iz storage.telemetry_topics odlazi u particiju telemetrijskog dnevnika svoje jezgre: prsten segmenata s CRC-om po zapisu, posoljenim sekvencom segmenta, tako da se stari prolazi prstena nikad ne pojave ponovno. Jezgre predaju zapise bazi vlastitim prstenom, bez kopiranja, a ona ih ispisuje svakih 5 ms ili svakih 256 KiB. Pri podizanju oporavak zadržava svaki ispisani zapis, odsiječe nedovršeni rep i nastavlja u novom segmentu. Zadržavanje je po veličini, kroz prsten, i po starosti, kroz retention_days. Provjerili smo to s milijun zapisa i s kill -9 usred pisanja.
Brojke
Alat za opterećenje u tools/loadgen radi tri stvari: oluju TLS 1.3 rukovanja s mTLS-om i CONNECT-om, dugotrajne veze s PINGREQ-om koje svake sekunde objavljuju telemetriju, i oluje ponovnog spajanja. make bench na Apple M4 Pro pod HVF-om daje:
| Radne jezgre | rukovanja/s u gostu | skaliranje | kroz QEMU korisničku mrežu (veza/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
Brojka u gostu, gdje su klijent i poslužitelj unutar istog virtualnog stroja i svaka radna jezgra radi sama za sebe, pokazuje da podatkovna ravnina skalira linearno. Mrežna brojka ograničena je jednonitnim korisničkim umrežavanjem QEMU-a, ne gostom; i to je zapisano kao ADR-032.
Oluje ponovnog spajanja testiramo tako da se svi uređaji spoje odjednom, ponavljaju odbijene pokušaje s odgodom i onda svi padnu bez DISCONNECT-a. Parametar heap_limit_mb= u razvojnom načinu natjera prijem da heap smatra manjim nego što jest, pa se oluja veća od heapa može isprobati s malo socketa. U jednom takvom krugu od 3 000 uređaja spojilo se 1 368 u 36 sekundi, a 5 619 pokušaja bilo je uredno odbijeno, uz ograničenje od 8 MiB; memorija se nakon oluje vratila.
Kako radimo
Projekt vodi pisana specifikacija, PROMPT.md, s planom prekretnica, i svaka prekretnica završava istim ritualom: make check i make smoke zeleni, jedan commit s oznakom M<n>, zapis u docs/PROGRESS.md i ADR za svaku odluku koja nije očita. Pravila su kratka i ne pregovaramo o njima: nema panike na vanjski unos, unsafe samo u hardverskim i FFI paketima i uvijek s komentarom, nema alokacija na vrućem putu, komentar uz svaki redoslijed atomskih operacija, svako ograničenje dolazi iz konfiguracije, a namjerni privremeni dijelovi padaju glasno.
CI radi više nego što bismo ručno stigli: fmt, clippy za host i za aarch64-unknown-none, typos, actionlint i shellcheck; testove na Linuxu i macOS-u na arm64; izgradnju jezgre za razvojnu i produkcijsku konfiguraciju s veličinom ELF-a u sažetku; podizanje pod QEMU-om sa svim scenarijima dimnog testa i spremanje serijskih zapisa; cargo-deny za savjete, licence i izvore; minutu fuzzinga po svakoj od jedanaest meta, a noću deset minuta po meti s predmemoriranim korpusom. Uz to CodeQL, OpenSSF Scorecard, Dependabot i objava s SLSA porijeklom. Svaka tuđa akcija zakvačena je na puni commit SHA.
Reproducibilnost shvaćamo doslovno: make repro izgradi HEAD dvaput u svježim radnim stablima i usporedi ELF, ISO i sirovu sliku bajt po bajt. Objave su potpisane minisignom, a artefakti se mogu provjeriti s gh attestation verify. Produkcijska slika sadrži privatni ključ poslužitelja, pa je sam ISO tajna; slike uz objave na GitHubu razvojne su, s privremenim ključevima, i služe samo za isprobavanje.
Što smo naučili
Nekoliko lekcija koje su promijenile plan:
- Vlastiti TCP ipak se isplatio. Prototip je radio na smoltcp-u i dobro nam je poslužio do M13. Ali poslužitelj koji drži stotine tisuća veza ne treba klijentski TCP, treba SYN kolačiće, kotač tajmera i stanje od 96 bajtova. U M14 smo smoltcp zamijenili vlastitim stogom samo za poslužiteljsku stranu, s testovima simuliranog gubitka i fuzzingom segmenata.
- Mjerite memoriju iznutra. Procjene po vezi bile su beskorisne dok jezgra nije sama počela brojati heap u blokovima alokatora, a ne u traženim bajtovima. Tek tada smo vidjeli gdje 512 bajtova stvarno odlazi i spustili se na 494.
- Alat za testiranje je dio proizvoda. Najveće ograničenje nije bila jezgra, nego QEMU-ovo korisničko umrežavanje i broj efemernih portova na razvojnom računalu. Za sto tisuća i više veza treba
vmnet-sharedna macOS-u ili Linux s KVM-om, tap sučeljem i više klijentskih adresa. - SQLite bez operacijskog sustava je moguć, ali samo uz stroge uvjete: fiksni heap, ekskluzivno zaključavanje bez dijeljene memorije, VFS koji ne zna za datoteke, i prijem koji baze ne pušta da zaostane.
- Oluje ponovnog spajanja su normalno stanje, ne iznimka. Kad padne mobilna mreža i vrati se, tisuće uređaja kucaju istodobno. Zato prijem odbija vezu prije ikakve kriptografije, a svaka odbijena veza košta gotovo ništa.
Što slijedi
Rad nakon M17 planiran je u tri smjera, a redoslijed među njima zapisan je u docs/PLAN.md. Ništa od ovoga još nije izgrađeno.
- Jedan ISO za više platformi. Ista datoteka
urmqtt-<env>-<verzija>.isoi jedna sirova GPT slika koje se podižu na aarch64 UEFI-ju, x86_64 UEFI-ju i x86_64 BIOS-u, u virtualnim strojevima i na pravom hardveru, počevši od jednog Intel PC-a. I dalje uređaj sa svime unutra, reproducibilan i potpisan. - Sirovi disk, bez datotečnog sustava. Specifikacija formata na razini bajta, host alat
urmqtt-diskkoji dijeli parsere s jezgrom, poravnanje regija na 1 MiB, rast diska s podacima, discard za tanko dodijeljene diskove, A/B snimke baze, provjera bitova i, po izboru, šifriranje AES-256-XTS s hardverskim AES-om. - Dokazivo sigurno. Opoziv i lista dopuštenih uređaja za vrijeme rada preko potpisanog kontrolnog kanala, bez nove slike; diferencijalno i konformacijsko testiranje vlastitog TLS-a i TCP-a; KASLR, zaštita stoga, kCFI, MTE i ojačani alokator; SQLite izoliran na razini EL0; Secure Boot; SBOM i cargo-vet; i na kraju paket za vanjsku reviziju.
Platforme koje nude samo ne-virtio uređaje, poput AWS ENA-e ili Azure MANA-e, zasad nisu podržane. Raspberry Pi 4 i 5 kao KVM domaćin, Hetzner CAX i Oracle Ampere A1 dokumentirani su i podržani kodom, ali još nisu isprobani na pravoj usluzi.
Isprobajte
Repozitorij je na github.com/zeljan-alduk/urMQTT. Uz Rust s prikvačenim nightly lancem alata i QEMU, make certs, make run i jedan mosquitto klijent dovoljni su za prvi PUBLISH preko mTLS-a na vlastitom računalu. Upozorenje s početka vrijedi i na kraju: ovo je pred-alfa, i sve što radi piše u docs/PROGRESS.md. Prijave grešaka, pitanja o dizajnu i sigurnosne prijave po uputama u SECURITY.md su dobrodošli. O svakoj sljedećoj prekretnici javit ćemo se ovdje.
Komentari
Pravila komentiranja