<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Blog ALDO TECH</title>
    <link>https://aldo.tech/pl/blog/</link>
    <atom:link href="https://aldo.tech/pl/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Notatki z terenu: transport publiczny, pobieranie opłat, sprzęt i oprogramowanie, które muszą działać w drodze.</description>
    <language>pl</language>
    <item>
      <title>urMQTT po VPS-ie: SQLite w trybie użytkownika, porównanie z Mosquitto, konfiguracja jako dane i konstruktor w przeglądarce</title>
      <link>https://aldo.tech/pl/blog/urmqtt-config-builder-plaintext-mosquitto/</link>
      <guid isPermaLink="true">https://aldo.tech/pl/blog/urmqtt-config-builder-plaintext-mosquitto/</guid>
      <pubDate>Sun, 01 Nov 2026 08:00:00 +0000</pubDate>
      <description>Co wydarzyło się po drugim tekście o urMQTT: SQLite wydzielony poza jądro, pomiary względem Mosquitto na tym samym VPS-ie (także to, co nie wypadło na naszą korzyść), znalezione przyczyny wolnego pojedynczego strumienia, konfiguracja jako dane wraz z webowym konstruktorem ISO i emulatorem w przeglądarce, opcjonalny zwykły MQTT oraz to, co nadal nie jest udowodnione.</description>
      <content:encoded><![CDATA[<p>Drugi tekst, <a href="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/">urMQTT na prawdziwym VPS-ie</a>, zakończył się listą „Co dalej": H6 (SQLite poza jądrem), H7 (bezpieczeństwo czasu), X11 (porównanie z Mosquitto na tym samym VPS-ie) i pierwsze wydanie. Ten tekst idzie za tą listą w tej samej kolejności, a potem dodaje dwie rzeczy, których na liście nie było, bo pojawiły się po drodze: konfigurację jako dane, z webowym konstruktorem ISO, oraz opcjonalny zwykły MQTT bez TLS. Tak jak wcześniej: co planowaliśmy, co zbudowaliśmy, co nas zaskoczyło i co pozostaje otwarte. W tym tekście jest też kilka wyników, które nie przemawiają na korzyść urMQTT; zostawiliśmy je, bo są częścią prawdy.</p>
<p>Zastrzeżenie na wstępie pozostaje to samo: projekt jest w fazie pre-alfa, pierwsze wydanie jest tylko dla VPS-ów z KVM, a liczby mierzą kod, testy i określone eksperymenty, a nie dojrzałość produkcyjną.</p>
<h2 id="jak-to-wygladao-w-czasie">Jak to wyglądało w czasie</h2>
<p>Od poprzedniego tekstu (31.10. o 23:00) do dziś (1.11. o 20:41) w repozytorium przybyło 59 commitów, łącznie jest ich 178. Wszystkie powstały w jednym dniu kalendarzowym, więc obowiązuje to samo ostrzeżenie co wcześniej: projekt powstaje z Claude Code (commity mają <code>Co-Authored-By: Claude</code>), plan, kod, testy i ADR-y powstają w krótkich, zamkniętych pętlach, a liczby poniżej mówią o ilości kodu i weryfikacji, a nie o tym, ile w nich pracy człowieka.</p>
<figure><img loading="lazy" alt="Wzrost od poprzedniego tekstu: linie Rusta od 89 790 do 112 408, funkcje #[test] od 739 do 926, ADR-y od 104 do 126 i commity od 119 do 178. Każdy wiersz ma własną skalę." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/growth-since.svg" /><figcaption>Wzrost od poprzedniego tekstu: linie Rusta od 89 790 do 112 408, funkcje #[test] od 739 do 926, ADR-y od 104 do 126 i commity od 119 do 178. Każdy wiersz ma własną skalę.</figcaption></figure>
<p>Liczby wyliczono tą samą metodą co w poprzednim tekście: wszystkie pliki <code>.rs</code> poza <code>vendor/</code> i <code>fuzz/</code>, funkcje <code>#[test]</code>, nagłówki <code>## ADR-</code> w <code>docs/DECISIONS.md</code>.</p>
<table>
<thead>
<tr>
<th>Moment</th>
<th>Commit</th>
<th>Czas</th>
<th style="text-align: right;">Linii Rusta</th>
<th style="text-align: right;">Testów</th>
<th style="text-align: right;">ADR-ów</th>
</tr>
</thead>
<tbody>
<tr>
<td>poprzedni tekst</td>
<td>794f607</td>
<td>31.10. 23:00</td>
<td style="text-align: right;">89 790</td>
<td style="text-align: right;">739</td>
<td style="text-align: right;">104</td>
</tr>
<tr>
<td>H6 gotowe</td>
<td>a0423cc</td>
<td>1.11. 03:59</td>
<td style="text-align: right;">95 078</td>
<td style="text-align: right;">754</td>
<td style="text-align: right;">107</td>
</tr>
<tr>
<td>X11</td>
<td>a29f49b</td>
<td>1.11. 07:56</td>
<td style="text-align: right;">95 579</td>
<td style="text-align: right;">754</td>
<td style="text-align: right;">108</td>
</tr>
<tr>
<td>webowy konstruktor i lustro wydań</td>
<td>582783f</td>
<td>1.11. 16:49</td>
<td style="text-align: right;">105 645</td>
<td style="text-align: right;">848</td>
<td style="text-align: right;">122</td>
</tr>
<tr>
<td>zwykły MQTT, dokumentacja</td>
<td>8f959f1</td>
<td>1.11. 19:05</td>
<td style="text-align: right;">112 408</td>
<td style="text-align: right;">926</td>
<td style="text-align: right;">125</td>
</tr>
<tr>
<td>dziś</td>
<td>d21e3ec</td>
<td>1.11. 20:41</td>
<td style="text-align: right;">112 408</td>
<td style="text-align: right;">926</td>
<td style="text-align: right;">126</td>
</tr>
</tbody>
</table>
<p>Pracowano równolegle na wielu gałęziach, więc kolejność commitów nie jest ściśle kolejnością pracy. ADR-y są numerowane do 232, ale numeracja ma luki (część została przemianowana przy scalaniu gałęzi); liczymy nagłówki, a nie największy numer. Wydania: v0.2.0 (1.11., 18:45 UTC) i v0.2.1 (1.11., 20:22 UTC), oba oznaczone jako pre-release.</p>
<h2 id="h6-sqlite-w-trybie-uzytkownika">H6: SQLite w trybie użytkownika</h2>
<p><strong>Co planowaliśmy.</strong> SQLite to 254 268 linii C, które parsują bazę, WAL i dziennik z dysku, a dysk może być spreparowany lub zmieniony. Dotąd działał w jądrze, z uprawnieniami jądra. <strong>Co zbudowaliśmy</strong> (ADR-210 do ADR-212): SQLite, jego części libc i sterta zostały przeniesione do osobnego programu, <code>dbworker</code>, który działa na EL0 (aarch64), czyli ring 3 (x86_64), we własnej przestrzeni adresowej. Jądro już nie linkuje SQLite. Worker rozmawia z jądrem przez wspólne okno pamięci i dwanaście wywołań systemowych, a każda zwrócona długość i każde przesunięcie jest sprawdzane przed użyciem. Błąd w workerze (niedozwolony dostęp, niezdefiniowana instrukcja, przekroczony budżet czasu 30 s) kończy wywołanie, worker jest uruchamiany ponownie, a baza otwiera się z WAL tak jak przy starcie; po 5 restartach w ciągu minuty magazyn jest uznawany za uszkodzony, a nie restartowany w nieskończoność.</p>
<p>Koszt, zmierzony na aarch64 pod HVF, ta sama VM, jądro z wbudowanym SQLite wobec jądra z workerem, po sześć naprzemiennych uruchomień każdego:</p>
<table>
<thead>
<tr>
<th>Pomiar</th>
<th>Przed (w jądrze)</th>
<th>Po (worker)</th>
<th>Różnica</th>
</tr>
</thead>
<tbody>
<tr>
<td>wejście do trybu użytkownika i wyjście z niego</td>
<td>-</td>
<td>1,1 us na obieg</td>
<td></td>
</tr>
<tr>
<td><code>db-bench</code>, 5 000 wstawień w 50 transakcjach, najlepszy / mediana</td>
<td>61,9k / 58,4k op/s</td>
<td>54,7k / 53,3k op/s</td>
<td>-11,6 % / -8,7 %</td>
</tr>
<tr>
<td>jedno wstawienie na transakcję, najlepszy / mediana</td>
<td>2 319 / 2 148 op/s</td>
<td>2 307 / 2 126 op/s</td>
<td>-0,5 % / -1 %</td>
</tr>
<tr>
<td>cała kopia zapasowa jednym wywołaniem (23 MiB)</td>
<td>514 ms</td>
<td>635 ms</td>
<td>+23 %</td>
</tr>
</tbody>
</table>
<p>Zapisy na dysk (1 572) i flushe (319) są identyczne, jak być powinno. To nie jest odizolowane: <strong>jądro nadal ufa temu, co zwróci worker</strong>, po sprawdzeniu kształtu. Skompromitowany worker może kłamać (na przykład zwrócić pustą listę unieważnionych certyfikatów, a tym samym je „odwołać z unieważnienia"), ale nie może czytać pamięci jądra, kluczy TLS ani kluczy ticketów. Nie ma KPTI, więc wycieki typu Spectre/Meltdown nie są zablokowane i niczego o nich nie twierdzimy; klucz PAC dzielą EL0 i EL1. Na x86_64 kosztu przełączania nie zmierzyliśmy na KVM (pod TCG liczba byłaby bez sensu), a samo fuzzowanie workera i protokołu nie jest skończone. Zestaw testów magazynu (79 scenariuszy, każdy obraca SQLite w workerze) przechodzi na aarch64 HVF i x86_64 TCG, a na aarch64 TCG 76 (jeden jest pomijany, bo TCG nie ma MSI-X).</p>
<p><strong>H7 jest zamknięty, ale nie tak, jak zapowiadaliśmy.</strong> Dolna granica czasu (ADR-050) i SNTP z konsensusem (T1) pokrywają zakres H7; NTS (RFC 8915) pozostaje poza zakresem i jest zapisany jako ryzyko szczątkowe, a nie jako coś, co działa. Nie uważamy, że bezpieczeństwo czasu jest „rozwiązane", tylko że jego dowodliwa część jest gotowa.</p>
<p><strong>Nowa opcja sieciowa.</strong> Contabo nie daje DHCP dla własnego ISO, więc VPS dotąd wymagał innego obrazu niż laboratoryjna VM. Teraz <code>network.mode = "auto"</code> (ADR-214) najpierw pyta DHCP, a jeśli dzierżawy nie ma w zadanym czasie (<code>dhcp_timeout_s</code>, domyślnie 15 s) lub ją straci, stosuje podany adres statyczny. Wypróbowane ręcznie na QEMU (z DHCP używa dzierżawy, bez sieci po 5 s wraca do adresu statycznego); smoke scenariusza na to jeszcze nie ma.</p>
<h2 id="x11-porownanie-z-mosquitto-na-tym-samym-vps-ie">X11: porównanie z Mosquitto na tym samym VPS-ie</h2>
<p>Ten sam rodzaj maszyny co w poprzednim tekście (Contabo, 4 vCPU AMD EPYC, 8 GiB), obciążenie z hosta Linux oddalonego o 15 ms, wzajemny TLS 1.3 z tymi samymi certyfikatami po obu stronach, Mosquitto 2.0.21 na Debianie 13 bez trwałości. Metoda i decyzje są w ADR-213, surowe dane w <code>docs/perf-results/</code>. Mediana z trzech uruchomień.</p>
<figure><img loading="lazy" alt="Pełne uzgodnienia na sekundę (TLS 1.3 + certyfikat klienta + CONNECT, bez wznowienia sesji) przy 32, 64 i 128 klientach: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. To są dolne granice: generator obciążenia był blisko własnego limitu." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/mosquitto.svg" /><figcaption>Pełne uzgodnienia na sekundę (TLS 1.3 + certyfikat klienta + CONNECT, bez wznowienia sesji) przy 32, 64 i 128 klientach: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. To są dolne granice: generator obciążenia był blisko własnego limitu.</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>urMQTT</th>
<th>Mosquitto 2.0.21</th>
</tr>
</thead>
<tbody>
<tr>
<td>uzgodnienia/s, 32 / 64 / 128 klientów (profil bench)</td>
<td>169 / 278 / 413</td>
<td>82 / 140 / 184</td>
</tr>
<tr>
<td>to samo, z limitami takimi, jakie są wydawane</td>
<td>185 / 262 / 333 (przy 128: 202 do 286 błędów, resety uzgodnień)</td>
<td>-</td>
</tr>
<tr>
<td>pamięć na bezczynne połączenie (20 000 połączeń)</td>
<td>0,50 KB sterty jądra (0,66 KB przy 5 000)</td>
<td>26,8 KB wzrostu procesu</td>
</tr>
<tr>
<td>opóźnienie p50, QoS 0 / QoS 1, 800 wiadomości/s, 64 B</td>
<td>14,0 / 14,8 ms</td>
<td>21,4 / 39,5 ms</td>
</tr>
<tr>
<td>QoS 1, 4 800 wiadomości/s, dostarczone/s (p50)</td>
<td>4 209 (77 ms)</td>
<td>4 235 (197 ms)</td>
</tr>
<tr>
<td>100 subskrybentów, 50 wiadomości/s: p50</td>
<td>16,6 ms</td>
<td>99 ms</td>
</tr>
<tr>
<td>pojedynczy strumień, QoS 0, 64 B, opublikowane/s</td>
<td>2 962 (0 do 7 733)</td>
<td>22 749</td>
</tr>
</tbody>
</table>
<p>Co to znaczy, a czego nie. Uzgodnienia są <strong>dolnymi granicami dla obu serwerów</strong>: generator obciążenia to współdzielona VM z 60 do 70 % skradzionego czasu procesora, a przy 128 wątkach był blisko własnego limitu, podczas gdy rdzenie robocze urMQTT były zajęte tylko w 12 do 13 %. Stosunek przy 128 klientach (około 2,2 razy) jest najbardziej sensowny; przy 32 i 64 w większości mierzy oczekiwanie klientów. Liczby pamięci nie liczą tego samego (sterta jądra wobec RSS procesu), więc „około 50 razy mniej" to rząd wielkości, a nie dokładny stosunek. Opóźnienie 14 ms to jeden obieg sieci (około 15 ms), a więc „brak mierzalnego czasu brokera"; ogony (p99 około 100 do 200 ms) są u obu brokerów zdominowane przez współdzielone hosty. Różnic poniżej około 20 % nie twierdzimy.</p>
<p><strong>Co nie wypadło na naszą korzyść</strong> i zostało w tabelach: pojedynczy strumień TCP przez urMQTT niósł 2 do 8 razy mniej niż przez Mosquitto, ze zmianami od zera do 7,7 tysiąca wiadomości na sekundę między uruchomieniami, a 20-milisekundowy watchdog handlera <strong>przerywał żywe połączenia</strong>, gdy hipernadzorca zatrzymał vCPU (w konfiguracji produkcyjnej <code>action = "drop"</code>, 6 do 8 połączeń na uruchomienie przy zalewaniu QoS 0). Obie sprawy są opisane w następnej sekcji, bo miały przyczyny w naszym kodzie.</p>
<h2 id="dlaczego-pojedynczy-strumien-by-wolny-przyczyny-a-nie-wymowki">Dlaczego pojedynczy strumień był wolny: przyczyny, a nie wymówki</h2>
<p>Najłatwiej byłoby powiedzieć „współdzielony VPS". Nie powiedzieliśmy tego, tylko na tym samym VPS-ie i tą samą metodą szukaliśmy przyczyn. Znaleziono pięć, cztery w ścieżce danych i jedną w watchdogu (ADR-221 do ADR-224):</p>
<table>
<thead>
<tr>
<th>Objaw w X11</th>
<th>Przyczyna w urMQTT</th>
<th>Zmiana</th>
</tr>
</thead>
<tbody>
<tr>
<td>pojedynczy strumień 1,9k wiadomości/s, często <strong>0 dostarczonych</strong></td>
<td><strong>Zgubione wybudzenie:</strong> pętla wyjścia zwracała listę gotowych połączeń na wierzch właśnie wypisanej; subskrybent, którego bufor wyjściowy raz się zapełnił, nigdy więcej nie był obsługiwany, dopóki nie obudził go PINGRESP</td>
<td>naprawione (ADR-223)</td>
</tr>
<tr>
<td>kilka procent strat zamienia 48k w 14k wiadomości/s</td>
<td><strong>każdy segment po zgubionym był odrzucany</strong> (odbiór tylko po kolei), więc odzyskanie kosztowało obieg sieci na segment</td>
<td>kolejka segmentów poza kolejnością, SACK (ADR-222)</td>
</tr>
<tr>
<td>sufit 4,4 MB/s przy 15 ms</td>
<td>16-bitowe okno, kolejka wysyłania 64 KiB i wolny start liczący ACK-i, a nie bajty</td>
<td>skalowanie okna, znaczniki czasu, liczenie bajtów (ADR-222)</td>
</tr>
<tr>
<td>każdy obieg strumienia, który się mieści, czeka 0 do 10 ms</td>
<td><strong>dyspozytor spał do następnego taktu 10 ms</strong> (virtio-net nie ma przerwania odbioru)</td>
<td>odpytywanie NIC co 200 us, dopóki istnieją połączenia (ADR-224)</td>
</tr>
<tr>
<td>1 do 8 przerwanych połączeń na uruchomienie</td>
<td><strong>watchdog naliczał handlerowi upłynięty czas</strong>, a VPS zatrzymuje vCPU często i na długo</td>
<td>budżetem jest czas, który jądro faktycznie pracowało (ADR-221)</td>
</tr>
</tbody>
</table>
<p>Zatrzymania są prawdziwe: na tym VPS-ie rdzenie 0 do 2 miały medianę 36 do 80 spóźnionych taktów na 5 sekund, a najgorsze zatrzymanie w oknie miało medianę około 70 ms i do 0,9 s (rdzeń 3, baza i dziennik: do 1,55 s), podczas gdy host nie zgłasza skradzionego czasu (<code>host steal 0 us</code>). Dlatego watchdog potrzebował drugiego zegara, a nie większego budżetu.</p>
<figure><img loading="lazy" alt="Przed i po, na tym samym VPS-ie tą samą metodą, mediana z trzech do sześciu uruchomień: pojedynczy strumień QoS 0, 64 B: opublikowane 1,9k, 23,7k (Mosquitto 29,3k), dostarczone 0 (mediana), 12,6k (29,2k). 16 par, zalewanie, 64 B: opublikowane 24,7k, 43,2k (31,4k), dostarczone 20,0k, 37,4k (30,7k)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/before-after.svg" /><figcaption>Przed i po, na tym samym VPS-ie tą samą metodą, mediana z trzech do sześciu uruchomień: pojedynczy strumień QoS 0, 64 B: opublikowane 1,9k, 23,7k (Mosquitto 29,3k), dostarczone 0 (mediana), 12,6k (29,2k). 16 par, zalewanie, 64 B: opublikowane 24,7k, 43,2k (31,4k), dostarczone 20,0k, 37,4k (30,7k).</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>kod z X11 (7f9517e)</th>
<th>teraz (86a71ff)</th>
<th>Mosquitto</th>
</tr>
</thead>
<tbody>
<tr>
<td>pojedynczy strumień, QoS 0, 64 B, opublikowane / dostarczone na s</td>
<td>1,9k / 0</td>
<td>23,7k / 12,6k</td>
<td>29,3k / 29,2k</td>
</tr>
<tr>
<td>pojedynczy strumień, QoS 0, 1 KiB</td>
<td>0 / 0</td>
<td>3,9k / 2,3k</td>
<td>5,4k / 5,4k</td>
</tr>
<tr>
<td>16 par, zalewanie, QoS 0, 64 B</td>
<td>24,7k / 20,0k</td>
<td>43,2k / 37,4k</td>
<td>31,4k / 30,7k</td>
</tr>
<tr>
<td>16 par, zalewanie, QoS 0, 1 KiB</td>
<td>3,7k / 2,9k</td>
<td>5,0k / 4,5k</td>
<td>5,9k / 5,7k</td>
</tr>
<tr>
<td>zgubione połączenia na uruchomienie (16 par, 64 B)</td>
<td>13 (7 do 16)</td>
<td>0 (0 do 0)</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Watchdog: 148 przerwanych połączeń w 30 obciążonych uruchomieniach przed, 0 w 24 uruchomieniach po; na lokalnym urządzeniu, które opóźnia i gubi ramki, przy stracie 0,1 % pojedynczy strumień 64 B z 14,3k wiadomości/s (jądro z X11) ma teraz 41,0k, a przy 0,5 % z 5,0k na 17,7k.</p>
<p><strong>Czego to nie mówi.</strong> urMQTT <strong>nadal nie jest szybszy od Mosquitto na pojedynczym strumieniu</strong>: przy 64 B publikuje około 80 % jego szybkości, a dostarcza około 40 %, bo odrzuca QoS 0, gdy strumień subskrybenta jest wolniejszy od strumienia wydawcy (Mosquitto bez limitu kolejkuje). Przy 1 KiB w 16-parowym zalewaniu Mosquitto jest z przodu o około 15 %, co mieści się w szumie. Generator znów był współdzieloną VM z 58 do 77 % skradzionego czasu. Druga runda (blokowanie sterty poza ścieżką danych, SACK po stronie wysyłania i inne) jest na niezintegrowanej gałęzi <code>perf-single-flow-wip</code>; jej liczby nie są jeszcze twierdzeniami i tu ich nie podajemy.</p>
<h2 id="obietnica-ktorej-nie-skadalismy-a-ktora-spenilismy-konfiguracja-jest-danymi">Obietnica, której nie składaliśmy, a którą spełniliśmy: konfiguracja jest danymi</h2>
<p>Dotąd każda konfiguracja była nowym buildem: klucze i limity były wbudowane w obraz. C1 to odwraca (ADR-215 do ADR-219): jądro buduje się raz, a konfiguracja jest danymi.</p>
<ul>
<li><strong>Blob <code>urmqtt-cfg</code></strong> (C1a): wersjonowany, kanoniczny zapis (pola w rosnącej kolejności znaczników, odcisk SHA-256, opcjonalny oddzielny podpis Ed25519), 70 pól z wartościami domyślnymi i granicami, dekoder odrzucający wszystko, co niepoprawne.</li>
<li><strong>Jądro czyta go jako moduł Limine</strong> (C1b), przed czymkolwiek innym, na aarch64 i x86_64. Istnieje <strong>jedno wydawane jądro dla każdego <code>ENV</code> oprócz <code>dev</code></strong> (ELF-y aarch64 dla <code>release</code>, <code>prod</code> i <code>bench</code> mają ten sam SHA-256; sprawdzone tylko na aarch64). Wydawane jądro odrzuca konfigurację deweloperską, a bez konfiguracji pisze „refusing to serve" i niczego nie obsługuje.</li>
<li><strong>Zainstalowany dysk niesie konfigurację</strong> i aktualizacja ją zachowuje (C1c).</li>
<li><strong><code>urmqtt-config</code> i szablon ISO</strong> (C1d): <code>pack</code>, <code>show</code>, <code>verify</code>, a <code>pack --iso</code> zastępuje obie kopie miejsca na konfigurację i 24 skróty modułów w gotowym ISO. Wynik jest bajt w bajt taki sam jak ISO budowane przez <code>mkiso.sh --config</code>. Szablon wydania v0.2.0 ma 63 111 168 bajtów.</li>
<li><strong>Oświadczenie o zaufaniu</strong> (ADR-219): skonfigurowany obraz przy starcie wypisuje odcisk konfiguracji (<code>config: fingerprint ...</code>), a podpis jest opcjonalny i <strong>domyślnie wyłączony</strong>. Zmienione ISO nie ma oficjalnego skrótu; odcisk to sposób, by zobaczyć, co jest w środku.</li>
</ul>
<h2 id="konstruktor-na-aldotech">Konstruktor na aldo.tech</h2>
<p>To samo, co robi <code>urmqtt-config pack --iso</code>, robi strona internetowa, tym samym kodem: biblioteka narzędzi skompilowana do WebAssembly (1,06 MB, bez importów i bez bloku <code>unsafe</code>). Strona jest pod adresem <a href="https://aldo.tech/pl/produkty/urmqtt-builder/">aldo.tech/pl/produkty/urmqtt-builder/</a> i istnieje we wszystkich 15 językach serwisu (na przykład <a href="https://aldo.tech/proizvodi/urmqtt-builder/">hr</a>).</p>
<ul>
<li><strong>Nic nie opuszcza przeglądarki.</strong> Certyfikaty, klucze prywatne i gotowe ISO zostają na komputerze; strona pobiera tylko pliki wydań z tego serwisu, nie ma śledzenia ani skryptów osób trzecich. W teście na żywo żadne żądanie nie niosło materiału kluczowego.</li>
<li><strong>Lustro wydań</strong> (ADR-227): co godzinę (timer systemd, niski priorytet) kopiuje wydania z GitHuba, każde sprawdza przed publikacją, a strona w przeglądarce weryfikuje podpis <code>SHA256SUMS</code> kluczem wbudowanym w stronę, i dopiero potem wpisuje konfigurację do szablonu. Nieudana weryfikacja blokuje pobranie.</li>
<li><strong>Prosto domyślnie, zaawansowanie w razie potrzeby</strong> (ADR-232): pierwszy ekran to trzy kroki (gdzie działa, twoje certyfikaty, nazwa obrazu). Jedno pole do przeciągania plików sortuje certyfikat, klucz i CA według zawartości. Widok zaawansowany otwiera każde z 70 pól, wyszukiwanie, import i eksport TOML; oba widoki edytują ten sam stan. Szablon (63 MB) pobiera się, gdy użytkownik wypełnia formularz. W próbie na żywo, z próbnym lustrem i próbnym kluczem, zbudowanie ISO w przeglądarce trwało 5 do 18 s.</li>
</ul>
<figure><img loading="lazy" alt="Prosty widok konstruktora: trzy kroki, „Gdzie będzie działać?&quot;, „Twoje certyfikaty&quot; i „Nadaj nazwę swojemu obrazowi&quot;, z jedną następną czynnością na pasku bocznym." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-simple.png" /><figcaption>Prosty widok konstruktora: trzy kroki, „Gdzie będzie działać?&quot;, „Twoje certyfikaty&quot; i „Nadaj nazwę swojemu obrazowi&quot;, z jedną następną czynnością na pasku bocznym.</figcaption></figure>
<figure><img loading="lazy" alt="Widok zaawansowany, zrobiony na próbnym lustrze z próbnym kluczem (data wydania i klucz podpisu nie są prawdziwe): wydanie z zweryfikowanym podpisem, szablony, każde pole konfiguracji." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-advanced.png" /><figcaption>Widok zaawansowany, zrobiony na próbnym lustrze z próbnym kluczem (data wydania i klucz podpisu nie są prawdziwe): wydanie z zweryfikowanym podpisem, szablony, każde pole konfiguracji.</figcaption></figure>
<p><strong>Emulator, opcjonalnie.</strong> Obok „Pobierz ISO" stoi „Wypróbuj w emulatorze": QEMU skompilowane do WebAssembly uruchamia właśnie zbudowany obraz w karcie przeglądarki. Nic z niego nie jest pobierane przed kliknięciem (około 3 MB za pierwszym razem). Emulator jest tylko BIOS, <strong>bez sieci</strong> (żeby ruch użytkownika nie przechodził przez jakiegoś pośrednika i nie naruszał „nic nie opuszcza przeglądarki"), wymaga około 1,5 GB pamięci i przeglądarki na komputerze (testowany Chrome; Firefox i Safari nie). urMQTT dochodzi w nim do <code>URMQTT READY</code> w około 8 s (z pominiętym odliczaniem) i wypisuje ten sam odcisk konfiguracji co strona, co jest dowodem, że uruchomiono właśnie tę konfigurację. Start jest około 2 razy wolniejszy niż w natywnym QEMU na tym samym Macu (0,82 s wobec 1,9 s), a kryptografia około 58 razy wolniejsza, bo instrukcje sprzętowe są emulowane. Dlatego <strong>z emulatora nie wyciągamy żadnych twierdzeń o szybkości</strong>, a strona mówi to przed uruchomieniem. Przy budowaniu trzeba było załatać cztery bajty w <code>.wasm</code> z powodu błędu w tłumaczeniu instrukcji POPCNT w źródłowym emulatorze (jeszcze go nie zgłosiliśmy); sam emulator jest na GPL, z ofertą kodu źródłowego.</p>
<figure><img loading="lazy" alt="Wpis diagnostyczny w emulatorze na żywo na aldo.tech: jądro wypisało ten sam odcisk konfiguracji co strona, a ekran pokazuje diagnostykę (w emulatorze kończy się po około 20 s; jej liczby opisują emulator, a nie urMQTT)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/emulator-diagnostic.png" /><figcaption>Wpis diagnostyczny w emulatorze na żywo na aldo.tech: jądro wypisało ten sam odcisk konfiguracji co strona, a ekran pokazuje diagnostykę (w emulatorze kończy się po około 20 s; jej liczby opisują emulator, a nie urMQTT).</figcaption></figure>
<h2 id="zwyky-mqtt-tylko-dla-sieci-ktorym-ufasz">Zwykły MQTT, tylko dla sieci, którym ufasz</h2>
<p>Zwykły MQTT (TCP 1883, bez TLS) jest w IoT bardzo częsty: jest domyślny w Mosquitto, w większości firmware'u dla Arduino i ESP, w Tasmocie, Zigbee2MQTT i Home Assistant, a używa się go też wewnątrz segmentowanych sieci przemysłowych, za VPN-em i w prywatnych sieciach LAN, a także na urządzeniach bez zegara (certyfikaty wymagają daty), bez dość flasha lub procesora na TLS. Przez niezaufaną sieć jest niedopuszczalny: kto jest po drodze, czyta i zmienia ruch. Dlatego P1 (ADR-229 do ADR-231) to wyraźny, ograniczony wybór, <strong>domyślnie wyłączony</strong>, a port TLS zostaje; TLS nie jest tym wyłączany, zwykły port pojawia się obok niego.</p>
<ul>
<li><strong>Lista dozwolonych sieci jest obowiązkowa</strong> (<code>allow_from</code>, CIDR-y) i sprawdzana przy SYN: poza listą nie ma SYN-ACK ani stanu. Cały internet (<code>0.0.0.0/0</code>) wymaga osobnej, wyraźnej opcji.</li>
<li><strong>Trzy tryby logowania:</strong> hasło (domyślne, gdy włączone; PBKDF2-HMAC-SHA-256, sól, 10 000 iteracji, porównanie w stałym czasie, blokada według adresu), anonimowo (jedna skonfigurowana tożsamość z własnym ACL) i według adresu (<code>ip</code>). Konstruktor haszuje hasło w przeglądarce i w pamięci trzyma tylko skrót.</li>
<li><strong>Mniej uprawnień niż mTLS:</strong> zwykły klient nigdy nie ma uprawnień serwisowych, operatorskich ani zarządzania, nie ma <code>$SYS</code> ani konsoli, ma własne sesje (<code>$plain/&lt;tożsamość&gt;/&lt;id klienta&gt;</code>, tylko czyste), a własne limity i rejestr dozwolonych nadal go obowiązują.</li>
<li><strong>Co się traci, jest w <code>SECURITY.md</code>:</strong> poufność, integralność, uwierzytelnienie brokera i powtórzenie nagranego logowania. Konfiguracją nie da się tego naprawić; tak jest, gdy nie ma TLS.</li>
</ul>
<p>Zmierzone na laptopie (Apple silicon, HVF, sieć użytkownika QEMU), 16 par, 64 B, trzy uruchomienia po 10 s: przy 8 000 wiadomości na sekundę ścieżka TLS i zwykła dostarczają wszystkie wiadomości z tym samym opóźnieniem (p50 0,17 ms wobec 0,13 do 0,18 ms), nowe połączenia na sekundę: TLS około 2 600 do 2 750, anonimowy zwykły około 4 900 do 7 800, a <strong>logowanie hasłem jest drogą częścią</strong>: jeden PBKDF2 kosztuje około 2,5 ms, więc liczba nowych logowań jest ograniczona do około 200 na rdzeń danych na sekundę (zmierzono 308 do 342 na dwóch rdzeniach). Zalewanie na tej maszynie nie jest stabilnym instrumentem (20k do 440k dostarczonych na sekundę między uruchomieniami) i <strong>nie wyciągamy z niego wniosku</strong>. Porównania zwykłej ścieżki z Mosquitto na VPS-ie jeszcze nie zrobiono. Testy: grupa smoke <code>plaintext</code> 7 z 7 na aarch64 HVF i na x86_64 TCG, nowy cel fuzz <code>plain_input</code> (2,3 miliona uruchomień bez znalezisk).</p>
<h2 id="bedy-znalezione-przez-prace-na-zywo">Błędy znalezione przez pracę na żywo</h2>
<p>Dwóch rzeczy nie wyłapały testy, ale pierwsze włączenie na prawdziwej stronie:</p>
<ul>
<li>Strona szukała plików wydań <strong>o jeden katalog za wysoko</strong>, więc każda weryfikacja podpisu odpowiadała 404 (poprawka 13af301). Test używał próbnego lustra pod inną ścieżką.</li>
<li><strong>Klawiatura emulatora nigdy nie dotarła do gościa</strong>: przyczyną nie było złe tłumaczenie klawiszy, tylko to, że okno dialogowe w ogóle nie nasłuchiwało klawiszy (poprawka w ADR-232, wraz z wpisem startowym bez klawiszy).</li>
</ul>
<h2 id="lekcje">Lekcje</h2>
<ul>
<li><strong>Szukaj przyczyny, nie obwiniaj otoczenia.</strong> Większość słabości pojedynczego strumienia i wszystkie przerwane połączenia były nasze; hipernadzorca jest prawdziwy, ale trzeba go było mierzyć, a nie zakładać.</li>
<li><strong>Porównanie z innym produktem musi obejmować także straty.</strong> Tabela, w której są tylko nasze wygrane, nie byłaby porównaniem.</li>
<li><strong>Instrument pomiarowy ma granice.</strong> Współdzielony generator ze 60 do 70 % skradzionego czasu zmienia to, co liczby znaczą; dlatego piszemy „dolna granica" i nie twierdzimy różnic poniżej 20 %.</li>
<li><strong>Izolacja nie jest dowodem.</strong> SQLite w EL0 zmniejsza to, co może zrobić błąd w nim, a nie to, co może skłamać.</li>
<li><strong>Prawdziwa instalacja znajduje to, o co testy nie pytają</strong>: źle złożoną ścieżkę i klawisze, których nikt nie nasłuchuje.</li>
</ul>
<h2 id="co-nadal-nie-jest-udowodnione">Co nadal nie jest udowodnione</h2>
<ul>
<li>Klucz podpisu wydań jest na razie kluczem próbnym; v0.2.0 i v0.2.1 to pre-release, a obrazy przy nich są deweloperskie z próbnymi certyfikatami (poza szablonem, który jest bez konfiguracji).</li>
<li>13 tłumaczeń strony konstruktora (i tego tekstu) napisała AI i sprawdziły je testy i próbki, a nie rodzimi użytkownicy języka.</li>
<li>Porównanie z Mosquitto to jedna sesja, jedna ścieżka i jeden współdzielony generator, na współdzielonym VPS-ie; różnice poniżej 20 % nie są twierdzeniami.</li>
<li>Zwykła ścieżka nie była mierzona na VPS-ie ani wobec Mosquitto.</li>
<li>Lighthouse na nowym prostym widoku nie jest potwierdzony (stary widok: dostępność 95 lub więcej).</li>
<li>Bare metal, koszt workera na x86_64 na KVM, Firefox i Safari dla emulatora: nie mierzono.</li>
</ul>
<h2 id="co-dalej">Co dalej</h2>
<ul>
<li><strong>D1: publiczny broker demo.</strong> Emulatora z przeglądarki nie możemy uczynić publicznym: żyje w karcie jednego odwiedzającego, jest 20 do 100 razy wolniejszy od KVM, nie ma sieci przychodzącej i znika po zamknięciu karty; przekierowanie internetu do cudzej przeglądarki byłoby też nadużyciem. Możliwy jest prawdziwy broker demo: urMQTT na VPS-ie pod nazwą na aldo.tech, z opublikowanym próbnym CA, ścisłym ACL i małymi limitami. To na razie tylko pomysł, a nie budowa, i wymaga wyraźnej zgody na publiczne wystawienie.</li>
<li><strong>Druga runda dla pojedynczego strumienia</strong> (gałąź <code>perf-single-flow-wip</code>): dopiero gdy zostanie zmierzona na VPS-ie i scalona.</li>
<li><strong>Zwykła ścieżka wobec Mosquitto na VPS-ie</strong>, logowanie i unieważnianie kont dla zwykłego MQTT przez podpisany kanał zarządzania.</li>
<li><strong>Po wydaniu:</strong> bare metal (X12 do X16), IOMMU i Secure Boot, szyfrowanie dysku.</li>
</ul>
<p>Kod jest na <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, a <code>docs/PROGRESS.md</code> nadal jest jedynym źródłem prawdy o tym, co naprawdę działa, <code>docs/PERFORMANCE.md</code> o tym, co zmierzono. Pre-alfa, tak jak wcześniej; zgłoszenia błędów i zgłoszenia dotyczące bezpieczeństwa według wskazówek w <code>SECURITY.md</code> są mile widziane.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT na prawdziwym VPS-ie: ISO, uzgadnianie w 0,24 ms, własny dysk i utwardzone jądro</title>
      <link>https://aldo.tech/pl/blog/urmqtt-vps-handshake-disk-hardening/</link>
      <guid isPermaLink="true">https://aldo.tech/pl/blog/urmqtt-vps-handshake-disk-hardening/</guid>
      <pubDate>Sat, 31 Oct 2026 08:00:00 +0000</pubDate>
      <description>Co stało się z trzema obietnicami z pierwszego tekstu o urMQTT (jedno ISO, surowy dysk bez systemu plików, bezpieczeństwo, które da się udowodnić) po pierwszych czterech dniach pracy, z pomiarami, tabelami i listą tego, co jeszcze nie jest gotowe.</description>
      <content:encoded><![CDATA[<p>W <a href="https://aldo.tech/blog/urmqtt-unikernel-mqtt-broker/">pierwszym tekście o urMQTT</a> opisaliśmy unikernel, który jest brokerem MQTT, do kamienia milowego M17, i zakończyliśmy trzema obietnicami pod nagłówkiem „Co dalej": jedno ISO uruchamiane na wielu platformach, surowy dysk bez systemu plików oraz bezpieczeństwo, które można udowodnić, a nie tylko zadeklarować. Nic z tego nie było wtedy zbudowane. Ten tekst śledzi, co stało się z każdą obietnicą, w tej samej kolejności co poprzednio: co planowaliśmy, co zbudowaliśmy, co nas zaskoczyło i co jest jeszcze otwarte. Przy okazji pokażemy, ile to trwało, bo to część tej historii.</p>
<p>Jedna uwaga na wstępie: projekt nadal jest w fazie pre-alfa. Liczby w tekście mierzą kod i testy, a nie dojrzałość produkcyjną, a pierwsze wydanie jest celowo ograniczone do VPS-ów z KVM. Bare metal zostaje na później.</p>
<h2 id="jak-to-wygladao-w-czasie">Jak to wyglądało w czasie</h2>
<p>Pierwszy commit powstał 28 października 2026 o 23:48 (M0). Do 31 października, godz. 23:00, w repozytorium jest 119 commitów, rozłożonych jako 3, 39, 38 i 39 na kolejne dni kalendarzowe. Od razu powiedzmy, jak to możliwe: projekt powstaje z Claude Code, a commity mają trailery <code>Co-Authored-By: Claude</code>. Cztery dni kalendarzowe historii nie znaczą, że jedna osoba napisała w cztery dni 90 000 linii; znaczą, że plan, implementacja, testy i ADR-y powstają w krótkich, zamkniętych pętlach, a każdy krok kończy się tym samym rytuałem weryfikacji co wcześniej. Liczby poniżej mówią o ilości kodu i testów, a nie o tym, ile z tego zostało sprawdzone w prawdziwej pracy.</p>
<figure><img loading="lazy" alt="Wzrost projektu w ciągu czterech dni z kamieniami milowymi od M0 do dziś: tysiące linii Rusta (od 1,6 do 89,8), liczba funkcji #[test] (od 27 do 739) i liczba ADR-ów (od 13 do 104), wraz ze słupkami commitów na dzień (39, 38 i 39 dla 29, 30 i 31 października)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/growth.svg" /><figcaption>Wzrost projektu w ciągu czterech dni z kamieniami milowymi od M0 do dziś: tysiące linii Rusta (od 1,6 do 89,8), liczba funkcji #[test] (od 27 do 739) i liczba ADR-ów (od 13 do 104), wraz ze słupkami commitów na dzień (39, 38 i 39 dla 29, 30 i 31 października).</figcaption></figure>
<p>Wykres pokazuje trzy krzywe, które rosną niemal razem, i to jest zamierzone: testy i zapisane decyzje nadążają za kodem, a nie zostają w tyle. Nachylenie jest największe 29 października, kiedy powstaje cała droga od jądra do brokera. Później linii przybywa wolniej, ale testy rosną szybciej niż kod (27 testów na 1,6 tys. linii na początku, 739 na 89,8 tys. dziś).</p>
<table>
<thead>
<tr>
<th>Kamień milowy</th>
<th>Commit</th>
<th>Czas</th>
<th style="text-align: right;">Linii Rusta</th>
<th style="text-align: right;">Testów</th>
<th style="text-align: right;">ADR-ów</th>
</tr>
</thead>
<tbody>
<tr>
<td>M0</td>
<td>9412a9c</td>
<td>28.10. 23:48</td>
<td style="text-align: right;">1 592</td>
<td style="text-align: right;">27</td>
<td style="text-align: right;">13</td>
</tr>
<tr>
<td>M5</td>
<td>a61083b</td>
<td>29.10. 01:32</td>
<td style="text-align: right;">9 314</td>
<td style="text-align: right;">88</td>
<td style="text-align: right;">24</td>
</tr>
<tr>
<td>M9</td>
<td>4be8cc9</td>
<td>29.10. 02:21</td>
<td style="text-align: right;">15 729</td>
<td style="text-align: right;">149</td>
<td style="text-align: right;">28</td>
</tr>
<tr>
<td>M11</td>
<td>4800904</td>
<td>29.10. 03:43</td>
<td style="text-align: right;">22 737</td>
<td style="text-align: right;">180</td>
<td style="text-align: right;">30</td>
</tr>
<tr>
<td>M13</td>
<td>a558d5e</td>
<td>29.10. 05:22</td>
<td style="text-align: right;">26 556</td>
<td style="text-align: right;">193</td>
<td style="text-align: right;">32</td>
</tr>
<tr>
<td>M17</td>
<td>7df4f98</td>
<td>29.10. 08:48</td>
<td style="text-align: right;">33 504</td>
<td style="text-align: right;">232</td>
<td style="text-align: right;">36</td>
</tr>
<tr>
<td>S1</td>
<td>ecf72ec</td>
<td>30.10. 00:46</td>
<td style="text-align: right;">41 910</td>
<td style="text-align: right;">299</td>
<td style="text-align: right;">45</td>
</tr>
<tr>
<td>X8</td>
<td>fb1504a</td>
<td>30.10. 19:08</td>
<td style="text-align: right;">56 341</td>
<td style="text-align: right;">456</td>
<td style="text-align: right;">59</td>
</tr>
<tr>
<td>PERF</td>
<td>76f7c36</td>
<td>31.10. 14:19</td>
<td style="text-align: right;">72 511</td>
<td style="text-align: right;">618</td>
<td style="text-align: right;">76</td>
</tr>
<tr>
<td>dziś</td>
<td>HEAD</td>
<td>31.10. 23:00</td>
<td style="text-align: right;">89 790</td>
<td style="text-align: right;">739</td>
<td style="text-align: right;">104</td>
</tr>
</tbody>
</table>
<p>Linie to wszystkie pliki <code>.rs</code> poza <code>vendor/</code> i <code>fuzz/</code>, a testy to funkcje <code>#[test]</code>. Co dodał każdy etap, według dat z historii gita:</p>
<table>
<thead>
<tr>
<th>Data</th>
<th>Kamienie milowe</th>
<th>Co dodano</th>
</tr>
</thead>
<tbody>
<tr>
<td>29.10.</td>
<td>M0 do M17</td>
<td>jądro, sieć, TLS, broker, SQLite, wiele rdzeni, własny TCP, utwardzanie, powtarzalne wydanie</td>
</tr>
<tr>
<td>29.10. wieczorem i 30.10.</td>
<td>X0 do X10, S1, H2, H3 (X0 zaczyna się 29.10. o 20:54)</td>
<td>port na x86_64, uniwersalne ISO, jeden dysk i samoinstalujące się ISO, kontrole TLS, klienci-atakujący</td>
</tr>
<tr>
<td>31.10.</td>
<td>O1 do O4, T1, PERF, H4, H5, H9, S2 do S6</td>
<td>wgląd i konsola przez MQTT, tryb live i diagnostyka, SNTP, przyspieszenia, utwardzanie, weryfikacja, łańcuch dostaw, przechowywanie</td>
</tr>
</tbody>
</table>
<h2 id="obietnica-1-jedno-iso-dla-wielu-platform">Obietnica 1: jedno ISO dla wielu platform</h2>
<p><strong>Co planowaliśmy.</strong> Jeden plik, który startuje na aarch64 UEFI, x86_64 UEFI i x86_64 BIOS. <strong>Co zbudowaliśmy.</strong> Port na x86_64 (X0 do X10) i uniwersalne ISO z bootloaderem Limine, jeden hybrydowy obraz, z którego startują wszystkie te kombinacje; smoke testy uruchamiają go na czterech maszynach wirtualnych. Menu ma trzy pozycje: urMQTT (tryb live, działa z RAM-u, nic nie zapisuje na dysku), instalacja na dysku maszyny, na której jest uruchomione (instalacja zapisuje GPT i dane, po czym wyłącza maszynę) oraz Diagnostyka (wykrywanie sprzętu i pomiary).</p>
<figure><img loading="lazy" alt="Menu startowe ISO zarejestrowane na VPS-ie: pozycje „urMQTT&quot;, „Install urMQTT on this machine's disk&quot; i „Diagnostic: hardware detection and benchmarks&quot; z odliczaniem do automatycznego startu. Zrzut pochodzi z testowego ISO z dłuższym odliczaniem; domyślnie jest to 10 sekund." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/boot-menu.png" /><figcaption>Menu startowe ISO zarejestrowane na VPS-ie: pozycje „urMQTT&quot;, „Install urMQTT on this machine's disk&quot; i „Diagnostic: hardware detection and benchmarks&quot; z odliczaniem do automatycznego startu. Zrzut pochodzi z testowego ISO z dłuższym odliczaniem; domyślnie jest to 10 sekund.</figcaption></figure>
<p>Pierwszym prawdziwym celem był VPS, a nie fizyczny komputer: decyzja brzmi, że pierwsze wydanie obsługuje tylko VPS (KVM), a bare metal przychodzi po nim. Testowaliśmy na VPS-ie Contabo z procesorem AMD EPYC (rodzina 23, Zen 1), 4 vCPU i 8 GiB RAM, z siecią virtio-net i dyskiem virtio-scsi. Pierwsza próba nas zaskoczyła: ISO podłączone jako wirtualny CD uruchamiało domyślną pozycję, obraz produkcyjny odmówił pracy z nośnika tylko do odczytu i się zresetował, więc dostaliśmy nieskończoną pętlę menu, jądro, reset, bez niczego czytelnego. Dziś błąd krytyczny w obrazie produkcyjnym zostawia komunikat na konsoli na 60 sekund przed resetem, a z nośnika optycznego wypisuje, że uruchomiono z nośnika tylko do odczytu, i się zatrzymuje. Tryb live w RAM-ie (O3) obszedł później ten problem w typowym przypadku.</p>
<p>Pozycja diagnostyczna wzięła się z tego doświadczenia: maszynę, której nie widzimy, musimy móc zapytać, co ma. Wypisuje CPU, RAM, sieć, dyski i urządzenia PCI, mierzy przepustowość pamięci, dysk i kryptografię, a te same pomiary wypisuje jako JSON na port szeregowy. Zapis na dysk jest dozwolony tylko w nieprzydzielonym obszarze i dopiero po wpisaniu <code>WIPE</code>; na zrzucie poniżej to pominięto.</p>
<figure><img loading="lazy" alt="Ekran diagnostyczny na VPS-ie Contabo (AMD EPYC, KVM, 4 vCPU): system, pamięć, dysk, urządzenia PCI, przepustowość i opóźnienie RAM-u oraz pomiary kryptograficzne, w tym 244 us na uzgadnianie mTLS, X25519 48 us, podpis ECDSA 35,7 us i weryfikacja 107 us." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/diag-vps.png" /><figcaption>Ekran diagnostyczny na VPS-ie Contabo (AMD EPYC, KVM, 4 vCPU): system, pamięć, dysk, urządzenia PCI, przepustowość i opóźnienie RAM-u oraz pomiary kryptograficzne, w tym 244 us na uzgadnianie mTLS, X25519 48 us, podpis ECDSA 35,7 us i weryfikacja 107 us.</figcaption></figure>
<p><strong>Co jest otwarte.</strong> Prawdziwy sprzęt. Po pierwszym wydaniu przyjdzie bare metal (start z USB, NVMe i AHCI, karta sieciowa prawdziwego komputera), razem z IOMMU i Secure Boot, bo bez IOMMU złośliwe lub wadliwe urządzenie może zapisywać w całej pamięci. W tej wersji niczego na ten temat nie twierdzimy.</p>
<h2 id="uzgadnianie-od-12-ms-do-024-ms">Uzgadnianie: od 1,2 ms do 0,24 ms</h2>
<p>Pierwszy pomiar diagnostyki na VPS-ie dał niewygodną liczbę: uzgadnianie mTLS, wyłącznie kryptografia po stronie serwera, trwało 1,2 ms. Na aarch64 używaliśmy już sprzętowych instrukcji AES i SHA, ale x86_64 nie miał nic podobnego dla kluczy publicznych: RustCrypto jest przenośne i constant-time, a przez to także wolne. Zmieniły to trzy kroki.</p>
<ul>
<li>Dla P-256 przejęliśmy asembler <code>nistz256</code> z OpenSSL i ring (ADR-130), z tablicami o stałej bazie do podpisywania i weryfikacji podpisu CA (ADR-110, ADR-111).</li>
<li>X25519 został przepisany na cztery 64-bitowe kończyny z instrukcjami MULX/ADX (ADR-131).</li>
<li>Następnie sprofilowaliśmy resztę (ADR-132, ADR-200).</li>
</ul>
<figure><img loading="lazy" alt="Uzgadnianie na VPS-ie: 1200 us przed, 237 us po asemblerze i tablicach; porównanie A/B memcpy w tym samym uruchomieniu, 278,8 us ze starym, 229,4 us z nowym; oraz przed i po dla X25519 (146 do 48 us), podpisu ECDSA (673 do 34 us) i weryfikacji (309 do 90 us)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/handshake.svg" /><figcaption>Uzgadnianie na VPS-ie: 1200 us przed, 237 us po asemblerze i tablicach; porównanie A/B memcpy w tym samym uruchomieniu, 278,8 us ze starym, 229,4 us z nowym; oraz przed i po dla X25519 (146 do 48 us), podpisu ECDSA (673 do 34 us) i weryfikacji (309 do 90 us).</figcaption></figure>
<table>
<thead>
<tr>
<th>Pomiar na VPS-ie (serwer, mikrosekundy)</th>
<th style="text-align: right;">Przed</th>
<th style="text-align: right;">Po</th>
</tr>
</thead>
<tbody>
<tr>
<td>uzgadnianie mTLS, cała kryptografia</td>
<td style="text-align: right;">1200</td>
<td style="text-align: right;">około 240 (237 w pomiarze z wykresu, 244 na zrzucie)</td>
</tr>
<tr>
<td>X25519</td>
<td style="text-align: right;">146</td>
<td style="text-align: right;">48</td>
</tr>
<tr>
<td>podpis ECDSA</td>
<td style="text-align: right;">673</td>
<td style="text-align: right;">34</td>
</tr>
<tr>
<td>weryfikacja ECDSA</td>
<td style="text-align: right;">309</td>
<td style="text-align: right;">90 (107 na zrzucie)</td>
</tr>
<tr>
<td>uzgadnianie, stary memcpy, ten sam boot</td>
<td style="text-align: right;">278,8</td>
<td style="text-align: right;">229,4 (-17,7%)</td>
</tr>
</tbody>
</table>
<p>Liczby różnią się między uruchomieniami o około 10%, więc zapisaliśmy zakres zamiast jednej wartości. W sumie to mniej więcej pięć razy mniej czasu CPU na uzgadnianie.</p>
<p>Czego nauczył nas memcpy. Gdy kryptografia spadła, profil pokazał coś, czego się nie spodziewaliśmy: funkcje <code>mem*</code> (memcpy, memmove, memset) zajmowały 7% uzgadniania, 13 do 39% ścieżki rekordu TLS (39% dla 64 B, 13% dla 4 KiB) i 17 do 34% rozdzielania PUBLISH. Zastąpiliśmy je asemblerem SSE2 dla x86_64 (ADR-200), z testami różnicowymi względem implementacji referencyjnej dla wszystkich rozmiarów, wyrównań i nakładania się. Na wywołanie to 55 do 75 cykli wobec 7 do 12 dla 1 do 64 bajtów.</p>
<figure><img loading="lazy" alt="Po lewej: udział czasu serwera w funkcjach mem* przed przepisaniem (uzgadnianie 7%, rekord TLS 64 B 39%, 256 B 37%, 1 KiB 24%, rozdzielanie 17 do 34%). Po prawej: o ile szybsza jest każda ścieżka potem (uzgadnianie 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, rozdzielanie 7 do 34%)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/hotpaths.svg" /><figcaption>Po lewej: udział czasu serwera w funkcjach mem* przed przepisaniem (uzgadnianie 7%, rekord TLS 64 B 39%, 256 B 37%, 1 KiB 24%, rozdzielanie 17 do 34%). Po prawej: o ile szybsza jest każda ścieżka potem (uzgadnianie 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, rozdzielanie 7 do 34%).</figcaption></figure>
<table>
<thead>
<tr>
<th>Ścieżka</th>
<th style="text-align: right;">Udział mem* przed</th>
<th style="text-align: right;">Szybciej po</th>
</tr>
</thead>
<tbody>
<tr>
<td>uzgadnianie</td>
<td style="text-align: right;">7%</td>
<td style="text-align: right;">17,7%</td>
</tr>
<tr>
<td>rekord TLS 64 B</td>
<td style="text-align: right;">39%</td>
<td style="text-align: right;">26,4%</td>
</tr>
<tr>
<td>rekord TLS 256 B</td>
<td style="text-align: right;">37%</td>
<td style="text-align: right;">19,6%</td>
</tr>
<tr>
<td>rekord TLS 1 KiB</td>
<td style="text-align: right;">24%</td>
<td style="text-align: right;">16,1%</td>
</tr>
<tr>
<td>rekord TLS 4 KiB</td>
<td style="text-align: right;">13%</td>
<td style="text-align: right;">6,6%</td>
</tr>
<tr>
<td>rozdzielanie wiadomości</td>
<td style="text-align: right;">17 do 34%</td>
<td style="text-align: right;">7 do 34%</td>
</tr>
</tbody>
</table>
<p>Jak czytać wykres: im mniejszy rekord, tym większa część czasu idzie na kopiowanie, więc tym większy zysk. Dla małych wiadomości, które stanowią większość ruchu MQTT, to właśnie ma znaczenie.</p>
<p>Czego to nie oznacza. Na natywnym x86_64 mierzyliśmy OpenSSL 3.5.5 (ADR-130) wyłącznie jako punkt odniesienia dla asemblera, a nie jako porównanie produktów. Porównania Linux plus Mosquitto na tym samym VPS-ie (zadanie X11) jeszcze nie zrobiliśmy. Dlatego nie twierdzimy, że urMQTT jest szybszy od Linuksa: nie zostało to zmierzone. Powyższe liczby porównują urMQTT z nim samym.</p>
<p>Przeciążenie było drugim wynikiem. Na VPS-ie, z jednego Maca (RTT 35 ms), 128 klientów dało 618 uzgodnień na sekundę, 256 klientów 393, a 512 klientów 67, przy tysiącach odrzuconych połączeń. To nie było plateau, tylko załamanie. Przyczyny znaleźliśmy w kodzie (dwie potwierdzono w QEMU/HVF, na VPS-ie nie dało się ich odtworzyć), kilka naraz: odrzucone połączenie wracało do klienta przez kolejny segment niosący ważne ciasteczko SYN, odrzucenie przez RST mówiło klientowi, żeby od razu spróbował ponownie, a odbiór był O(kolejka). Poprawka (ADR-080 do ADR-082) porzuca SYN, który i tak nie zostałby przyjęty, zapamiętuje odrzucone przepływy, utrzymuje odbiór w O(1) i liczy uzgodnienia na rdzeń. Możliwe (hipoteza, niesprawdzona), że część przyczyny leżała też po stronie klienta: narzędzie do obciążenia zamykało połączenia pierwsze, więc jeden Mac z 16 384 portami efemerycznymi i TIME_WAIT wynoszącym 30 sekund jest ograniczony do około 550 połączeń na sekundę. Dlatego prawdziwego limitu serwera jeszcze nie zmierzyliśmy.</p>
<h2 id="obietnica-2-surowy-dysk-ale-ostroznie">Obietnica 2: surowy dysk, ale ostrożnie</h2>
<p><strong>Co planowaliśmy.</strong> Zachować własny układ bez systemu plików i dodać wszystko, co system plików zwykle daje: wyrównanie, rozrost, discard, kopie zapasowe i kontrole. <strong>Dlaczego nie system plików.</strong> Rozmawialiśmy o tym i decyzja została potwierdzona: urMQTT zapisuje jedną bazę, jeden dziennik telemetrii i własne metadane, nie potrzebuje katalogów, uprawnień ani nazw plików, a każda dodatkowa warstwa to kolejny kod w zaufanej części i kolejny sposób, by zapis po cichu zniknął. Dysk to GPT z partycją ESP i jedną partycją danych urMQTT, na której jest własny układ.</p>
<p><strong>Co zbudowaliśmy (S2 do S6).</strong></p>
<ul>
<li><strong>Układ v2 (S2).</strong> Topologia dysku, wyrównanie do 1 MiB lub optymalnego rozmiaru I/O, całe bloki fizyczne, para superbloków A/B z generacją (ADR-140). Układ v1 jądro odrzuca z czytelnym komunikatem, a migruje go narzędzie offline <code>urmqtt-disk migrate</code> (ADR-141): domyślnie jest to przebieg próbny, kopiuje się, a dopiero potem przełącza, z wycofaniem i wstrzykiwaniem błędów przy każdym zapisie.</li>
<li><strong>Rozrost (S3).</strong> Przy starcie dysk może urosnąć (nigdy się nie zmniejsza), a na virtio-blk także w trakcie pracy, przez przerwanie zmiany konfiguracji (ADR-142, ADR-143).</li>
<li><strong>Discard i write-zeroes (S4).</strong> Dyski z cienkim przydziałem oddają miejsce hostowi. W teście plik qcow2 na hoście spadł z 62 do 16 MiB po wygaśnięciu segmentów telemetrii, a odzyskiwanie nigdy nie ufa zawartości, która została odrzucona.</li>
<li><strong>Kopia zapasowa bazy na dysku (S5).</strong> Kopia A/B za pomocą API online backup SQLite w krokach po 16 stron: najdłuższy krok 4,6 ms wobec 445 ms przy kopiowaniu jednym wywołaniem. Commity w trakcie kopii miały maksimum 2,4 ms wobec 2,2 ms bez niej. Jeśli <code>quick_check</code> przy starcie się nie powiedzie, baza jest przywracana z kopii.</li>
<li><strong>Scrubbing i ograniczony start (S6).</strong> Scrubber czyta dysk w ramach ograniczonego budżetu I/O i zgłasza uszkodzone rekordy, a start czyta tylko najnowszy segment każdej partycji, więc czas startu nie rośnie wraz z rozmiarem regionu telemetrii. Zamknęliśmy w ten sposób problem, który policzyliśmy z góry: na dużym dysku pełne odzyskiwanie trwałoby od minut do dziesiątek minut.</li>
</ul>
<p>Cała grupa smoke testów przechowywania przechodzi na obu architekturach: 79 udanych, 0 nieudanych, na aarch64 pod HVF i na x86_64 pod TCG. Zmierzyliśmy też, że S4 nie spowolnił bazy: 53,3 tys. wstawień na sekundę w paczkach przed i po, bez mierzalnej różnicy.</p>
<p><strong>Co zaskoczyło.</strong> Ile z „prostego" układu to przypadki brzegowe: virtio-scsi i virtio-blk zgłaszają topologię na różne sposoby, a niepełny zapis w środku migracji musi zawsze zostawić co najmniej jedną ważną wersję. Dlatego każdy zapis na ścieżce migracji i rozrostu przeszedł wstrzykiwanie błędów.</p>
<p><strong>Co jest otwarte.</strong> Strony SQLite nie mają sumy kontrolnej, więc zmiana bitu wewnątrz wartości w wierszu nie zostanie przez bazę wykryta; rekordy telemetrii i zawartość kopii mają CRC i są wykrywane. Szyfrowanie dysku (S7) nie jest gotowe.</p>
<h2 id="obietnica-3-bezpieczenstwo-ktore-da-sie-udowodnic-na-ile-sie-uda">Obietnica 3: bezpieczeństwo, które da się udowodnić, na ile się uda</h2>
<p><strong>Utwardzanie (H4).</strong> Alokator zeruje zwolnioną pamięć i sprawdza ją przy ponownym użyciu, wykrywa podwójne zwolnienie, wiąże wskaźniki listy wolnych przez XOR i zeruje klucze przy zwolnieniu (ADR-170). Ochrona stosu (<code>-Zstack-protector=strong</code>, 1315 funkcji, a SQLite z <code>-fstack-protector-strong</code>, ADR-171). Raporty błędów w produkcji nie zawierają rejestrów ani adresów (ADR-172). KASLR na obu architekturach: jądro jest PIE, a Limine przesuwa je o około 19 bitów, losowo ustawiane są sterta, stosy i okno MMIO (ADR-173). Na aarch64 MTE oznacza stertę (ADR-174), ale tylko pod TCG: HVF i x86 go nie mają. kCFI oceniliśmy i nie przyjęliśmy, bo kompilator na przypiętym nightly wywala się z błędem wewnętrznym; IOMMU odłożono razem z bare metalem (ADR-175, ADR-176).</p>
<p><strong>Koszt jest zmierzony, a nie założony.</strong> Na ścieżce uzgadniania (<code>dp-bench</code>, aarch64 HVF, pełne uzgadnianie TLS 1.3 + mTLS + CONNECT):</p>
<table>
<thead>
<tr>
<th>Krok</th>
<th style="text-align: right;">1 rdzeń</th>
<th style="text-align: right;">4 rdzenie</th>
<th>Zmiana</th>
</tr>
</thead>
<tbody>
<tr>
<td>bez utwardzania</td>
<td style="text-align: right;">1941</td>
<td style="text-align: right;">7620</td>
<td></td>
</tr>
<tr>
<td>+ alokator</td>
<td style="text-align: right;">1898</td>
<td style="text-align: right;">7429</td>
<td>-2,2% do -2,5%</td>
</tr>
<tr>
<td>+ ochrona stosu</td>
<td style="text-align: right;">1845</td>
<td style="text-align: right;">7243</td>
<td>razem -4,9% do -5%</td>
</tr>
<tr>
<td>+ PIE i oznaczanie</td>
<td style="text-align: right;">około 1800</td>
<td style="text-align: right;">około 6750</td>
<td>na obciążonym komputerze, z szumem</td>
</tr>
</tbody>
</table>
<p>W sumie koszt na ścieżce uzgadniania to więc około 5% dla części mierzonych czysto, a z PIE i oznaczaniem wychodzi około 7% na jednym i około 11% na czterech rdzeniach, ale ten pomiar jest zaszumiony. Uważamy go za akceptowalny, lecz nie powtórzyliśmy go na VPS-ie x86_64.</p>
<p>Warto porównać z liczbą z pierwszego tekstu (862 uzgodnienia na sekundę na jednym rdzeniu). To samo narzędzie dziś, ze sterownikiem sprzętowego szyfrowania na aarch64 (X0b) i serią przyspieszeń, które przyszły od tamtej pory, daje 1941 przed utwardzaniem. Nie wyodrębniliśmy wkładu każdej zmiany, więc o przyczynie nic nie twierdzimy.</p>
<p><strong>Jak to sprawdzono, a czego nie.</strong> H5 dodał Miri na podzbiorze crate'ów hosta, dowody Kani, sanitizery, model-fuzzer dla ACL i raporty pokrycia. Kani udowodnił <code>record_len</code> w TLS, <code>parse</code> w TCP i dwie arytmetyki koła timerów. <strong>Nie udowodniono</strong> (przekroczony czas): obu dekoderów MQTT, obu parsowań konfiguracji i zachowania koła timerów; zadanie CI dla Kani dlatego jeszcze nie blokuje. Miri nie potrafi wykonywać asemblera. Łańcuch dostaw (H9): SBOM CycloneDX z 215 komponentami, <code>cargo-vet</code> przechodzi z 39 w pełni zrecenzowanymi i 140 wyjątkami, a cargo-deny jest skonfigurowany tak, by traktować zalecenia o podatnościach jako błąd CI (te zadania CI były dotąd sprawdzone tylko lokalnie, nie na GitHubie, a test negatywny nie został uruchomiony), wraz z <code>VULNERABILITY-HANDLING.md</code>. Szczery stan: ze 67 wersji crate'ów zewnętrznych linkowanych do jądra 53 to wyjątki, a nie recenzje. Vet dowodzi, że zbioru zależności nie da się zmienić bez widocznej różnicy, a nie że TCB został przejrzany. Własnej recenzji żadnego crate'a nie napisaliśmy.</p>
<p>SNTP (T1) jest pod tym względem małą opowieścią o bezpieczeństwie: jest opcjonalny, tylko wobec prywatnych serwerów IPv4, z maksymalnym skokiem i warunkiem zgodności źródeł (mediana, większość w ciągu jednej sekundy). Zegar, który ktokolwiek po drodze może przesunąć, nie jest zegarem, któremu wolno ufać przy ważności certyfikatów.</p>
<h2 id="na-macu-aarch64-natywnie-i-x86_64-przez-emulacje">Na Macu: aarch64 natywnie i x86_64 przez emulację</h2>
<p>Rozwijamy na Macu, a ten ma dwa sposoby uruchamiania tego samego kodu i bardzo się one różnią.</p>
<table>
<thead>
<tr>
<th></th>
<th>aarch64 pod HVF</th>
<th>x86_64 pod TCG</th>
</tr>
</thead>
<tbody>
<tr>
<td>Co to jest</td>
<td>wykonanie natywne, szybkość bliska prawdziwego procesora</td>
<td>QEMU emuluje instrukcje</td>
</tr>
<tr>
<td>Uzgadnianie w gościu</td>
<td>około 0,58 ms (zmierzone po raz pierwszy około M13); <code>dp-bench</code> dziś 1941/s na rdzeń</td>
<td>około 3,3 ms</td>
</tr>
<tr>
<td>Scenariusze smoke TLS</td>
<td>szybkie</td>
<td>około 15 minut na scenariusz</td>
</tr>
<tr>
<td>MTE</td>
<td>nie (nie ma go na HVF)</td>
<td>x86 go nie ma; oznaczanie sterty działa tylko dla gościa aarch64 pod TCG</td>
</tr>
<tr>
<td>Do czego służy</td>
<td>prawdziwe pomiary</td>
<td>poprawność: ścieżka x86 jest sprawdzana, liczby nie są wydajnością</td>
</tr>
</tbody>
</table>
<p>Zasada jest prosta: liczby pod TCG to emulacja i nie cytujemy ich jako wydajności. Wydajność x86 mierzymy tylko na prawdziwym VPS-ie. Dlatego liczby kryptograficzne powyżej pochodzą z VPS-a Contabo, a nie z Maca.</p>
<h2 id="lekcje">Lekcje</h2>
<ul>
<li><strong>Mierz na prawdziwej maszynie, zanim zaczniesz optymalizować.</strong> Pierwsze podejście polegało na optymalizowaniu tego, co uważaliśmy za wolne; dopiero diagnostyka na VPS-ie pokazała 1,2 ms i gdzie dokładnie ucieka czas.</li>
<li><strong>Przeciążenie to osobna cecha.</strong> System szybki pod obciążeniem może się załamać powyżej niego; odrzucenie musi być tańsze niż przyjęcie.</li>
<li><strong>Profil zaskakuje.</strong> Największy pojedynczy zysk dla małych wiadomości nie był w kryptografii, tylko w kopiowaniu pamięci.</li>
<li><strong>Dysk bez systemu plików wymaga więcej pracy, nie mniej.</strong> To, co system plików zwykle daje (wyrównanie, rozrost, discard, kopie, kontrole), musimy napisać i sprawdzić sami, każdy krok ze wstrzykiwaniem błędów.</li>
<li><strong>Uczciwość w liczbach.</strong> Pomiary pamięci na współdzielonym VPS-ie wahają się nawet do 10 razy między uruchomieniami i nic nie mówią o kodzie, więc ich nie podajemy. Liczby kryptograficzne są stabilne do około 10%.</li>
<li><strong>Tempo rozwoju to nie dojrzałość.</strong> Testy i ADR-y rosną razem z kodem, ale cztery dni to nie jest prawdziwa eksploatacja.</li>
</ul>
<h2 id="co-dalej">Co dalej</h2>
<ul>
<li><strong>H6:</strong> SQLite wyizolowany na poziomie EL0, tak aby błąd w bazie nie otwierał jądra.</li>
<li><strong>H7:</strong> reszta bezpieczeństwa czasu (NTS i obsługa skoków); SNTP to dopiero pierwszy krok.</li>
<li><strong>X11:</strong> porównanie z Linuksem i Mosquitto na tym samym VPS-ie, dopiero wtedy ma sens mówić o względnej szybkości.</li>
<li><strong>Wydanie:</strong> pierwsza publiczna wersja dla VPS (KVM), z podpisanymi artefaktami i instrukcjami.</li>
<li><strong>Po wydaniu:</strong> bare metal (X12 do X16), IOMMU i Secure Boot oraz szyfrowanie dysku.</li>
</ul>
<p>Kod jest na <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, a <code>docs/PROGRESS.md</code> nadal jest jedynym źródłem prawdy o tym, co naprawdę działa. Pre-alfa, jak poprzednio; zgłoszenia błędów i zgłoszenia dotyczące bezpieczeństwa zgodnie z instrukcją w <code>SECURITY.md</code> są mile widziane.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT: budujemy broker MQTT, który jest jednocześnie systemem operacyjnym</title>
      <link>https://aldo.tech/pl/blog/urmqtt-unikernel-broker-mqtt/</link>
      <guid isPermaLink="true">https://aldo.tech/pl/blog/urmqtt-unikernel-broker-mqtt/</guid>
      <pubDate>Thu, 29 Oct 2026 08:00:00 +0000</pubDate>
      <description>Notatki z prac nad urMQTT, unikernelem napisanym w Rust, który sam jest brokerem MQTT: jeden plik ELF z własnym stosem TCP/IP, TLS 1.3, brokerem i SQLite, uruchamiany z jednego obrazu ISO.</description>
      <content:encoded><![CDATA[<p>Urządzenia w pojazdach, na parkingach i w szafach z elektroniką mają jedną wspólną cechę: wysyłają niewielkie komunikaty, rzadko, ale jest ich bardzo dużo i są stale połączone. Od lat podłączamy je do brokerów MQTT i od lat dręczy nas to samo pytanie. Dlaczego do czegoś tak prostego potrzebujemy całego Linuksa, OpenSSL, środowiska uruchomieniowego jakiegoś języka, SSH, menedżera pakietów i dopiero na końcu brokera? Każda z tych warstw wnosi własne podatności, zużywa własną pamięć i dokłada kolejną rzecz, która może się zepsuć, a flota urządzeń oczekuje od serwera tylko trzech rzeczy: utrzymywania wielu niemal bezczynnych połączeń, niezawodnego uwierzytelniania każdego urządzenia i przekazywania komunikatów bez strat.</p>
<p>Tak powstał <a href="https://github.com/zeljan-alduk/urMQTT">urMQTT</a>: unikernel, który <em>jest</em> brokerem MQTT. Jądro, TCP/IP, TLS 1.3, MQTT i wbudowana baza danych w jednym programie binarnym napisanym w Rust, uruchamianym z jednego obrazu ISO. Bez procesów, bez wywołań systemowych, bez powłoki. Pierwszą platformą, na której powstaje i jest testowany, jest aarch64, a ten sam kod zmierza w stronę x86_64, przez UEFI i klasyczny BIOS, oraz prawdziwego sprzętu: celem jest jeden ISO, który uruchamia się na tym wszystkim. Projekt jest open source na licencji MIT lub Apache 2.0, a w tym tekście chcemy zapisać, gdzie jesteśmy, jak jest zbudowany od środka i czego przy okazji się nauczyliśmy.</p>
<h2 id="co-dokadnie-oznacza-unikernel">Co dokładnie oznacza „unikernel"</h2>
<p>Typowy broker to program działający na systemie operacyjnym. urMQTT nie ma pod sobą żadnego systemu operacyjnego: sam startuje przez firmware UEFI i bootloader Limine, ustawia własne tablice stron, programuje kontroler przerwań i timer, odnajduje urządzenia virtio przez PCIe, uzyskuje adres przez DHCP i nasłuchuje na jednym porcie. Wszystko, co normalnie robiłyby jądro, sterowniki, stos sieciowy i aplikacja, jest tutaj jednym plikiem ELF działającym na poziomie EL1.</p>
<p>Porównanie z typową instalacją mówi samo za siebie:</p>
<table>
<thead>
<tr>
<th></th>
<th>Typowy broker na Linuksie</th>
<th>urMQTT</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wystawione usługi</td>
<td>SSH, broker, często także port metryk lub administracji</td>
<td><strong>tylko TCP 8883</strong> (MQTT przez TLS 1.3 z mTLS)</td>
</tr>
<tr>
<td>Kod w obrazie</td>
<td>jądro, userland, libc, OpenSSL, środowisko uruchomieniowe</td>
<td><strong>jeden plik ELF w Rust</strong> (plus dołączony SQLite)</td>
</tr>
<tr>
<td>Zdalne zarządzanie</td>
<td>powłoka, pliki konfiguracyjne, aktualizacje pakietów</td>
<td><strong>brak.</strong> Nowa wersja to nowy ISO</td>
</tr>
<tr>
<td>Pamięć na bezczynne połączenie</td>
<td>kilobajty (bufory gniazd, obiekty TLS, sterta)</td>
<td><strong>cel ≤ 512 B</strong></td>
</tr>
<tr>
<td>Model wielordzeniowy</td>
<td>wątki i blokady albo jedna pętla zdarzeń</td>
<td><strong>wątek na rdzeń, bez współdzielonego stanu, obsługa do końca</strong></td>
</tr>
<tr>
<td>Integralność kodu</td>
<td>możliwe są odwzorowania jednocześnie zapisywalne i wykonywalne</td>
<td><strong>W^X, zablokowane tablice stron, PAC/BTI</strong></td>
</tr>
</tbody>
</table>
<p>Priorytety są następujące, w tej kolejności: bezpieczeństwo, skalowalność, wydajność wielordzeniowa, mały ślad i niezmienna instalacja. Gdy dwa cele wchodzą w konflikt, wygrywa ten, który jest wyżej na liście.</p>
<h2 id="gdzie-jestesmy-teraz">Gdzie jesteśmy teraz</h2>
<p>Projekt jest w fazie pre-alfa, ale zamknęliśmy siedemnaście kamieni milowych, od M0 do M17. Dziś urMQTT obsługuje MQTT 3.1.1 przez TLS 1.3 z obowiązkowymi certyfikatami klienta, a stan przechowuje w SQLite na surowym dysku. W skrócie, co już działa:</p>
<ul>
<li><strong>Jądro:</strong> własne wektory wyjątków z zdekodowanymi raportami, własne tablice stron z egzekwowanym W^X, strony ochronne, PAC i BTI tam, gdzie procesor je ma, GICv3 i GICv2, timer ogólnego przeznaczenia, SMP do 64 rdzeni.</li>
<li><strong>Wykrywanie sprzętu</strong> z DTB lub ACPI do jednego opisu platformy, z identycznym wynikiem z obu źródeł.</li>
<li><strong>Sterowniki:</strong> PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi i virtio-rng oraz CSPRNG zasilany sprzętową entropią.</li>
<li><strong>Sieć:</strong> własne DHCPv4, ARP, ICMP i rygorystyczne IPv4, a do tego własny TCP wyłącznie po stronie serwera: ciasteczka SYN, hierarchiczne koło timerów, NewReno, łączenie ACK, bez stanu TIME_WAIT.</li>
<li><strong>TLS:</strong> wyłącznie TLS 1.3, obowiązkowe mTLS, tożsamość urządzenia z CN lub SAN URI; uzgadnianie obsługuje rustls, a po nim działa własna warstwa rekordów z AES-128-GCM i ChaCha20-Poly1305. Wznawianie sesji biletami PSK, lista unieważnień, kontrola przyjęć już na etapie uzgadniania.</li>
<li><strong>MQTT:</strong> kodek bez kopiowania, fuzzowany i testowany z prawdziwymi klientami mosquitto; QoS 0 i 1, symbole wieloznaczne, wiadomości zatrzymane, testament (LWT), trwałe sesje, ACL ograniczone do <code>devices/{id}/#</code>, limity szybkości na urządzenie.</li>
<li><strong>Przechowywanie:</strong> SQLite wewnątrz jądra przez VFS nad surowym dyskiem, w trybie WAL, ze sprawdzeniem integralności przy starcie; rejestr urządzeń, unieważnienia, wiadomości zatrzymane, trwałe sesje z kolejkami wiadomości dla urządzeń offline; osobny dziennik telemetrii na każdy rdzeń z CRC na zapis i odzyskiwaniem po awarii.</li>
<li><strong>Eksploatacja i nadzór:</strong> timer nadzorczy dla procedur obsługi, kontrola stanu, ograniczone logowanie, syslog i statsd przez UDP, kontrola przyjęć, która przetrwa burze ponownych połączeń.</li>
<li><strong>Dostawa:</strong> bitowo identyczne ponowne kompilacje weryfikowane w CI, ISO i surowy obraz dysku GPT, podpisy minisign, instrukcje dla KVM, Proxmox i Hetzner.</li>
</ul>
<p>Każdy krok jest oznaczony kamieniem milowym, który go wprowadził, a plik <code>docs/PROGRESS.md</code> w repozytorium jest jedynym źródłem prawdy o tym, co naprawdę działa.</p>
<h2 id="architektura-w-trzech-obrazach">Architektura w trzech obrazach</h2>
<p>Cały projekt jest opisany w <code>docs/ARCHITECTURE.md</code>, a powody każdej ważniejszej decyzji zapisane są jako ADR w <code>docs/DECISIONS.md</code>. Poniżej przegląd.</p>
<h3 id="warstwy">Warstwy</h3>
<figure><img loading="lazy" alt="Warstwy urMQTT: od brokera przez kodek MQTT, TLS, TCP i sieć do sterowników virtio i warstwy architektury, wszystko w jednym pliku ELF na poziomie EL1." src="https://aldo.tech/blog/urmqtt/img/layers.svg" /><figcaption>Warstwy urMQTT: od brokera przez kodek MQTT, TLS, TCP i sieć do sterowników virtio i warstwy architektury, wszystko w jednym pliku ELF na poziomie EL1.</figcaption></figure>
<p>Od góry do dołu: broker (sesje, drzewo tematów, QoS, wiadomości zatrzymane, testamenty, ACL, limity), kodek MQTT, TLS, TCP, warstwa sieciowa, a następnie sterowniki. Obok tej głównej ścieżki znajduje się przechowywanie (SQLite i dziennik telemetrii), a pod spodem zarządzanie pamięcią, wykrywanie platformy i konfiguracja zamieniona w czasie kompilacji na stałe, razem z wbudowanym certyfikatem CA, certyfikatem serwera i kluczem. Jedyne, co leży poza obrazem, to hipernadzorca z urządzeniami virtio.</p>
<h3 id="watek-na-rdzen-bez-wspodzielonego-stanu">Wątek na rdzeń, bez współdzielonego stanu</h3>
<figure><img loading="lazy" alt="Model wykonania: każdy rdzeń danych ma własną pętlę i własny fragment brokera, a najwyższy rdzeń uruchamia wyłącznie SQLite. Rdzenie komunikują się wyłącznie przez pierścienie SPSC." src="https://aldo.tech/blog/urmqtt/img/cores.svg" /><figcaption>Model wykonania: każdy rdzeń danych ma własną pętlę i własny fragment brokera, a najwyższy rdzeń uruchamia wyłącznie SQLite. Rdzenie komunikują się wyłącznie przez pierścienie SPSC.</figcaption></figure>
<p>Każdy rdzeń danych prowadzi własną pętlę z własnym stosem TCP, stanami TLS, fragmentem brokera (sesje i drzewo tematów), pamięcią podręczną sterty i partycją telemetrii. Rdzenie nie współdzielą żadnego zmiennego stanu, a jedynie ograniczone pierścienie bez blokad, po jednym dla każdej uporządkowanej pary rdzeni. Najwyższy rdzeń uruchamia SQLite i nic więcej.</p>
<p>Karta sieciowa ma dziś jedną parę kolejek, którą obsługuje rdzeń 0. Kieruje on każdy strumień MQTT, według skrótu Toeplitz jego czwórki, na jeden z rdzeni roboczych i wysyła to, co rdzenie robocze zwrócą. Sesja żyje na swoim rdzeniu macierzystym, wyznaczonym skrótem identyfikatora klienta, więc przejmowanie, testamenty i trwałość nigdy nie przekraczają granic rdzeni. Połączenie, które trafi na inny rdzeń, zachowuje tam swój TCP i TLS, a zwykły MQTT przekazuje rdzeniowi macierzystemu z kontrolą przepływu opartą na kredytach. Każda skierowana wiadomość jest też rozsyłana do pozostałych fragmentów brokera, z których każdy utrzymuje replikę wiadomości zatrzymanych.</p>
<p>Ten model spinają trzy zasady. <strong>Obsługa do końca:</strong> odebrany pakiet jest przetwarzany w całości na jednym rdzeniu, od TCP przez deszyfrowanie TLS w miejscu, parsowanie MQTT i routing aż do zaszyfrowanej odpowiedzi, bez kolejek zadań i bez kopiowania do buforów aplikacji. <strong>Adaptacyjne odpytywanie:</strong> pod obciążeniem kolejki odbiorcze są aktywnie odpytywane, a w bezczynności rdzeń wraca do przerwań i <code>WFI</code>; procedury obsługi przerwań jedynie potwierdzają przerwanie i oznaczają pracę. <strong>Timer nadzorczy:</strong> takt 10 ms na rdzeń wyłapuje procedury obsługi, które przekraczają swój budżet.</p>
<h3 id="zycie-jednego-publish">Życie jednego PUBLISH</h3>
<figure><img loading="lazy" alt="Droga jednej wiadomości PUBLISH z QoS 1: od rekordu TLS na karcie sieciowej przez deszyfrowanie, sprawdzenie ACL i routing do PUBACK połączonego z TCP ACK w jednym segmencie." src="https://aldo.tech/blog/urmqtt/img/publish.svg" /><figcaption>Droga jednej wiadomości PUBLISH z QoS 1: od rekordu TLS na karcie sieciowej przez deszyfrowanie, sprawdzenie ACL i routing do PUBACK połączonego z TCP ACK w jednym segmencie.</figcaption></figure>
<p>Urządzenie wysyła rekord TLS. Karta sieciowa przekazuje go pętli rdzenia deskryptorem, bez kopiowania. Pętla sprawdza TCP, deszyfruje TLS w miejscu i parsuje MQTT z pożyczonego bufora, po czym w pamięci sprawdza ACL, limit szybkości i ścieżkę przez drzewo tematów. Jeśli temat interesuje inny rdzeń, ten otrzymuje komunikat routingu z odwołaniem do ładunku i identyfikatorem tematu; baza dostaje wpis telemetrii lub aktualizację wiadomości zatrzymanej, w paczce. Odpowiedź PUBACK jest szyfrowana bezpośrednio do wychodzącego bufora i łączona z TCP ACK, więc do urządzenia wychodzi jeden segment. Inny rdzeń kosztuje wiadomość jedno szyfrowanie na subskrybenta w chwili wysyłki.</p>
<h2 id="ile-kosztuje-jedno-poaczenie">Ile kosztuje jedno połączenie</h2>
<p>Celem jest <strong>najwyżej 512 bajtów na bezczynne połączenie TLS</strong>, z dążeniem do 256. Gdy połączenie spoczywa, nic dla niego nie jest trzymane w buforze: odebrane dane przetwarzane są bezpośrednio z ramki sieciowej, a bufory wysyłania, rekordów i MQTT istnieją tylko wtedy, gdy coś jest niedokończone lub niepotwierdzone. Jądro samo to mierzy: dokładne liczniki sterty w żądanych bajtach i w blokach, które faktycznie zajmuje, zapisywane co dziesięć sekund jako sterta ponad poziom bazowy na otwarte połączenie.</p>
<table>
<thead>
<tr>
<th>Element</th>
<th style="text-align: right;">Rozmiar</th>
<th>Jak</th>
</tr>
</thead>
<tbody>
<tr>
<td>Blok kontrolny TCP</td>
<td style="text-align: right;">96 B</td>
<td>spakowane flagi, 16-bitowe RTT/RTO, miejsce w kole timerów, bez bufora w bezczynności</td>
</tr>
<tr>
<td>Miejsce w tablicy TCP</td>
<td style="text-align: right;">4–8 B</td>
<td>tylko indeks połączenia, tablica najwyżej w połowie zapełniona</td>
</tr>
<tr>
<td>Połączenie i stan rekordów TLS 1.3</td>
<td style="text-align: right;">152 B</td>
<td>sekret i numer sekwencyjny na kierunek (40 B); klucz i IV wyprowadzane na rekord</td>
</tr>
<tr>
<td>Połączenie MQTT</td>
<td style="text-align: right;">48 B</td>
<td>tożsamość i testament na stercie tylko wtedy, gdy istnieją</td>
</tr>
<tr>
<td>Sesja MQTT, forma gorąca</td>
<td style="text-align: right;">64 B</td>
<td>jedna subskrypcja w wierszu, internowane identyfikatory węzłów</td>
</tr>
<tr>
<td>Subskrypcja <code>devices/&lt;id&gt;/cmd/#</code></td>
<td style="text-align: right;">0 węzłów drzewa</td>
<td>przechowywana względem identyfikatora sesji, wspólna dla wszystkich urządzeń</td>
</tr>
</tbody>
</table>
<p>Zmierzone pod HVF z czterema rdzeniami wirtualnymi, z czego trzy robocze, przy czym każde urządzenie utrzymuje połączenie mTLS i jedną subskrypcję z QoS 1, wraz z zaokrągleniami alokatora:</p>
<table>
<thead>
<tr>
<th style="text-align: right;">Połączenia</th>
<th style="text-align: right;">Bajtów na połączenie</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: right;">960</td>
<td style="text-align: right;">531 B</td>
</tr>
<tr>
<td style="text-align: right;">9 922</td>
<td style="text-align: right;">494 B</td>
</tr>
<tr>
<td style="text-align: right;">10 197</td>
<td style="text-align: right;">474 B</td>
</tr>
</tbody>
</table>
<p>Pula ramek sieciowych ma stały rozmiar około 2 MiB, co daje mniej więcej 200 B na połączenie przy dziesięciu tysiącach połączeń i około 2 B przy milionie. Więcej nie udało nam się zmierzyć: sieć użytkownika QEMU na macOS przestała przyjmować połączenia na 10 197, mniej więcej przy granicy 10 240 deskryptorów procesu QEMU. Wartość maleje, gdy stałe części rozkładają się na więcej połączeń, więc nie rośnie wraz z ich liczbą.</p>
<h2 id="model-bezpieczenstwa">Model bezpieczeństwa</h2>
<p>Bezpieczeństwo jest pierwszym priorytetem i wygrywa wszystkie konflikty z pozostałymi celami. Model ma kilka warstw:</p>
<ul>
<li><strong>Jeden port.</strong> TCP 8883, wyłącznie TLS 1.3, obowiązkowe certyfikaty klienta podpisane przez CA wbudowane w obraz. Identyfikator klienta MQTT musi być równy identyfikatorowi urządzenia z certyfikatu.</li>
<li><strong>Brak wejść.</strong> Brak powłoki, wejścia z konsoli, SSH, API administracyjnego, kodu dynamicznego, SQL dostępnego z sieci i systemu plików.</li>
<li><strong>Bezpieczeństwo pamięci.</strong> Rust, z <code>forbid(unsafe_code)</code> w pakietach protokołów; każdy blok <code>unsafe</code> ma komentarz <code>// SAFETY:</code> weryfikowany przez lint, a na dane zewnętrzne nie ma paniki: clippy w pakietach na ścieżce danych zakazuje <code>unwrap</code>, <code>expect</code>, indeksowania i <code>panic!</code>.</li>
<li><strong>Łagodzenie skutków exploitów.</strong> W^X jest egzekwowane przez kodowanie deskryptorów i bit <code>SCTLR_EL1.WXN</code> oraz audytowane przy każdym starcie. Tablice stron po uruchomieniu są tylko do odczytu, bez zapisywalnego aliasu. Dolna połowa przestrzeni adresowej jest wyłączona. Każdy stos ma strony ochronne, a wyjątki działają na osobnym stosie. PAC i BTI włączają się, gdy procesor je obsługuje, co weryfikują testy dymne.</li>
<li><strong>Odporność na nadużycia.</strong> Ciasteczka SYN; kontrola przyjęć odrzuca nowe połączenia, przed jakąkolwiek kryptografią, gdy sterta jest niska, gdy baza zalega, gdy zbyt wiele uzgodnień trwa na rdzeń lub na adres źródłowy albo gdy tempo uzgodnień jest zbyt wysokie. Limity szybkości na urządzenie, ograniczone kolejki, okno TCP powiązane z przeciwciśnieniem brokera, ograniczone logowanie, timer nadzorczy i kontrola stanu.</li>
<li><strong>Niezmienność.</strong> Konfiguracja, CA i klucz serwera są wbudowane w obraz. Zmiana czegokolwiek oznacza nowy obraz.</li>
</ul>
<p>Model zagrożeń jest zapisany w <code>docs/SECURITY.md</code>.</p>
<h2 id="przechowywanie-bez-systemu-plikow">Przechowywanie bez systemu plików</h2>
<p>Dane żyją na osobnym dysku, virtio-blk lub virtio-scsi, na którym nie ma systemu plików. Układ jest minimalny i własny:</p>
<pre><code>LBA 0      [ superblok: magia · wersja układu · uuid · tablica regionów · CRC-32C ]
LBA 8, 16  [ tablica rozmiarów plików, sloty A/B (wygrywa najnowszy poprawny) ]
LBA 64     [ główna baza SQLite ½ ][ SQLite WAL ⅛ ][ dziennik 1 MiB ][ dziennik telemetrii: 8 partycji … ]
ostatnie 4 KiB [ przestrzeń do autotestu urządzenia blokowego przy starcie ]
</code></pre>
<p>SQLite (dołączona wersja 3.54.1, ze sprawdzeniem skrótu podczas kompilacji) jest kompilowany do jądra za pomocą clang, bez biblioteki standardowej, i używa stałej sterty <code>memsys5</code>. Jego VFS odwzorowuje trzy stałe pliki na regiony dysku. Działa w trybie WAL z <code>locking_mode=EXCLUSIVE</code>, więc nie potrzebuje indeksu w pamięci współdzielonej, oraz z <code>synchronous=FULL</code>; jeśli <code>quick_check</code> przy starcie znajdzie błąd, system zatrzymuje się w bezpieczny sposób. W bazie znajdują się rejestr urządzeń, unieważnienia, wiadomości zatrzymane i trwałe sesje z kolejkami wiadomości QoS 1 dla urządzeń, które nie są połączone.</p>
<p>Rdzeń bazy, ten o najwyższym numerze, uruchamia SQLite i nic więcej. Płaszczyzna danych wysyła mu zmiany stanu przez pierścień SPSC bez blokad; on zbiera je w jedną transakcję co 5 ms lub co 512 operacji i zwraca potwierdzenia drugim pierścieniem. Pusty dysk jest formatowany przy pierwszym starcie; wszystkiego innego nigdy się nie nadpisuje formatowaniem.</p>
<p>Telemetria idzie inną drogą. Każdy przyjęty PUBLISH na tematach z <code>storage.telemetry_topics</code> trafia do partycji dziennika telemetrii swojego rdzenia: pierścienia segmentów z CRC na zapis, osolonego numerem sekwencyjnym segmentu, tak aby stare przebiegi pierścienia nigdy nie pojawiły się ponownie. Rdzenie przekazują zapisy bazie własnym pierścieniem, bez kopiowania, a ona zrzuca je co 5 ms lub co 256 KiB. Przy starcie odzyskiwanie zachowuje każdy zrzucony zapis, odcina niedokończony ogon i kontynuuje w nowym segmencie. Retencja działa według rozmiaru, przez pierścień, i według wieku, przez <code>retention_days</code>. Sprawdziliśmy to z milionem zapisów i z <code>kill -9</code> w trakcie zapisu.</p>
<h2 id="liczby">Liczby</h2>
<p>Narzędzie do generowania obciążenia w <code>tools/loadgen</code> robi trzy rzeczy: burzę uzgodnień TLS 1.3 z mTLS i CONNECT, długotrwałe połączenia z PINGREQ, które co sekundę publikują telemetrię, oraz burze ponownych połączeń. <code>make bench</code> na Apple M4 Pro pod HVF daje:</p>
<table>
<thead>
<tr>
<th>Rdzenie robocze</th>
<th>uzgodnień/s w gościu</th>
<th>skalowanie</th>
<th>przez sieć użytkownika QEMU (połączeń/s)</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>862</td>
<td>1,00×</td>
<td>789</td>
</tr>
<tr>
<td>2</td>
<td>1 724</td>
<td>2,00×</td>
<td>636</td>
</tr>
<tr>
<td>4</td>
<td>3 438</td>
<td>3,99×</td>
<td>536</td>
</tr>
</tbody>
</table>
<p>Wartość w gościu, gdzie klient i serwer są wewnątrz tej samej maszyny wirtualnej, a każdy rdzeń roboczy pracuje sam dla siebie, pokazuje, że płaszczyzna danych skaluje się liniowo. Wartość sieciowa jest ograniczona jednowątkową siecią użytkownika QEMU, a nie gościem; zapisaliśmy to jako ADR-032.</p>
<p>Burze ponownych połączeń testujemy tak, że wszystkie urządzenia łączą się naraz, ponawiają odrzucone próby z opóźnieniem, a potem wszystkie padają bez DISCONNECT. Parametr <code>heap_limit_mb=</code> w trybie deweloperskim sprawia, że kontrola przyjęć uważa stertę za mniejszą, niż jest, więc burzę większą niż sterta można wypróbować przy niewielu gniazdach. W jednej takiej rundzie z 3 000 urządzeń połączyło się 1 368 w 36 sekund, a 5 619 prób zostało należycie odrzuconych, przy limicie 8 MiB; pamięć po burzy wróciła do poprzedniego stanu.</p>
<h2 id="jak-pracujemy">Jak pracujemy</h2>
<p>Projekt prowadzi pisana specyfikacja, <code>PROMPT.md</code>, z planem kamieni milowych, a każdy kamień milowy kończy się tym samym rytuałem: <code>make check</code> i <code>make smoke</code> na zielono, jeden commit z oznaczeniem <code>M&lt;n&gt;</code>, wpis w <code>docs/PROGRESS.md</code> i ADR dla każdej decyzji, która nie jest oczywista. Zasady są krótkie i nie negocjujemy ich: brak paniki na dane zewnętrzne, <code>unsafe</code> tylko w pakietach sprzętowych i FFI oraz zawsze z komentarzem, brak alokacji na gorącej ścieżce, komentarz przy każdym porządku operacji atomowych, każde ograniczenie pochodzi z konfiguracji, a celowo tymczasowe fragmenty zawodzą głośno.</p>
<p>CI robi więcej, niż bylibyśmy w stanie ręcznie: fmt, clippy dla hosta i dla <code>aarch64-unknown-none</code>, typos, actionlint i shellcheck; testy na Linuksie i macOS na arm64; kompilację jądra dla konfiguracji deweloperskiej i produkcyjnej z rozmiarem ELF w podsumowaniu; start pod QEMU ze wszystkimi scenariuszami testu dymnego i zapisem dzienników szeregowych; cargo-deny dla zaleceń, licencji i źródeł; minutę fuzzingu na każdy z jedenastu celów, a nocą dziesięć minut na cel z korpusem w pamięci podręcznej. Do tego CodeQL, OpenSSF Scorecard, Dependabot i wydanie z pochodzeniem SLSA. Każda cudza akcja jest przypięta do pełnego SHA commita.</p>
<p>Powtarzalność rozumiemy dosłownie: <code>make repro</code> buduje HEAD dwukrotnie w świeżych drzewach roboczych i porównuje ELF, ISO i surowy obraz bajt po bajcie. Wydania są podpisane minisign, a artefakty można zweryfikować przez <code>gh attestation verify</code>. Obraz produkcyjny zawiera klucz prywatny serwera, więc sam ISO jest tajemnicą; obrazy dołączone do wydań na GitHubie są deweloperskie, z kluczami tymczasowymi, i służą wyłącznie do wypróbowania.</p>
<h2 id="czego-sie-nauczylismy">Czego się nauczyliśmy</h2>
<p>Kilka lekcji, które zmieniły plan:</p>
<ul>
<li><strong>Własny TCP jednak się opłacił.</strong> Prototyp działał na smoltcp i dobrze nam służył do M13. Ale serwer utrzymujący setki tysięcy połączeń nie potrzebuje klienckiego TCP, potrzebuje ciasteczek SYN, koła timerów i stanu o rozmiarze 96 bajtów. W M14 zastąpiliśmy smoltcp własnym stosem wyłącznie po stronie serwera, z testami symulowanych strat i fuzzingiem segmentów.</li>
<li><strong>Mierzcie pamięć od środka.</strong> Szacunki na połączenie były bezużyteczne, dopóki jądro samo nie zaczęło liczyć sterty w blokach alokatora, a nie w żądanych bajtach. Dopiero wtedy zobaczyliśmy, gdzie naprawdę idzie 512 bajtów, i zeszliśmy do 494.</li>
<li><strong>Narzędzie do testowania jest częścią produktu.</strong> Największym ograniczeniem nie było jądro, lecz sieć użytkownika QEMU i liczba portów efemerycznych na komputerze deweloperskim. Dla stu tysięcy połączeń i więcej potrzebny jest <code>vmnet-shared</code> na macOS albo Linux z KVM, interfejsem tap i wieloma adresami klientów.</li>
<li><strong>SQLite bez systemu operacyjnego jest możliwy</strong>, ale tylko przy rygorystycznych warunkach: stała sterta, wyłączna blokada bez pamięci współdzielonej, VFS, który nie wie o plikach, oraz kontrola przyjęć, która nie pozwala bazie zalegać.</li>
<li><strong>Burze ponownych połączeń są normalnym stanem</strong>, a nie wyjątkiem. Gdy sieć komórkowa padnie i wróci, tysiące urządzeń pukają jednocześnie. Dlatego kontrola przyjęć odrzuca połączenie przed jakąkolwiek kryptografią, a każde odrzucone połączenie kosztuje prawie nic.</li>
</ul>
<h2 id="co-dalej">Co dalej</h2>
<p>Prace po M17 zaplanowano w trzech kierunkach, a kolejność między nimi jest zapisana w <code>docs/PLAN.md</code>. Nic z tego nie zostało jeszcze zbudowane.</p>
<ul>
<li><strong>Jeden ISO dla wielu platform.</strong> Ten sam plik <code>urmqtt-&lt;env&gt;-&lt;wersja&gt;.iso</code> i jeden surowy obraz GPT, które startują na aarch64 UEFI, x86_64 UEFI i x86_64 BIOS, w maszynach wirtualnych i na prawdziwym sprzęcie, zaczynając od jednego komputera PC z Intelem. Nadal urządzenie ze wszystkim w środku, powtarzalne i podpisane.</li>
<li><strong>Surowy dysk, bez systemu plików.</strong> Specyfikacja formatu na poziomie bajtów, narzędzie hosta <code>urmqtt-disk</code> współdzielące parsery z jądrem, wyrównanie regionów do 1 MiB, rozrost dysku wraz z danymi, discard dla dysków z cienkim przydziałem, migawki A/B bazy, weryfikacja bitów i, opcjonalnie, szyfrowanie AES-256-XTS ze sprzętowym AES.</li>
<li><strong>Udowodnione bezpieczeństwo.</strong> Unieważnianie i lista dozwolonych urządzeń w czasie pracy przez podpisany kanał kontrolny, bez nowego obrazu; testowanie różnicowe i zgodnościowe własnego TLS i TCP; KASLR, ochrona stosu, kCFI, MTE i wzmocniony alokator; SQLite wyizolowany na poziomie EL0; Secure Boot; SBOM i cargo-vet; a na końcu pakiet do audytu zewnętrznego.</li>
</ul>
<p>Platformy, które oferują wyłącznie urządzenia inne niż virtio, takie jak AWS ENA czy Azure MANA, na razie nie są obsługiwane. Raspberry Pi 4 i 5 jako host KVM, Hetzner CAX i Oracle Ampere A1 są udokumentowane i obsługiwane w kodzie, ale nie zostały jeszcze wypróbowane w prawdziwej usłudze.</p>
<h2 id="wyprobuj">Wypróbuj</h2>
<p>Repozytorium jest na <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>. Z Rust z przypiętym łańcuchem narzędzi nightly i QEMU wystarczą <code>make certs</code>, <code>make run</code> i jeden klient mosquitto, aby wysłać pierwszy PUBLISH przez mTLS na własnym komputerze. Ostrzeżenie z początku obowiązuje także na końcu: to pre-alfa, a wszystko, co działa, jest opisane w <code>docs/PROGRESS.md</code>. Zgłoszenia błędów, pytania o projekt i zgłoszenia dotyczące bezpieczeństwa zgodnie z instrukcją w <code>SECURITY.md</code> są mile widziane. O każdym kolejnym kamieniu milowym damy znać tutaj.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>Zaczynamy bloga: notatki z terenu</title>
      <link>https://aldo.tech/pl/blog/zaczynamy-bloga/</link>
      <guid isPermaLink="true">https://aldo.tech/pl/blog/zaczynamy-bloga/</guid>
      <pubDate>Sat, 17 Oct 2026 08:00:00 +0000</pubDate>
      <description>Dlaczego firma, która buduje terminale, aplikacje i elektronikę dla transportu publicznego, zaczyna pisać, i o czym tu przeczytasz.</description>
      <content:encoded><![CDATA[<p>Większości tego, co budujemy, nikt nie widzi. Oprogramowanie w terminalu przy drzwiach autobusu, firmware w urządzeniu, które nigdy nie dostanie ekranu, serwer, który nocą zamienia dzienne transakcje w raporty. Kiedy wszystko działa, działa po cichu. Kiedy nie działa, zauważają to wszyscy.</p>
<p>Na tym blogu zapisujemy to, czego nauczyliśmy się po drodze. Nie teksty marketingowe, lecz konkretne notatki: co poszło nie tak, jak to rozwiązaliśmy i co dziś zrobilibyśmy inaczej.</p>
<h2 id="o-czym-bedziemy-pisac">O czym będziemy pisać</h2>
<ul>
<li><strong>Pobieranie opłat w transporcie publicznym.</strong> Karty zbliżeniowe, moduły SAM, praca bez sieci i wszystko, co terminal musi znieść w pojeździe, który trzęsie się dwanaście godzin dziennie.</li>
<li><strong>Aplikacje mobilne w terenie.</strong> Android i Flutter na urządzeniach, które nie są zwykłymi telefonami: z czytnikami kart, drukarkami i bateriami, które muszą wytrzymać całą zmianę.</li>
<li><strong>Backend i monitoring floty.</strong> Skąd wiedzieć, że urządzenie w pojeździe ma problem, zanim zgłosi go kierowca.</li>
<li><strong>Sprzęt.</strong> Od prototypu na biurku do płytki, która trafia do produkcji seryjnej, i wszystkie błędy po drodze.</li>
<li><strong>Lokalne modele AI.</strong> Gdzie modele na własnym sprzęcie mają sens, a gdzie chmura i API są po prostu lepszym wyborem.</li>
</ul>
<h2 id="jak-bedziemy-pisac">Jak będziemy pisać</h2>
<p>Krótko i konkretnie, z kodem i liczbami tam, gdzie je mamy. Będziemy publikować, kiedy mamy coś do powiedzenia, a nie według harmonogramu. Wpisy piszemy po chorwacku i tłumaczymy na wszystkie języki tej strony; możesz nas też śledzić przez <a href="https://aldo.tech/pl/blog/feed.xml">RSS</a>.</p>
<p>Jeśli interesuje Cię jakiś temat albo pracujesz nad podobnym problemem, napisz do nas na <a href="mailto:info@aldo.tech">info@aldo.tech</a>.</p>]]></content:encoded>
      <category>wstep</category>
    </item>
  </channel>
</rss>
