urMQTT: градимо MQTT брокер који је уједно и оперативни систем
Развојне белешке о urMQTT-у, уникернелу написаном у Rustu који је сам по себи MQTT брокер: један ELF са сопственим TCP/IP-ом, TLS-ом 1.3, брокером и SQLite-ом, покренут са једног ISO-а.
Уређаји у возилима, на паркиралиштима и у ормарима са електроником имају једну заједничку особину: шаљу мале поруке, ретко, али их је много и стално су повезани. Годинама их повезујемо на MQTT брокере, и годинама нас мучи исто питање. Зашто за нешто тако једноставно треба цео Linux, OpenSSL, runtime неког језика, SSH, менаџер пакета и тек онда брокер? Сваки од тих слојева доноси сопствене рањивости, троши сопствену меморију и додаје још једну ствар која може да се поквари, а флота уређаја од сервера треба само троје: да држи много готово неактивних веза, да сваки уређај поуздано аутентификује и да поруке пренесе без губитка.
Тако је настао urMQTT: уникернел који јесте MQTT брокер. Језгро, TCP/IP, TLS 1.3, MQTT и уграђена база података у једном Rust бинарном програму који се покреће са једног ISO-а. Нема процеса, нема системских позива, нема љуске. Прва платформа на којој се гради и тестира је aarch64, а исти код иде ка x86_64, преко UEFI-ја и класичног BIOS-а, и ка правом хардверу: циљ је један ISO који се подиже на свему томе. Пројекат је отвореног кода под лиценцом MIT или Apache 2.0, и у овом тексту желимо да забележимо докле смо стигли, како је изнутра сложен и шта смо успут научили.
Шта тачно значи „уникернел”
Уобичајени брокер је програм који ради на оперативном систему. urMQTT нема оперативни систем испод себе: сам се подиже кроз UEFI фирмвер и Limine bootloader, поставља сопствене табеле страница, програмира контролер прекида и тајмер, проналази virtio уређаје преко PCIe-а, добија адресу преко DHCP-а и слуша на једном порту. Све што би иначе радили језгро, управљачки програми, мрежни стек и апликација, овде је један ELF који ради на нивоу EL1.
Поређење са типичном инсталацијом говори само за себе:
| Типичан брокер на Linuxу | urMQTT | |
|---|---|---|
| Изложени сервиси | SSH, брокер, често и порт за метрике или администрацију | само TCP 8883 (MQTT преко TLS-а 1.3 са mTLS-ом) |
| Код у слици | језгро, userland, libc, OpenSSL, runtime | један Rust ELF (плус вендорисани SQLite) |
| Удаљено управљање | љуска, конфигурационе датотеке, ажурирања пакета | ништа. Нова верзија је нови ISO |
| Меморија по неактивној вези | килобајти (међуспремници сокета, TLS објекти, heap) | циљ ≤ 512 B |
| Вишејезгарни модел | нити и браве или једна петља догађаја | нит по језгру, без дељеног стања, обрада до краја |
| Интегритет кода | могућа су пресликавања која су и уписива и извршна | W^X, закључане табеле страница, PAC/BTI |
Приоритети су, тим редом: безбедност, скалабилност, вишејезгарне перформансе, мали отисак и непроменљива инсталација. Кад год су два циља у сукобу, побеђује онај који је виши на тој листи.
Где смо сада
Пројекат је у пред-алфа фази, али је затворено седамнаест развојних прекретница, од M0 до M17. Данас urMQTT послужује MQTT 3.1.1 преко TLS-а 1.3 са обавезним клијентским сертификатима, а стање чува у SQLite-у на сировом диску. Укратко, шта већ ради:
- Језгро: сопствени вектори изузетака са декодираним извештајима, сопствене табеле страница са спроведеним W^X-ом, заштитне странице, PAC и BTI где их процесор има, GICv3 и GICv2, генерички тајмер, SMP до 64 језгра.
- Откривање хардвера из DTB-а или ACPI-ја у један опис платформе, са идентичним резултатом из оба извора.
- Управљачки програми: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi и virtio-rng, те CSPRNG засејан хардверском ентропијом.
- Мрежа: сопствени DHCPv4, ARP, ICMP и строги IPv4, па сопствени TCP само за серверску страну: SYN колачићи, хијерархијски точак тајмера, NewReno, спајање ACK-ова, без TIME_WAIT стања.
- TLS: само TLS 1.3, обавезни mTLS, идентитет уређаја из CN-а или SAN URI-ја; руковање ради rustls, а након њега сопствени слој записа са AES-128-GCM-ом и ChaCha20-Poly1305. Наставак сесије PSK улазницама, листа опозива, контрола пријема већ код руковања.
- MQTT: кодек без копирања, фазиран и тестиран са правим mosquitto клијентима; QoS 0 и 1, џокери, задржане поруке, опорука (LWT), трајне сесије, ACL са ограничењем на
devices/{id}/#, ограничења брзине по уређају. - Похрана: SQLite унутар језгра преко VFS-а над сировим диском, у WAL режиму, са провером интегритета при подизању; регистар уређаја, опозиви, задржане поруке, трајне сесије са редовима порука за уређаје који су offline; по језгру засебан телеметријски дневник са CRC-ом по запису и опоравком након пада.
- Погон и надзор: надзорни тајмер за обрађиваче, здравствена провера, ограничено логовање, syslog и statsd преко UDP-а, пријем који преживљава олује поновних повезивања.
- Испорука: битовно идентичне поновне изградње проверене у CI-ју, ISO и сирова GPT слика диска, minisign потписи, упутства за KVM, Proxmox и Hetzner.
Сваки корак је означен прекретницом која га је донела, а датотека docs/PROGRESS.md у репозиторијуму једини је извор истине о томе шта стварно ради.
Архитектура у три слике
Цео дизајн описан је у docs/ARCHITECTURE.md, а разлози за сваку важнију одлуку записани су као ADR-ови у docs/DECISIONS.md. Овде је преглед.
Слојеви
Од врха ка дну: брокер (сесије, стабло тема, QoS, задржане поруке, опоруке, ACL, ограничења), MQTT кодек, TLS, TCP, мрежни слој, па управљачки програми. Поред тог главног пута стоји похрана (SQLite и телеметријски дневник), а испод свега управљање меморијом, откривање платформе и конфигурација која је у време изградње преведена у константе, заједно са уграђеним CA сертификатом, серверским сертификатом и кључем. Хипервизор са virtio уређајима једино је што је изван слике.
Нит по језгру, без дељеног стања
Свако податковно језгро врти сопствену петљу са сопственим TCP стеком, TLS стањима, делом брокера (сесије и стабло тема), кеш меморијом heapа и партицијом телеметрије. Језгра не деле никакво променљиво стање, само ограничене прстенове без брава, по један за сваки уређени пар језгара. Највише језгро покреће SQLite и ништа друго.
Мрежна картица данас има један пар редова који држи језгро 0. Оно сваки MQTT ток према Toeplitz хешу његове четворке усмерава на једно од радних језгара и шаље оно што радна језгра врате. Сесија живи на свом домаћем језгру, одређеном хешом идентификатора клијента, па преузимање, опоруке и трајност никад не прелазе језгра. Веза која слети на друго језгро задржава тамо свој TCP и TLS, а обичан MQTT прослеђује домаћем језгру уз контролу тока заснованом на кредитима. Свака усмерена порука разгранава се и на остале делове брокера, од којих сваки држи реплику задржаних порука.
Три правила држе тај модел на окупу. Обрада до краја: примљени пакет обрађује се у целости на једном језгру, од TCP-а преко дешифровања TLS-а на месту, парсирања MQTT-а и усмеравања до шифрованог одговора, без редова задатака и без копирања у апликационе међуспремнике. Прилагодљиво испитивање: под оптерећењем се редови за пријем активно испитују, а у мировању се језгро враћа на прекиде и WFI; обрађивачи прекида само потврде прекид и означе посао. Надзорни тајмер: откуцај од 10 ms по језгру хвата обрађиваче који премаше свој буџет.
Живот једног PUBLISH-а
Уређај пошаље TLS запис. Мрежна картица га преда петљи језгра дескриптором, без копирања. Петља провери TCP, дешифрује TLS на месту и парсира MQTT из позајмљеног међуспремника, затим у меморији провери ACL, ограничење брзине и пут кроз стабло тема. Ако неко друго језгро занима та тема, добије поруку за усмеравање са референцом на садржај и идентификатором теме; база добије телеметријски запис или ажурирање задржане поруке, у пакету. Одговор PUBACK шифрује се директно у одлазни међуспремник и спаја са TCP ACK-ом, па уређају одлази један сегмент. Друго језгро порука кошта једно шифровање по претплатнику у тренутку слања.
Колико кошта једна веза
Циљ је највише 512 бајтова по неактивној TLS вези, са тежњом ка 256. Док веза мирује, ништа се за њу не држи у међуспремнику: примљени подаци обрађују се директно из мрежног оквира, а међуспремници за слање, записе и MQTT постоје само док је нешто недовршено или непотврђено. Језгро то и само мери: тачни бројачи heapа у траженим бајтовима и у блоковима које заузимају, записани сваких десет секунди као heap изнад основног нивоа по отвореној вези.
| Део | Величина | Како |
|---|---|---|
| TCP контролни блок | 96 B | запаковане заставице, 16-битни RTT/RTO, утор у точку тајмера, без међуспремника у мировању |
| Утор у TCP табели | 4–8 B | само индекс везе, табела највише напола пуна |
| Веза и стање TLS 1.3 записа | 152 B | тајна и секвенца по смеру (40 B); кључ и IV изводе се по запису |
| MQTT веза | 48 B | идентитет и опорука на heapу само док постоје |
| MQTT сесија, врућ облик | 64 B | једна претплата у реду, интернирани идентификатори чворова |
Претплата devices/<id>/cmd/# |
0 чворова стабла | похрањена релативно према идентификатору сесије, заједничка свим уређајима |
Измерено под HVF-ом са четири виртуелна језгра, од тога три радна, при чему сваки уређај држи mTLS везу и једну претплату са QoS 1, укључујући заокруживање алокатора:
| Везе | Бајтова по вези |
|---|---|
| 960 | 531 B |
| 9 922 | 494 B |
| 10 197 | 474 B |
Базен мрежних оквира фиксан је око 2 MiB, што је отприлике 200 B по вези при десет хиљада веза и око 2 B при милион. Више од тога нисмо могли да измеримо: QEMU-ово корисничко умрежавање на macOS-у престало је да прихвата везе на 10 197, отприлике на граници од 10 240 дескриптора QEMU процеса. Бројка пада како се фиксни делови распоређују на више веза, па не расте са њиховим бројем.
Безбедносни модел
Безбедност је први приоритет и добија све сукобе са осталим циљевима. Модел има неколико слојева:
- Један порт. TCP 8883, само TLS 1.3, обавезни клијентски сертификати потписани CA-ом уграђеним у слику. Идентификатор MQTT клијента мора бити једнак идентификатору уређаја из сертификата.
- Нема улаза. Нема љуске, конзолног уноса, SSH-а, администрацијског API-ја, динамичког кода, SQL-а доступног мрежом ни датотечног система.
- Меморијска безбедност. Rust, са
forbid(unsafe_code)у протоколним пакетима; свакиunsafeблок има коментар// SAFETY:који проверава lint, а на спољни унос нема панике: clippy у пакетима на податковном путу забрањујеunwrap,expect, индексирање иpanic!. - Ублажавање искоришћавања. W^X спроводи се кодирањем дескриптора и битом
SCTLR_EL1.WXNи ревидира при сваком подизању. Табеле страница након подизања су само за читање, без уписивог алијаса. Доња половина адресног простора је искључена. Сваки стек има заштитне странице, а изузеци раде на засебном стеку. PAC и BTI укључују се кад их процесор подржава, и то проверавају димни тестови. - Отпорност на злоупотребу. SYN колачићи; пријем одбија нове везе, пре икакве криптографије, кад је heap низак, кад база заостаје, кад је превише руковања у току по језгру или по изворној адреси или кад је брзина руковања превисока. Ограничења брзине по уређају, ограничени редови, TCP прозор везан уз противпритисак брокера, ограничено логовање, надзорни тајмер и здравствена провера.
- Непроменљивост. Конфигурација, CA и серверски кључ уграђени су у слику. Промена било чега значи нову слику.
Модел претњи записан је у docs/SECURITY.md.
Похрана без датотечног система
Подаци живе на засебном диску, virtio-blk или virtio-scsi, на којем нема датотечног система. Распоред је минималан и сопствен:
LBA 0 [ суперблок: магија · верзија распореда · uuid · табела региона · CRC-32C ]
LBA 8, 16 [ табела величина датотека, утори A/B (најновији важећи побеђује) ]
LBA 64 [ SQLite главна база ½ ][ SQLite WAL ⅛ ][ дневник 1 MiB ][ телеметријски дневник: 8 партиција … ]
задња 4 KiB [ простор за самопроверу блок-уређаја при подизању ]
SQLite (вендорисани 3.54.1, са провером сажетка при изградњи) преведен је у језгро clangом, без стандардне библиотеке, и користи фиксни memsys5 heap. Његов VFS пресликава три фиксне датотеке на регионе диска. Ради у WAL режиму са locking_mode=EXCLUSIVE, па му не треба индекс у дељеној меморији, и са synchronous=FULL; ако quick_check при подизању нађе грешку, систем се безбедно зауставља. У бази су регистар уређаја, опозиви, задржане поруке и трајне сесије са редовима QoS 1 порука за уређаје који нису повезани.
Језгро за базу, оно са највишим бројем, покреће SQLite и ништа друго. Податковна раван шаље му промене стања преко SPSC прстена без брава; оно их скупља у једну трансакцију сваких 5 ms или сваких 512 операција и потврде враћа другим прстеном. Празан диск форматира се при првом подизању; све друго се никад не форматира преко.
Телеметрија иде другим путем. Сваки прихваћени PUBLISH на темама из storage.telemetry_topics одлази у партицију телеметријског дневника свог језгра: прстен сегмената са CRC-ом по запису, посољеним секвенцом сегмента, тако да се стари пролази прстена никад не појаве поново. Језгра предају записе бази сопственим прстеном, без копирања, а она их исписује сваких 5 ms или сваких 256 KiB. При подизању опоравак задржава сваки исписани запис, одсеца недовршени реп и наставља у новом сегменту. Задржавање је по величини, кроз прстен, и по старости, кроз retention_days. Проверили смо то са милион записа и са kill -9 усред писања.
Бројке
Алат за оптерећење у tools/loadgen ради три ствари: олују TLS 1.3 руковања са mTLS-ом и CONNECT-ом, дуготрајне везе са PINGREQ-ом које сваке секунде објављују телеметрију, и олује поновног повезивања. make bench на Apple M4 Pro под HVF-ом даје:
| Радна језгра | руковања/s у госту | скалирање | кроз QEMU корисничку мрежу (веза/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
Бројка у госту, где су клијент и сервер унутар исте виртуелне машине и свако радно језгро ради само за себе, показује да податковна раван скалира линеарно. Мрежна бројка ограничена је једнонитним корисничким умрежавањем QEMU-а, не гостом; и то је записано као ADR-032.
Олује поновног повезивања тестирамо тако да се сви уређаји повежу одједном, понављају одбијене покушаје са одгодом и онда сви падну без DISCONNECT-а. Параметар heap_limit_mb= у развојном режиму натера пријем да heap сматра мањим него што јесте, па се олуја већа од heapа може испробати са мало сокета. У једном таквом кругу од 3 000 уређаја повезало се 1 368 за 36 секунди, а 5 619 покушаја било је уредно одбијено, уз ограничење од 8 MiB; меморија се након олује вратила.
Како радимо
Пројекат води писана спецификација, PROMPT.md, са планом прекретница, и свака прекретница завршава истим ритуалом: make check и make smoke зелени, један commit са ознаком M<n>, запис у docs/PROGRESS.md и ADR за сваку одлуку која није очигледна. Правила су кратка и не преговарамо о њима: нема панике на спољни унос, unsafe само у хардверским и FFI пакетима и увек са коментаром, нема алокација на врућем путу, коментар уз сваки редослед атомских операција, свако ограничење долази из конфигурације, а намерни привремени делови падају гласно.
CI ради више него што бисмо ручно стигли: fmt, clippy за host и за aarch64-unknown-none, typos, actionlint и shellcheck; тестове на Linuxу и macOS-у на arm64; изградњу језгра за развојну и производну конфигурацију са величином ELF-а у сажетку; подизање под QEMU-ом са свим сценаријима димног теста и чување серијских записа; cargo-deny за савете, лиценце и изворе; минут фазинга по свакој од једанаест мета, а ноћу десет минута по мети са кешираним корпусом. Уз то CodeQL, OpenSSF Scorecard, Dependabot и објава са SLSA пореклом. Свака туђа акција закачена је на пуни commit SHA.
Репродуцибилност схватамо дословно: make repro изгради HEAD двапут у свежим радним стаблима и упореди ELF, ISO и сирову слику бајт по бајт. Објаве су потписане minisignом, а артефакти се могу проверити са gh attestation verify. Производна слика садржи приватни кључ сервера, па је сам ISO тајна; слике уз објаве на GitHubу развојне су, са привременим кључевима, и служе само за испробавање.
Шта смо научили
Неколико лекција које су промениле план:
- Сопствени TCP ипак се исплатио. Прототип је радио на smoltcp-у и добро нам је послужио до M13. Али сервер који држи стотине хиљада веза не треба клијентски TCP, треба SYN колачиће, точак тајмера и стање од 96 бајтова. У M14 смо smoltcp заменили сопственим стеком само за серверску страну, са тестовима симулираног губитка и фазингом сегмената.
- Мерите меморију изнутра. Процене по вези биле су бескорисне док језгро није само почело да броји heap у блоковима алокатора, а не у траженим бајтовима. Тек тада смо видели где 512 бајтова стварно одлази и спустили се на 494.
- Алат за тестирање је део производа. Највеће ограничење није било језгро, него QEMU-ово корисничко умрежавање и број ефемерних портова на развојном рачунару. За сто хиљада и више веза треба
vmnet-sharedна macOS-у или Linux са KVM-ом, tap интерфејсом и више клијентских адреса. - SQLite без оперативног система је могућ, али само уз строге услове: фиксни heap, ексклузивно закључавање без дељене меморије, VFS који не зна за датотеке, и пријем који бази не дозвољава да заостане.
- Олује поновног повезивања су нормално стање, не изузетак. Кад падне мобилна мрежа и врати се, хиљаде уређаја куца истовремено. Зато пријем одбија везу пре икакве криптографије, а свака одбијена веза кошта готово ништа.
Шта следи
Рад после M17 планиран је у три смера, а редослед међу њима записан је у docs/PLAN.md. Ништа од овога још није изграђено.
- Један ISO за више платформи. Иста датотека
urmqtt-<env>-<verzija>.isoи једна сирова GPT слика које се подижу на aarch64 UEFI-ју, x86_64 UEFI-ју и x86_64 BIOS-у, у виртуелним машинама и на правом хардверу, почев од једног Intel PC-а. И даље уређај са свиме унутра, репродуцибилан и потписан. - Сирови диск, без датотечног система. Спецификација формата на нивоу бајта, host алат
urmqtt-diskкоји дели парсере са језгром, поравнање региона на 1 MiB, раст диска са подацима, discard за танко додељене дискове, A/B снимци базе, провера битова и, по избору, шифровање AES-256-XTS са хардверским AES-ом. - Доказиво безбедно. Опозив и листа дозвољених уређаја за време рада преко потписаног контролног канала, без нове слике; диференцијално и конформацијско тестирање сопственог TLS-а и TCP-а; KASLR, заштита стека, kCFI, MTE и ојачани алокатор; SQLite изолован на нивоу EL0; Secure Boot; SBOM и cargo-vet; и на крају пакет за спољну ревизију.
Платформе које нуде само не-virtio уређаје, попут AWS ENA-е или Azure MANA-е, засад нису подржане. Raspberry Pi 4 и 5 као KVM домаћин, Hetzner CAX и Oracle Ampere A1 документовани су и подржани кодом, али још нису испробани на правој услузи.
Испробајте
Репозиторијум је на github.com/zeljan-alduk/urMQTT. Уз Rust са прикаченим nightly ланцем алата и QEMU, make certs, make run и један mosquitto клијент довољни су за први PUBLISH преко mTLS-а на сопственом рачунару. Упозорење са почетка вреди и на крају: ово је пред-алфа, и све што ради пише у docs/PROGRESS.md. Пријаве грешака, питања о дизајну и безбедносне пријаве по упутствима у SECURITY.md су добродошли. О свакој следећој прекретници јавићемо се овде.
Коментари
Правила коментарисања