urMQTT: gradimo MQTT posrednik, ki je hkrati operacijski sistem
Razvojni zapiski o urMQTT, unikernelu, napisanem v Rustu, ki je sam po sebi MQTT posrednik: en ELF z lastnim TCP/IP, TLS 1.3, posrednikom in SQLite, zagnan z enega ISO-a.
Naprave v vozilih, na parkiriščih in v elektronskih omarah imajo eno skupno lastnost: pošiljajo majhna sporočila, redko, a jih je veliko in so nenehno povezane. Že leta jih povezujemo z MQTT posredniki in že leta nas muči isto vprašanje. Zakaj za nekaj tako preprostega potrebujemo cel Linux, OpenSSL, izvajalno okolje nekega jezika, SSH, upravitelj paketov in šele nato posrednika? Vsaka od teh plasti prinaša svoje ranljivosti, porablja svoj pomnilnik in dodaja še eno stvar, ki se lahko pokvari, flota naprav pa od strežnika potrebuje le troje: da drži veliko skoraj nedejavnih povezav, da vsako napravo zanesljivo overi in da sporočila prenese brez izgub.
Tako je nastal urMQTT: unikernel, ki je MQTT posrednik. Jedro, TCP/IP, TLS 1.3, MQTT in vgrajena podatkovna baza v enem Rustovem binarnem programu, ki se zažene z enega ISO-a. Ni procesov, ni sistemskih klicev, ni lupine. Prva platforma, na kateri se gradi in preizkuša, je aarch64, isto kodo pa pripravljamo za x86_64, prek UEFI in klasičnega BIOS-a, ter za pravo strojno opremo: cilj je en ISO, ki se zažene na vseh. Projekt je odprtokoden pod licenco MIT ali Apache 2.0, v tem besedilu pa želimo zapisati, kje smo, kako je zgrajen znotraj in česa smo se med delom naučili.
Kaj natančno pomeni »unikernel«
Običajen posrednik je program, ki teče na operacijskem sistemu. urMQTT pod sabo nima operacijskega sistema: sam se zažene prek UEFI firmwara in zagonskega nalagalnika Limine, vzpostavi lastne tabele strani, programira krmilnik prekinitev in časovnik, prek PCIe poišče naprave virtio, naslov pridobi z DHCP in posluša na enih vratih. Vse, kar bi sicer počeli jedro, gonilniki, omrežni sklad in aplikacija, je tu en ELF, ki teče na ravni EL1.
Primerjava s tipično namestitvijo govori sama zase:
| Tipičen posrednik na Linuxu | urMQTT | |
|---|---|---|
| Izpostavljene storitve | SSH, posrednik, pogosto tudi vrata za metrike ali administracijo | samo TCP 8883 (MQTT prek TLS 1.3 z mTLS) |
| Koda v sliki | jedro, uporabniški prostor, libc, OpenSSL, izvajalno okolje | en Rustov ELF (plus vendoriran SQLite) |
| Oddaljeno upravljanje | lupina, konfiguracijske datoteke, posodobitve paketov | nič. Nova različica je nov ISO |
| Pomnilnik na nedejavno povezavo | kilobajti (medpomnilniki vtičnic, objekti TLS, kopica) | cilj ≤ 512 B |
| Večjedrni model | niti in ključavnice ali ena zanka dogodkov | nit na jedro, brez skupnega stanja, obdelava do konca |
| Celovitost kode | možne so preslikave, ki so hkrati zapisljive in izvršljive | W^X, zaklenjene tabele strani, PAC/BTI |
Prednostne naloge so v tem vrstnem redu: varnost, razširljivost, večjedrna zmogljivost, majhen odtis in nespremenljiva namestitev. Kadar sta dva cilja v navzkrižju, zmaga tisti, ki je na tem seznamu višje.
Kje smo zdaj
Projekt je v predalfa fazi, vendar je zaprtih sedemnajst razvojnih mejnikov, od M0 do M17. Danes urMQTT streže MQTT 3.1.1 prek TLS 1.3 z obveznimi odjemalskimi certifikati, stanje pa hrani v SQLite na surovem disku. Na kratko, kaj že deluje:
- Jedro: lastni vektorji izjem z dekodiranimi poročili, lastne tabele strani z uveljavljenim W^X, zaščitne strani, PAC in BTI, kjer ju procesor ima, GICv3 in GICv2, generični časovnik, SMP do 64 jeder.
- Odkrivanje strojne opreme iz DTB ali ACPI v en opis platforme, z enakim rezultatom iz obeh virov.
- Gonilniki: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi in virtio-rng ter CSPRNG, zasejan s strojno entropijo.
- Omrežje: lasten DHCPv4, ARP, ICMP in strogi IPv4, nato lasten TCP samo za strežniško stran: piškotki SYN, hierarhično kolo časovnikov, NewReno, združevanje ACK-jev, brez stanja TIME_WAIT.
- TLS: samo TLS 1.3, obvezni mTLS, identiteta naprave iz CN ali SAN URI; rokovanje opravi rustls, nato pa lasten sloj zapisov z AES-128-GCM in ChaCha20-Poly1305. Nadaljevanje seje s PSK-vstopnicami, seznam preklicev, nadzor sprejema že pri rokovanju.
- MQTT: kodek brez kopiranja, fuzziran in testiran s pravimi odjemalci mosquitto; QoS 0 in 1, nadomestni znaki, obdržana sporočila, oporoka (LWT), trajne seje, ACL z omejitvijo na
devices/{id}/#, omejitve hitrosti po napravi. - Shranjevanje: SQLite znotraj jedra prek VFS nad surovim diskom, v načinu WAL, s preverjanjem celovitosti ob zagonu; register naprav, preklici, obdržana sporočila, trajne seje z vrstami sporočil za naprave, ki niso povezane; za vsako jedro ločen telemetrijski dnevnik s CRC na zapis in obnovitvijo po sesutju.
- Delovanje in nadzor: nadzorni časovnik za obdelovalce, preverjanje zdravja, omejeno beleženje, syslog in statsd prek UDP, sprejem, ki preživi nevihte ponovnih povezav.
- Dostava: bitno enake ponovne izgradnje, preverjene v CI, ISO in surova GPT slika diska, podpisi minisign, navodila za KVM, Proxmox in Hetzner.
Vsak korak je označen z mejnikom, ki ga je prinesel, datoteka docs/PROGRESS.md v repozitoriju pa je edini vir resnice o tem, kaj res deluje.
Arhitektura v treh slikah
Celoten načrt je opisan v docs/ARCHITECTURE.md, razlogi za vsako pomembnejšo odločitev pa so zapisani kot ADR-ji v docs/DECISIONS.md. Tu je pregled.
Plasti
Od vrha navzdol: posrednik (seje, drevo tem, QoS, obdržana sporočila, oporoke, ACL, omejitve), kodek MQTT, TLS, TCP, omrežna plast in nato gonilniki. Ob tej glavni poti stoji shranjevanje (SQLite in telemetrijski dnevnik), pod vsem pa upravljanje pomnilnika, odkrivanje platforme in konfiguracija, ki je ob izgradnji prevedena v konstante, skupaj z vgrajenim certifikatom CA, strežniškim certifikatom in ključem. Hipervizor z napravami virtio je edino, kar je zunaj slike.
Nit na jedro, brez skupnega stanja
Vsako podatkovno jedro poganja lastno zanko z lastnim skladom TCP, stanji TLS, delom posrednika (seje in drevo tem), predpomnilnikom kopice in particijo telemetrije. Jedra ne delijo nobenega spremenljivega stanja, le omejene obroče brez ključavnic, po enega za vsak urejen par jeder. Najvišje jedro poganja SQLite in nič drugega.
Omrežna kartica ima danes en par vrst, ki ga drži jedro 0. To vsak tok MQTT glede na Toeplitzev hash njegove četverice usmeri na eno od delovnih jeder in pošlje tisto, kar delovna jedra vrnejo. Seja živi na svojem domačem jedru, določenem s hashem identifikatorja odjemalca, zato prevzem, oporoke in trajnost nikoli ne prečkajo jeder. Povezava, ki pristane na drugem jedru, tam obdrži svoj TCP in TLS, navaden MQTT pa posreduje domačemu jedru z nadzorom pretoka, ki temelji na kreditih. Vsako usmerjeno sporočilo se razdeli tudi na preostale dele posrednika, od katerih vsak drži repliko obdržanih sporočil.
Ta model drže skupaj tri pravila. Obdelava do konca: prejeti paket se v celoti obdela na enem jedru, od TCP prek dešifriranja TLS na mestu, razčlenjevanja MQTT in usmerjanja do šifriranega odgovora, brez vrst opravil in brez kopiranja v aplikacijske medpomnilnike. Prilagodljivo poizvedovanje: pod obremenitvijo se vrste za sprejem aktivno poizvedujejo, v mirovanju pa se jedro vrne k prekinitvam in WFI; obdelovalci prekinitev prekinitev le potrdijo in označijo delo. Nadzorni časovnik: utrip na 10 ms na jedro ujame obdelovalce, ki presežejo svoj proračun.
Življenje enega PUBLISH-a
Naprava pošlje zapis TLS. Omrežna kartica ga preda zanki jedra z deskriptorjem, brez kopiranja. Zanka preveri TCP, dešifrira TLS na mestu in razčleni MQTT iz izposojenega medpomnilnika, nato v pomnilniku preveri ACL, omejitev hitrosti in pot skozi drevo tem. Če drugo jedro ta tema zanima, prejme sporočilo za usmerjanje s sklicem na vsebino in identifikatorjem teme; baza prejme telemetrijski zapis ali posodobitev obdržanega sporočila, v paketu. Odgovor PUBACK se šifrira neposredno v izhodni medpomnilnik in združi s TCP ACK, tako da napravi odide en segment. Drugo jedro sporočilo stane eno šifriranje na naročnika v trenutku pošiljanja.
Koliko stane ena povezava
Cilj je največ 512 bajtov na nedejavno povezavo TLS, z željo po 256. Medtem ko povezava miruje, zanjo ni v medpomnilniku ničesar: prejeti podatki se obdelajo neposredno iz omrežnega okvirja, medpomnilniki za pošiljanje, zapise in MQTT pa obstajajo samo, dokler je kaj nedokončanega ali nepotrjenega. Jedro to meri samo: natančni števci kopice v zahtevanih bajtih in v blokih, ki jih zasedajo, zapisani vsakih deset sekund kot kopica nad osnovno ravnjo na odprto povezavo.
| Del | Velikost | Kako |
|---|---|---|
| Kontrolni blok TCP | 96 B | zapakirane zastavice, 16-bitni RTT/RTO, reža v kolesu časovnikov, brez medpomnilnikov v mirovanju |
| Reža v tabeli TCP | 4–8 B | samo indeks povezave, tabela največ do polovice polna |
| Povezava in stanje zapisov TLS 1.3 | 152 B | skrivnost in zaporedje po smeri (40 B); ključ in IV se izpeljeta za vsak zapis |
| Povezava MQTT | 48 B | identiteta in oporoka na kopici samo, dokler obstajata |
| Seja MQTT, vroča oblika | 64 B | ena naročnina v vrstici, internirani identifikatorji vozlišč |
Naročnina devices/<id>/cmd/# |
0 vozlišč drevesa | shranjena relativno glede na identifikator seje, skupna vsem napravam |
Izmerjeno pod HVF s štirimi virtualnimi jedri, od tega tremi delovnimi, pri čemer vsaka naprava drži povezavo mTLS in eno naročnino s QoS 1, vključno z zaokroževanjem alokatorja:
| Povezave | Bajtov na povezavo |
|---|---|
| 960 | 531 B |
| 9 922 | 494 B |
| 10 197 | 474 B |
Bazen omrežnih okvirjev je fiksnih približno 2 MiB, kar je okoli 200 B na povezavo pri desettisoč povezavah in okoli 2 B pri milijonu. Več od tega nismo mogli izmeriti: uporabniško omrežje QEMU na macOS je prenehalo sprejemati povezave pri 10 197, približno na meji 10 240 deskriptorjev procesa QEMU. Število pada, ko se fiksni deli porazdelijo na več povezav, zato ne raste z njihovim številom.
Varnostni model
Varnost je prva prednostna naloga in dobi vse spore z ostalimi cilji. Model ima več plasti:
- Ena vrata. TCP 8883, samo TLS 1.3, obvezni odjemalski certifikati, podpisani s CA, vgrajenim v sliko. Identifikator odjemalca MQTT mora biti enak identifikatorju naprave iz certifikata.
- Brez vhodov. Ni lupine, konzolnega vnosa, SSH, administracijskega API-ja, dinamične kode, SQL, dostopnega prek omrežja, niti datotečnega sistema.
- Pomnilniška varnost. Rust, s
forbid(unsafe_code)v protokolnih paketih; vsak blokunsafeima komentar// SAFETY:, ki ga preverja lint, na zunanji vnos pa ni panike: clippy v paketih na podatkovni poti prepovedujeunwrap,expect, indeksiranje inpanic!. - Ublažitev izkoriščanja. W^X se uveljavlja s kodiranjem deskriptorjev in bitom
SCTLR_EL1.WXNter se revidira ob vsakem zagonu. Tabele strani so po zagonu samo za branje, brez zapisljivega aliasa. Spodnja polovica naslovnega prostora je izključena. Vsak sklad ima zaščitne strani, izjeme pa tečejo na ločenem skladu. PAC in BTI se vklopita, ko ju procesor podpira, to pa preverjajo dimni testi. - Odpornost na zlorabe. Piškotki SYN; sprejem zavrne nove povezave, pred kakršno koli kriptografijo, ko je kopica nizka, ko baza zaostaja, ko je v teku preveč rokovanj na jedro ali na izvorni naslov ali ko je hitrost rokovanj previsoka. Omejitve hitrosti po napravi, omejene vrste, okno TCP, vezano na protipritisk posrednika, omejeno beleženje, nadzorni časovnik in preverjanje zdravja.
- Nespremenljivost. Konfiguracija, CA in strežniški ključ so vgrajeni v sliko. Sprememba česar koli pomeni novo sliko.
Model groženj je zapisan v docs/SECURITY.md.
Shranjevanje brez datotečnega sistema
Podatki živijo na ločenem disku, virtio-blk ali virtio-scsi, na katerem ni datotečnega sistema. Razpored je minimalen in lasten:
LBA 0 [ superblok: magija · različica razporeda · uuid · tabela regij · CRC-32C ]
LBA 8, 16 [ tabela velikosti datotek, reži A/B (zmaga najnovejša veljavna) ]
LBA 64 [ SQLite glavna baza ½ ][ SQLite WAL ⅛ ][ dnevnik 1 MiB ][ telemetrijski dnevnik: 8 particij … ]
zadnjih 4 KiB [ prostor za samopreverjanje blokovne naprave ob zagonu ]
SQLite (vendorirana 3.54.1, s preverjanjem izvlečka ob izgradnji) je v jedro preveden s clangom, brez standardne knjižnice, in uporablja fiksno kopico memsys5. Njegov VFS preslika tri fiksne datoteke na regije diska. Deluje v načinu WAL z locking_mode=EXCLUSIVE, zato ne potrebuje indeksa v deljenem pomnilniku, in s synchronous=FULL; če quick_check ob zagonu najde napako, se sistem varno ustavi. V bazi so register naprav, preklici, obdržana sporočila in trajne seje z vrstami sporočil QoS 1 za naprave, ki niso povezane.
Jedro za bazo, tisto z najvišjo številko, poganja SQLite in nič drugega. Podatkovna ravnina mu pošilja spremembe stanja prek obroča SPSC brez ključavnic; te zbira v eno transakcijo vsakih 5 ms ali vsakih 512 operacij, potrditve pa vrača z drugim obročem. Prazen disk se formatira ob prvem zagonu; vsega drugega se nikoli ne formatira čez.
Telemetrija gre po drugi poti. Vsak sprejeti PUBLISH na temah iz storage.telemetry_topics odide v particijo telemetrijskega dnevnika svojega jedra: obroč segmentov s CRC na zapis, osoljenim z zaporedjem segmenta, tako da se stari prehodi obroča nikoli ne pojavijo znova. Jedra zapise predajo bazi z lastnim obročem, brez kopiranja, ta pa jih izpiše vsakih 5 ms ali vsakih 256 KiB. Ob zagonu obnovitev ohrani vsak izpisani zapis, odreže nedokončan rep in nadaljuje v novem segmentu. Hramba je po velikosti, prek obroča, in po starosti, prek retention_days. To smo preverili z milijonom zapisov in z kill -9 sredi pisanja.
Številke
Orodje za obremenitev v tools/loadgen počne tri stvari: nevihto rokovanj TLS 1.3 z mTLS in CONNECT, dolgotrajne povezave s PINGREQ, ki vsako sekundo objavljajo telemetrijo, in nevihte ponovnih povezav. make bench na Apple M4 Pro pod HVF daje:
| Delovna jedra | rokovanj/s v gostu | skaliranje | prek uporabniškega omrežja QEMU (povezav/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
Število v gostu, kjer sta odjemalec in strežnik znotraj istega virtualnega stroja in vsako delovno jedro dela samo zase, kaže, da podatkovna ravnina skalira linearno. Omrežno število omejuje enonitno uporabniško omrežje QEMU, ne gost; tudi to je zapisano kot ADR-032.
Nevihte ponovnih povezav testiramo tako, da se vse naprave povežejo naenkrat, zavrnjene poskuse ponavljajo z zakasnitvijo in nato vse padejo brez DISCONNECT. Parameter heap_limit_mb= v razvojnem načinu prisili sprejem, da kopico šteje za manjšo, kot je, tako da je nevihto, večjo od kopice, mogoče preizkusiti z malo vtičnicami. V enem takem krogu s 3 000 napravami se jih je v 36 sekundah povezalo 1 368, 5 619 poskusov pa je bilo urejeno zavrnjenih, z omejitvijo 8 MiB; pomnilnik se je po nevihti vrnil.
Kako delamo
Projekt vodi pisna specifikacija, PROMPT.md, z načrtom mejnikov, in vsak mejnik se konča z istim obredom: make check in make smoke zelena, en commit z oznako M<n>, zapis v docs/PROGRESS.md in ADR za vsako odločitev, ki ni očitna. Pravila so kratka in o njih se ne pogajamo: brez panike na zunanji vnos, unsafe samo v strojnih paketih in paketih FFI ter vedno s komentarjem, brez alokacij na vroči poti, komentar ob vsakem vrstnem redu atomskih operacij, vsaka omejitev prihaja iz konfiguracije, namerni začasni deli pa padejo glasno.
CI dela več, kot bi jih mi ročno zmogli: fmt, clippy za gostitelja in za aarch64-unknown-none, typos, actionlint in shellcheck; teste na Linuxu in macOS na arm64; izgradnjo jedra za razvojno in produkcijsko konfiguracijo z velikostjo ELF-a v povzetku; zagon pod QEMU z vsemi scenariji dimnega testa in shranjevanjem serijskih zapisov; cargo-deny za svetovalna obvestila, licence in vire; minuto fuzzinga na vsako od enajstih tarč, ponoči pa deset minut na tarčo s predpomnjenim korpusom. Poleg tega CodeQL, OpenSSF Scorecard, Dependabot in objava s poreklom SLSA. Vsaka tuja akcija je pripeta na polni commit SHA.
Ponovljivost razumemo dobesedno: make repro zgradi HEAD dvakrat v svežih delovnih drevesih in primerja ELF, ISO in surovo sliko bajt za bajtom. Objave so podpisane z minisign, artefakte pa je mogoče preveriti z gh attestation verify. Produkcijska slika vsebuje zasebni ključ strežnika, zato je sam ISO skrivnost; slike ob objavah na GitHubu so razvojne, z začasnimi ključi, in služijo samo za preizkušanje.
Kaj smo se naučili
Nekaj lekcij, ki so spremenile načrt:
- Lasten TCP se je vendarle izplačal. Prototip je tekel na smoltcp in nam je dobro služil do M13. A strežnik, ki drži stotisoče povezav, ne potrebuje odjemalskega TCP, potrebuje piškotke SYN, kolo časovnikov in stanje s 96 bajti. V M14 smo smoltcp zamenjali z lastnim skladom samo za strežniško stran, s testi simulirane izgube in fuzzingom segmentov.
- Pomnilnik merite od znotraj. Ocene na povezavo so bile neuporabne, dokler jedro ni samo začelo šteti kopice v blokih alokatorja in ne v zahtevanih bajtih. Šele takrat smo videli, kam dejansko odhaja 512 bajtov, in se spustili na 494.
- Orodje za testiranje je del izdelka. Največja omejitev ni bilo jedro, temveč uporabniško omrežje QEMU in število efemernih vrat na razvojnem računalniku. Za sto tisoč in več povezav potrebujete
vmnet-sharedna macOS ali Linux s KVM, vmesnikom tap in več odjemalskimi naslovi. - SQLite brez operacijskega sistema je mogoč, a le ob strogih pogojih: fiksna kopica, izključno zaklepanje brez deljenega pomnilnika, VFS, ki ne ve za datoteke, in sprejem, ki bazi ne pusti zaostati.
- Nevihte ponovnih povezav so običajno stanje, ne izjema. Ko mobilno omrežje pade in se vrne, tisoči naprav trkajo hkrati. Zato sprejem povezavo zavrne pred kakršno koli kriptografijo, vsaka zavrnjena povezava pa stane skoraj nič.
Kaj sledi
Delo po M17 je načrtovano v treh smereh, vrstni red med njimi pa je zapisan v docs/PLAN.md. Nič od tega še ni zgrajeno.
- En ISO za več platform. Ista datoteka
urmqtt-<env>-<različica>.isoin ena surova slika GPT, ki se zaganjata na aarch64 UEFI, x86_64 UEFI in x86_64 BIOS, v virtualnih strojih in na pravi strojni opremi, začenši z enim računalnikom Intel. Še vedno naprava z vsem znotraj, ponovljiva in podpisana. - Surovi disk, brez datotečnega sistema. Specifikacija formata na ravni bajta, gostiteljsko orodje
urmqtt-disk, ki si s jedrom deli razčlenjevalnike, poravnava regij na 1 MiB, rast diska s podatki, discard za tanko dodeljene diske, posnetki baze A/B, preverjanje bitov in, po izbiri, šifriranje AES-256-XTS s strojnim AES. - Dokazljivo varno. Preklic in seznam dovoljenih naprav med delovanjem prek podpisanega kontrolnega kanala, brez nove slike; diferencialno in skladnostno testiranje lastnega TLS in TCP; KASLR, zaščita sklada, kCFI, MTE in utrjen alokator; SQLite, izoliran na ravni EL0; Secure Boot; SBOM in cargo-vet; in na koncu paket za zunanjo revizijo.
Platforme, ki ponujajo samo naprave, ki niso virtio, kot sta AWS ENA ali Azure MANA, zaenkrat niso podprte. Raspberry Pi 4 in 5 kot gostitelj KVM, Hetzner CAX in Oracle Ampere A1 so dokumentirani in podprti s kodo, vendar še niso preizkušeni na pravi storitvi.
Preizkusite
Repozitorij je na github.com/zeljan-alduk/urMQTT. Z Rustom s pripeto nightly verigo orodij in QEMU so make certs, make run in en odjemalec mosquitto dovolj za prvi PUBLISH prek mTLS na vašem računalniku. Opozorilo z začetka velja tudi na koncu: to je predalfa in vse, kar deluje, piše v docs/PROGRESS.md. Prijave napak, vprašanja o zasnovi in varnostne prijave po navodilih v SECURITY.md so dobrodošle. O vsakem naslednjem mejniku se bomo oglasili tukaj.
Komentarji
Pravila komentiranja