urMQTT: we bouwen een MQTT-broker die tegelijk het besturingssysteem is
Ontwikkelaantekeningen over urMQTT, een in Rust geschreven unikernel dat zelf een MQTT-broker is: één ELF met eigen TCP/IP, TLS 1.3, broker en SQLite, opgestart vanaf één ISO.
Apparaten in voertuigen, op parkeerterreinen en in schakelkasten hebben één eigenschap gemeen: ze sturen kleine berichten, zelden, maar er zijn er veel en ze zijn voortdurend verbonden. Al jaren verbinden we ze met MQTT-brokers, en al jaren houdt ons dezelfde vraag bezig. Waarom hebben we voor iets zo eenvoudigs een compleet Linux nodig, OpenSSL, de runtime van een programmeertaal, SSH, een pakketbeheerder en pas dan de broker? Elk van die lagen brengt eigen kwetsbaarheden mee, verbruikt eigen geheugen en voegt weer iets toe wat kapot kan gaan, terwijl een vloot apparaten van een server maar drie dingen nodig heeft: veel vrijwel inactieve verbindingen vasthouden, elk apparaat betrouwbaar authenticeren en berichten zonder verlies doorgeven.
Zo is urMQTT ontstaan: een unikernel die zelf de MQTT-broker is. Kernel, TCP/IP, TLS 1.3, MQTT en een ingebouwde database in één Rust-binary die vanaf één ISO wordt gestart. Geen processen, geen systeemaanroepen, geen shell. Het eerste platform waarop het wordt gebouwd en getest is aarch64, en dezelfde code gaat richting x86_64, via UEFI en klassieke BIOS, en richting echte hardware: het doel is één ISO die op al die platformen opstart. Het project is open source onder de licentie MIT of Apache 2.0, en in dit artikel leggen we vast waar we staan, hoe het van binnen in elkaar zit en wat we onderweg hebben geleerd.
Wat "unikernel" precies betekent
Een gebruikelijke broker is een programma dat op een besturingssysteem draait. urMQTT heeft geen besturingssysteem onder zich: het start zichzelf op via UEFI-firmware en de Limine-bootloader, zet eigen paginatabellen op, programmeert de interruptcontroller en de timer, vindt virtio-apparaten via PCIe, krijgt een adres via DHCP en luistert op één poort. Alles wat normaal door de kernel, de stuurprogramma's, de netwerkstack en de applicatie wordt gedaan, is hier één ELF dat op EL1-niveau draait.
Een vergelijking met een typische installatie zegt genoeg:
| Typische broker op Linux | urMQTT | |
|---|---|---|
| Blootgestelde diensten | SSH, broker, vaak ook een poort voor metrics of beheer | alleen TCP 8883 (MQTT over TLS 1.3 met mTLS) |
| Code in de image | kernel, userland, libc, OpenSSL, runtime | één Rust-ELF (plus gevendord SQLite) |
| Beheer op afstand | shell, configuratiebestanden, pakketupdates | niets. Een nieuwe versie is een nieuwe ISO |
| Geheugen per inactieve verbinding | kilobytes (socketbuffers, TLS-objecten, heap) | doel ≤ 512 B |
| Multicoremodel | threads en locks of één eventloop | één thread per core, geen gedeelde toestand, run-to-completion |
| Code-integriteit | mappings die zowel beschrijfbaar als uitvoerbaar zijn, zijn mogelijk | W^X, vergrendelde paginatabellen, PAC/BTI |
De prioriteiten zijn, in deze volgorde: veiligheid, schaalbaarheid, multicoreprestaties, kleine footprint en een onveranderlijke installatie. Wanneer twee doelen botsen, wint het doel dat hoger op deze lijst staat.
Waar we nu staan
Het project bevindt zich in de pre-alfafase, maar zeventien ontwikkelmijlpalen, van M0 tot en met M17, zijn afgerond. Vandaag bedient urMQTT MQTT 3.1.1 over TLS 1.3 met verplichte clientcertificaten en bewaart het zijn toestand in SQLite op een rauwe schijf. In het kort wat al werkt:
- Kernel: eigen exceptievectoren met gedecodeerde rapporten, eigen paginatabellen met afgedwongen W^X, guard pages, PAC en BTI waar de processor ze heeft, GICv3 en GICv2, generieke timer, SMP tot 64 cores.
- Hardwaredetectie uit DTB of ACPI naar één platformbeschrijving, met een identiek resultaat uit beide bronnen.
- Stuurprogramma's: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi en virtio-rng, plus een CSPRNG die met hardware-entropie wordt geïnitialiseerd.
- Netwerk: eigen DHCPv4, ARP, ICMP en strikt IPv4, daarnaast een eigen TCP alleen voor de serverkant: SYN-cookies, hiërarchisch timerwiel, NewReno, samenvoegen van ACK's, geen TIME_WAIT-toestand.
- TLS: alleen TLS 1.3, verplichte mTLS, apparaatidentiteit uit de CN of de SAN-URI; de handshake wordt door rustls afgehandeld, daarna volgt een eigen recordlaag met AES-128-GCM en ChaCha20-Poly1305. Sessiehervatting met PSK-tickets, een intrekkingslijst, toelatingscontrole al tijdens de handshake.
- MQTT: codec zonder kopiëren, gefuzzd en getest met echte mosquitto-clients; QoS 0 en 1, wildcards, retained berichten, last will (LWT), persistente sessies, ACL met beperking tot
devices/{id}/#, snelheidslimieten per apparaat. - Opslag: SQLite in de kernel via een VFS bovenop een rauwe schijf, in WAL-modus, met een integriteitscontrole bij het opstarten; apparaatregister, intrekkingen, retained berichten, persistente sessies met berichtenwachtrijen voor offline apparaten; per core een afzonderlijk telemetriejournaal met een CRC per record en herstel na een crash.
- Bedrijf en bewaking: een watchdogtimer voor handlers, een gezondheidscontrole, begrensde logging, syslog en statsd via UDP, een toelatingslaag die stormen van heropgebouwde verbindingen overleeft.
- Oplevering: bitgewijs identieke herbouwen, gecontroleerd in CI, ISO en rauw GPT-schijfimage, minisign-handtekeningen, handleidingen voor KVM, Proxmox en Hetzner.
Elke stap is gemarkeerd met de mijlpaal die hem heeft opgeleverd, en het bestand docs/PROGRESS.md in de repository is de enige bron van waarheid over wat er werkelijk werkt.
De architectuur in drie plaatjes
Het volledige ontwerp staat beschreven in docs/ARCHITECTURE.md, en de redenen achter elke belangrijke beslissing zijn vastgelegd als ADR's in docs/DECISIONS.md. Hier volgt een overzicht.
Lagen
Van boven naar beneden: de broker (sessies, onderwerpenboom, QoS, retained berichten, last wills, ACL, limieten), de MQTT-codec, TLS, TCP, de netwerklaag en dan de stuurprogramma's. Naast dat hoofdpad staat de opslag (SQLite en het telemetriejournaal), en onder alles het geheugenbeheer, de platformdetectie en de configuratie die tijdens de build in constanten is omgezet, samen met het ingebouwde CA-certificaat, het servercertificaat en de sleutel. De hypervisor met de virtio-apparaten is het enige wat buiten de image valt.
Eén thread per core, geen gedeelde toestand
Elke datacore draait een eigen lus met een eigen TCP-stack, eigen TLS-toestanden, een eigen deel van de broker (sessies en onderwerpenboom), een heapcache en een telemetriepartitie. De cores delen geen enkele veranderlijke toestand, alleen begrensde lock-vrije ringen, één voor elk geordend paar cores. De hoogste core draait SQLite en niets anders.
De netwerkkaart heeft op dit moment één paar wachtrijen, dat door core 0 wordt beheerd. Die stuurt elke MQTT-stroom op basis van de Toeplitz-hash van zijn viertal naar een van de werkcores en verstuurt wat de werkcores teruggeven. Een sessie leeft op haar thuiscore, bepaald door de hash van de client-ID, zodat overname, last wills en persistentie nooit cores overschrijden. Een verbinding die op een andere core terechtkomt, houdt daar haar TCP en TLS en stuurt gewone MQTT door naar de thuiscore, met flowcontrol op basis van credits. Elk doorgestuurd bericht wordt bovendien verspreid naar de overige delen van de broker, die elk een replica van de retained berichten bijhouden.
Drie regels houden dat model bijeen. Run-to-completion: een ontvangen pakket wordt volledig op één core afgehandeld, van TCP via TLS-ontsleuteling ter plekke, MQTT-parsing en routering tot het versleutelde antwoord, zonder taakwachtrijen en zonder kopieën naar applicatiebuffers. Adaptieve polling: onder belasting worden de ontvangstwachtrijen actief gepold, en in rust keert de core terug naar interrupts en WFI; interrupthandlers bevestigen alleen de interrupt en markeren werk. Watchdogtimer: een tik van 10 ms per core vangt handlers op die hun budget overschrijden.
Het leven van één PUBLISH
Het apparaat verstuurt een TLS-record. De netwerkkaart geeft het via een descriptor zonder kopiëren aan de lus van de core. De lus controleert TCP, ontsleutelt TLS ter plekke en parset MQTT uit de geleende buffer, en controleert daarna in het geheugen de ACL, de snelheidslimiet en het pad door de onderwerpenboom. Als een andere core in dat onderwerp geïnteresseerd is, krijgt die een routeringsbericht met een verwijzing naar de payload en de onderwerp-ID; de database krijgt een telemetrierecord of een update van een retained bericht, gebundeld. Het PUBACK-antwoord wordt rechtstreeks in de uitgaande buffer versleuteld en samengevoegd met de TCP-ACK, zodat er één segment naar het apparaat gaat. Voor een andere core kost het bericht één versleuteling per abonnee op het moment van verzenden.
Wat een verbinding kost
Het doel is maximaal 512 bytes per inactieve TLS-verbinding, met 256 als streven. Zolang een verbinding rust, wordt er niets voor haar in een buffer bewaard: ontvangen gegevens worden rechtstreeks uit het netwerkframe verwerkt, en de buffers voor verzenden, records en MQTT bestaan alleen zolang er iets onafgemaakt of onbevestigd is. De kernel meet dat zelf: exacte heaptellers in gevraagde bytes en in de blokken die ze innemen, elke tien seconden vastgelegd als heap boven het basisniveau per open verbinding.
| Onderdeel | Grootte | Hoe |
|---|---|---|
| TCP-controleblok | 96 B | gepakte vlaggen, 16-bits RTT/RTO, slot in het timerwiel, geen buffers in rust |
| Slot in de TCP-tabel | 4–8 B | alleen de verbindingsindex, tabel hoogstens half vol |
| Verbinding en toestand van het TLS 1.3-record | 152 B | geheim en volgnummer per richting (40 B); sleutel en IV worden per record afgeleid |
| MQTT-verbinding | 48 B | identiteit en last will op de heap alleen zolang ze bestaan |
| MQTT-sessie, hete vorm | 64 B | één abonnement in de rij, geïnterneerde knoop-ID's |
Abonnement devices/<id>/cmd/# |
0 knopen in de boom | relatief aan de sessie-ID opgeslagen, gedeeld door alle apparaten |
Gemeten onder HVF met vier virtuele cores, waarvan drie werkcores, waarbij elk apparaat een mTLS-verbinding en één abonnement met QoS 1 vasthoudt, inclusief afronding door de allocator:
| Verbindingen | Bytes per verbinding |
|---|---|
| 960 | 531 B |
| 9.922 | 494 B |
| 10.197 | 474 B |
De pool met netwerkframes is vast ongeveer 2 MiB groot, wat neerkomt op circa 200 B per verbinding bij tienduizend verbindingen en circa 2 B bij een miljoen. Verder konden we niet meten: de gebruikersnetwerkmodus van QEMU op macOS accepteerde bij 10.197 geen verbindingen meer, ongeveer op de grens van 10.240 descriptors van het QEMU-proces. Het getal daalt naarmate de vaste delen over meer verbindingen worden verdeeld, en groeit dus niet met hun aantal.
Het beveiligingsmodel
Veiligheid is de eerste prioriteit en wint alle conflicten met de andere doelen. Het model heeft meerdere lagen:
- Eén poort. TCP 8883, alleen TLS 1.3, verplichte clientcertificaten die zijn ondertekend door de in de image ingebouwde CA. De MQTT-client-ID moet gelijk zijn aan de apparaat-ID uit het certificaat.
- Geen ingang. Geen shell, geen console-invoer, geen SSH, geen beheer-API, geen dynamische code, geen via het netwerk bereikbare SQL en geen bestandssysteem.
- Geheugenveiligheid. Rust, met
forbid(unsafe_code)in de protocolpakketten; elkunsafe-blok heeft een// SAFETY:-commentaar dat door een lint wordt gecontroleerd, en bij externe invoer treedt er geen panic op: clippy verbiedt in de pakketten op het datapadunwrap,expect, indexering enpanic!. - Beperking van exploits. W^X wordt afgedwongen via de codering van descriptors en het bit
SCTLR_EL1.WXNen bij elke start gecontroleerd. De paginatabellen zijn na het opstarten alleen-lezen, zonder beschrijfbare alias. De onderste helft van de adresruimte is uitgesloten. Elke stack heeft guard pages en exceptions draaien op een aparte stack. PAC en BTI worden ingeschakeld wanneer de processor ze ondersteunt, en dat wordt door smoke-tests gecontroleerd. - Weerstand tegen misbruik. SYN-cookies; de toelatingslaag weigert nieuwe verbindingen, vóór enige cryptografie, wanneer de heap laag is, de database achterloopt, er per core of per bronadres te veel handshakes lopen of de handshake-snelheid te hoog is. Snelheidslimieten per apparaat, begrensde wachtrijen, een TCP-venster dat aan de tegendruk van de broker is gekoppeld, begrensde logging, een watchdogtimer en een gezondheidscontrole.
- Onveranderlijkheid. Configuratie, CA en serversleutel zijn in de image ingebouwd. Iets wijzigen betekent een nieuwe image.
Het dreigingsmodel is vastgelegd in docs/SECURITY.md.
Opslag zonder bestandssysteem
De gegevens leven op een aparte schijf, virtio-blk of virtio-scsi, waarop geen bestandssysteem staat. De indeling is minimaal en eigen:
LBA 0 [ superblok: magic · indelingsversie · uuid · regiotabel · CRC-32C ]
LBA 8, 16 [ tabel met bestandsgroottes, slots A/B (de nieuwste geldige wint) ]
LBA 64 [ SQLite-hoofddatabase ½ ][ SQLite WAL ⅛ ][ journaal 1 MiB ][ telemetriejournaal: 8 partities … ]
laatste 4 KiB [ ruimte voor de zelftest van het blokapparaat bij het opstarten ]
SQLite (gevendord 3.54.1, met controle van de hash tijdens de build) is met clang in de kernel gecompileerd, zonder standaardbibliotheek, en gebruikt een vaste memsys5-heap. Zijn VFS koppelt drie vaste bestanden aan schijfregio's. Het draait in WAL-modus met locking_mode=EXCLUSIVE, zodat het geen index in gedeeld geheugen nodig heeft, en met synchronous=FULL; als quick_check bij het opstarten een fout vindt, stopt het systeem veilig. In de database staan het apparaatregister, intrekkingen, retained berichten en persistente sessies met wachtrijen van QoS 1-berichten voor apparaten die niet verbonden zijn.
De core voor de database, die met het hoogste nummer, draait SQLite en niets anders. Het datavlak stuurt haar toestandswijzigingen via een lock-vrije SPSC-ring; zij bundelt ze elke 5 ms of elke 512 operaties in één transactie en stuurt de bevestigingen via een tweede ring terug. Een lege schijf wordt bij de eerste start geformatteerd; al het andere wordt nooit overschreven.
Telemetrie gaat langs een andere weg. Elke geaccepteerde PUBLISH op onderwerpen uit storage.telemetry_topics gaat naar de partitie van het telemetriejournaal van zijn core: een ring van segmenten met een CRC per record, gezouten met het volgnummer van het segment, zodat oude rondes van de ring nooit opnieuw opduiken. De cores geven records via een eigen ring zonder kopiëren aan de database door, die ze elke 5 ms of elke 256 KiB wegschrijft. Bij het opstarten behoudt het herstel elk weggeschreven record, snijdt het onvoltooide einde af en gaat verder in een nieuw segment. Bewaren gebeurt op grootte, via de ring, en op leeftijd, via retention_days. We hebben dit getest met een miljoen records en met kill -9 midden in het schrijven.
De cijfers
De belastingstool in tools/loadgen doet drie dingen: een storm van TLS 1.3-handshakes met mTLS en CONNECT, langlevende verbindingen met PINGREQ die elke seconde telemetrie publiceren, en reconnect-stormen. make bench op een Apple M4 Pro onder HVF geeft:
| Werkcores | handshakes/s in de gast | schaling | via het gebruikersnetwerk van QEMU (verbindingen/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1.724 | 2,00× | 636 |
| 4 | 3.438 | 3,99× | 536 |
Het getal in de gast, waar client en server binnen dezelfde virtuele machine zitten en elke werkcore voor zichzelf werkt, laat zien dat het datavlak lineair schaalt. Het netwerkgetal wordt begrensd door het enkelvoudig-threadige gebruikersnetwerk van QEMU, niet door de gast; ook dat is vastgelegd als ADR-032.
Reconnect-stormen testen we door alle apparaten tegelijk te laten verbinden, geweigerde pogingen met uitstel te herhalen en ze daarna allemaal zonder DISCONNECT te laten wegvallen. De parameter heap_limit_mb= laat de toelatingslaag in de ontwikkelmodus de heap kleiner inschatten dan hij is, zodat een storm die groter is dan de heap met weinig sockets kan worden uitgeprobeerd. In één zo'n ronde met 3.000 apparaten maakten er 1.368 verbinding in 36 seconden, en 5.619 pogingen werden netjes geweigerd, met een limiet van 8 MiB; na de storm keerde het geheugen terug.
Hoe we werken
Het project wordt geleid door een geschreven specificatie, PROMPT.md, met een mijlpalenplan, en elke mijlpaal eindigt met hetzelfde ritueel: make check en make smoke groen, één commit met het label M<n>, een aantekening in docs/PROGRESS.md en een ADR voor elke beslissing die niet vanzelfsprekend is. De regels zijn kort en we onderhandelen er niet over: geen panic bij externe invoer, unsafe alleen in hardware- en FFI-pakketten en altijd met commentaar, geen allocaties op het hete pad, commentaar bij elke volgorde van atomaire operaties, elke limiet komt uit de configuratie, en bewuste tijdelijke onderdelen falen luid.
CI doet meer dan wij met de hand zouden halen: fmt, clippy voor de host en voor aarch64-unknown-none, typos, actionlint en shellcheck; tests op Linux en macOS op arm64; een build van de kernel voor de ontwikkel- en productieconfiguratie met de grootte van de ELF in de samenvatting; opstarten onder QEMU met alle smoke-testscenario's en het bewaren van seriële logs; cargo-deny voor advisories, licenties en bronnen; een minuut fuzzing per elk van de elf doelen, en 's nachts tien minuten per doel met een gecachet corpus. Daarnaast CodeQL, OpenSSF Scorecard, Dependabot en publicatie met SLSA-herkomst. Elke actie van derden is vastgezet op een volledige commit-SHA.
Reproduceerbaarheid nemen we letterlijk: make repro bouwt HEAD tweemaal in verse werkbomen en vergelijkt de ELF, de ISO en het rauwe image byte voor byte. Releases zijn ondertekend met minisign en de artefacten kunnen worden gecontroleerd met gh attestation verify. De productie-image bevat de privésleutel van de server, dus de ISO zelf is een geheim; de images bij de releases op GitHub zijn ontwikkelimages, met tijdelijke sleutels, en dienen alleen om te proberen.
Wat we hebben geleerd
Een paar lessen die het plan hebben veranderd:
- Een eigen TCP bleek toch de moeite waard. Het prototype draaide op smoltcp en heeft ons tot en met M13 goed gediend. Maar een server die honderdduizenden verbindingen vasthoudt, heeft geen client-TCP nodig, maar SYN-cookies, een timerwiel en een toestand van 96 bytes. In M14 hebben we smoltcp vervangen door een eigen stack alleen voor de serverkant, met tests met gesimuleerd pakketverlies en fuzzing van segmenten.
- Meet het geheugen van binnenuit. Schattingen per verbinding waren nutteloos totdat de kernel zelf de heap ging tellen in allocatorblokken in plaats van in gevraagde bytes. Pas toen zagen we waar die 512 bytes werkelijk blijven en kwamen we uit op 494.
- Testgereedschap is deel van het product. De grootste beperking was niet de kernel, maar de gebruikersnetwerkmodus van QEMU en het aantal efemere poorten op de ontwikkelcomputer. Voor honderdduizend verbindingen of meer heeft u
vmnet-sharedop macOS nodig, of Linux met KVM, een tap-interface en meerdere clientadressen. - SQLite zonder besturingssysteem is mogelijk, maar alleen onder strenge voorwaarden: een vaste heap, exclusieve vergrendeling zonder gedeeld geheugen, een VFS die niets van bestanden weet, en een toelatingslaag die de database niet laat achterlopen.
- Reconnect-stormen zijn de normale toestand, geen uitzondering. Wanneer een mobiel netwerk uitvalt en terugkomt, kloppen duizenden apparaten tegelijk aan. Daarom weigert de toelatingslaag een verbinding vóór enige cryptografie, en kost elke geweigerde verbinding vrijwel niets.
Wat volgt
Het werk na M17 is in drie richtingen gepland, en de volgorde daartussen staat in docs/PLAN.md. Niets hiervan is al gebouwd.
- Eén ISO voor meerdere platformen. Hetzelfde bestand
urmqtt-<env>-<versie>.isoen één rauw GPT-image die opstarten op aarch64 UEFI, x86_64 UEFI en x86_64 BIOS, in virtuele machines en op echte hardware, te beginnen met één Intel-pc. Nog steeds een appliance met alles erin, reproduceerbaar en ondertekend. - Rauwe schijf, zonder bestandssysteem. Een formaatspecificatie op byteniveau, de hosttool
urmqtt-diskdie de parsers met de kernel deelt, uitlijning van regio's op 1 MiB, groei van de schijf met de gegevens, discard voor thin-provisioned schijven, A/B-snapshots van de database, bitcontrole en, naar keuze, versleuteling met AES-256-XTS met hardware-AES. - Aantoonbaar veilig. Intrekking en een lijst met toegestane apparaten tijdens bedrijf via een ondertekend controlekanaal, zonder nieuwe image; differentiële tests en conformiteitstests van de eigen TLS en TCP; KASLR, stackbescherming, kCFI, MTE en een geharde allocator; SQLite geïsoleerd op EL0-niveau; Secure Boot; SBOM en cargo-vet; en tot slot een pakket voor een externe audit.
Platformen die alleen niet-virtio-apparaten aanbieden, zoals AWS ENA of Azure MANA, worden voorlopig niet ondersteund. Raspberry Pi 4 en 5 als KVM-host, Hetzner CAX en Oracle Ampere A1 zijn gedocumenteerd en in code ondersteund, maar nog niet getest op een echte dienst.
Probeer het uit
De repository staat op github.com/zeljan-alduk/urMQTT. Met Rust met een vastgezette nightly-toolchain en QEMU zijn make certs, make run en één mosquitto-client genoeg voor de eerste PUBLISH over mTLS op uw eigen computer. De waarschuwing van het begin geldt ook aan het eind: dit is pre-alfa, en alles wat werkt staat in docs/PROGRESS.md. Foutmeldingen, vragen over het ontwerp en beveiligingsmeldingen volgens de instructies in SECURITY.md zijn welkom. Over elke volgende mijlpaal laten we hier van ons horen.
Reacties
Reactieregels