<?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>ALDO TECH blog</title>
    <link>https://aldo.tech/en/blog/</link>
    <atom:link href="https://aldo.tech/en/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Notes from the field: public transport, fare collection, hardware and software that has to work on the road.</description>
    <language>en</language>
    <item>
      <title>urMQTT after the VPS: SQLite in user mode, a comparison with Mosquitto, configuration as data and a builder in the browser</title>
      <link>https://aldo.tech/en/blog/urmqtt-config-builder-plaintext-mosquitto/</link>
      <guid isPermaLink="true">https://aldo.tech/en/blog/urmqtt-config-builder-plaintext-mosquitto/</guid>
      <pubDate>Sun, 01 Nov 2026 08:00:00 +0000</pubDate>
      <description>What happened after the second post about urMQTT: SQLite isolated outside the kernel, measurements against Mosquitto on the same VPS (including what did not go in our favour), the causes found for the slow single flow, configuration as data with a web ISO builder and an emulator in the browser, optional plain MQTT and what is still not proven.</description>
      <content:encoded><![CDATA[<p>The second post, <a href="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/">urMQTT on a real VPS</a>, ended with a "What comes next" list: H6 (SQLite outside the kernel), H7 (time security), X11 (a comparison with Mosquitto on the same VPS) and the first release. This post follows that list in the same order and then adds two things that were not on it, because they appeared along the way: configuration as data, with a web ISO builder, and optional plain MQTT without TLS. As before: what we planned, what we built, what surprised us and what is open. This post also contains several results that do not favour urMQTT; we kept them because they are part of the truth.</p>
<p>The note up front stays the same: the project is pre-alpha, the first release is only for VPSs with KVM, and the numbers measure code, tests and specific experiments, not maturity in production.</p>
<h2 id="what-it-looked-like-over-time">What it looked like over time</h2>
<p>From the previous post (31.10. at 23:00) to today (1.11. at 20:41) the repository has 59 new commits, 178 in total. All of them were made within a single calendar day, so the same warning applies as before: the project is built with Claude Code (commits carry <code>Co-Authored-By: Claude</code>), the plan, code, tests and ADRs are produced in short closed loops, and the numbers below say something about the amount of code and checks, not about how much human work is in them.</p>
<figure><img loading="lazy" alt="Growth since the previous post: Rust lines from 89,790 to 112,408, #[test] functions from 739 to 926, ADRs from 104 to 126 and commits from 119 to 178. Each row has its own scale." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/growth-since.svg" /><figcaption>Growth since the previous post: Rust lines from 89,790 to 112,408, #[test] functions from 739 to 926, ADRs from 104 to 126 and commits from 119 to 178. Each row has its own scale.</figcaption></figure>
<p>The numbers were computed with the same method as in the previous post: all <code>.rs</code> files outside <code>vendor/</code> and <code>fuzz/</code>, <code>#[test]</code> functions, <code>## ADR-</code> headings in <code>docs/DECISIONS.md</code>.</p>
<table>
<thead>
<tr>
<th>Moment</th>
<th>Commit</th>
<th>Time</th>
<th style="text-align: right;">Rust lines</th>
<th style="text-align: right;">Tests</th>
<th style="text-align: right;">ADRs</th>
</tr>
</thead>
<tbody>
<tr>
<td>previous post</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 done</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>web builder and release mirror</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>plain MQTT, documentation</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>today</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>Work went on in parallel on several branches, so the order of commits is not strictly the order of work. ADRs are numbered up to 232, but the numbering has gaps (some were renamed when branches were merged); we count headings, not the highest number. Releases: v0.2.0 (1.11., 18:45 UTC) and v0.2.1 (1.11., 20:22 UTC), both marked as pre-release.</p>
<h2 id="h6-sqlite-in-user-mode">H6: SQLite in user mode</h2>
<p><strong>What we planned.</strong> SQLite is 254,268 lines of C that parse the database, the WAL and the journal from disk, and the disk may be crafted or modified. Until then it ran in the kernel, with kernel privileges. <strong>What we built</strong> (ADR-210 to ADR-212): SQLite, its libc parts and its heap were moved into a separate program, <code>dbworker</code>, which runs at EL0 (aarch64) or ring 3 (x86_64), in its own address space. The kernel no longer links SQLite. The worker talks to the kernel through a shared memory window and twelve system calls, and every length and offset that comes back is checked before use. A fault in the worker (illegal access, undefined instruction, an exceeded time budget of 30 s) ends the call, the worker is restarted and the database is opened from the WAL as at boot; after 5 restarts in a minute the storage is declared faulty rather than restarted endlessly.</p>
<p>The cost, measured on aarch64 under HVF, the same VM, a kernel with built-in SQLite versus a kernel with the worker, six alternating runs of each:</p>
<table>
<thead>
<tr>
<th>Measurement</th>
<th>Before (in kernel)</th>
<th>After (worker)</th>
<th>Difference</th>
</tr>
</thead>
<tbody>
<tr>
<td>entering and leaving user mode</td>
<td>-</td>
<td>1.1 us per round trip</td>
<td></td>
</tr>
<tr>
<td><code>db-bench</code>, 5,000 inserts in 50 transactions, best / median</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>one insert per transaction, best / median</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>whole backup in one call (23 MiB)</td>
<td>514 ms</td>
<td>635 ms</td>
<td>+23 %</td>
</tr>
</tbody>
</table>
<p>Disk writes (1,572) and flushes (319) are identical, as they must be. What is not isolated: <strong>the kernel still trusts what the worker returns</strong>, after checking its shape. A compromised worker can lie (for example, return an empty list of revoked certificates and thereby "un-revoke" them), but it cannot read kernel memory, TLS keys or ticket keys. There is no KPTI, so Spectre/Meltdown-type leaks are not prevented and we claim nothing about them; the PAC key is shared by EL0 and EL1. On x86_64 we did not measure the switching cost on KVM (under TCG the number would be meaningless), and fuzzing of the worker and the protocol itself is not finished. The storage test suite (79 scenarios, each of which runs SQLite in the worker) passes on aarch64 HVF and x86_64 TCG, and 76 on aarch64 TCG (one is skipped because TCG has no MSI-X).</p>
<p><strong>H7 is closed, but not the way we announced.</strong> The lower bound on time (ADR-050) and SNTP with consensus (T1) cover the scope of H7; NTS (RFC 8915) remains out of scope and is recorded as a residual risk, not as something that works. We do not consider time security "solved", only its provable part done.</p>
<p><strong>A new network option.</strong> Contabo does not give DHCP to a custom ISO, so until now the VPS needed a different image from the lab VM. Now <code>network.mode = "auto"</code> (ADR-214) asks DHCP first, and if there is no lease within the set time (<code>dhcp_timeout_s</code>, default 15 s) or it loses the lease, it applies the configured static address. Tried manually on QEMU (with DHCP it uses the lease, without a network it falls back to the static address after 5 s); there is no smoke scenario for it yet.</p>
<h2 id="x11-a-comparison-with-mosquitto-on-the-same-vps">X11: a comparison with Mosquitto on the same VPS</h2>
<p>The same kind of machine as in the previous post (Contabo, 4 vCPU AMD EPYC, 8 GiB), load from a Linux host 15 ms away, mutual TLS 1.3 with the same certificates on both sides, Mosquitto 2.0.21 on Debian 13 without persistence. The method and decisions are in ADR-213, the raw data in <code>docs/perf-results/</code>. Median of three runs.</p>
<figure><img loading="lazy" alt="Full handshakes per second (TLS 1.3 + client certificate + CONNECT, no session resumption) at 32, 64 and 128 clients: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. These are lower bounds: the load generator was close to its own limit." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/mosquitto.svg" /><figcaption>Full handshakes per second (TLS 1.3 + client certificate + CONNECT, no session resumption) at 32, 64 and 128 clients: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. These are lower bounds: the load generator was close to its own limit.</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>urMQTT</th>
<th>Mosquitto 2.0.21</th>
</tr>
</thead>
<tbody>
<tr>
<td>handshakes/s, 32 / 64 / 128 clients (bench profile)</td>
<td>169 / 278 / 413</td>
<td>82 / 140 / 184</td>
</tr>
<tr>
<td>same, with the limits as shipped</td>
<td>185 / 262 / 333 (at 128: 202 to 286 errors, handshake resets)</td>
<td>-</td>
</tr>
<tr>
<td>memory per idle connection (20,000 connections)</td>
<td>0.50 KB of kernel heap (0.66 KB at 5,000)</td>
<td>26.8 KB of process growth</td>
</tr>
<tr>
<td>latency p50, QoS 0 / QoS 1, 800 messages/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 messages/s, delivered/s (p50)</td>
<td>4,209 (77 ms)</td>
<td>4,235 (197 ms)</td>
</tr>
<tr>
<td>100 subscribers, 50 messages/s: p50</td>
<td>16.6 ms</td>
<td>99 ms</td>
</tr>
<tr>
<td>single flow, QoS 0, 64 B, published/s</td>
<td>2,962 (0 to 7,733)</td>
<td>22,749</td>
</tr>
</tbody>
</table>
<p>What this means and what it does not. The handshakes are <strong>lower bounds for both servers</strong>: the load generator is a shared VM with 60 to 70 % stolen CPU time and at 128 threads it was close to its own limit, while urMQTT's worker cores were busy only 12 to 13 %. The ratio at 128 clients (about 2.2 times) is the most meaningful; at 32 and 64 it mostly measures clients waiting. The memory numbers do not count the same thing (kernel heap versus process RSS), so "about 50 times less" is an order of magnitude, not a precise ratio. A latency of 14 ms is one network round trip (about 15 ms), so "no measurable broker time"; the tails (p99 around 100 to 200 ms) are dominated by the shared hosts for both brokers. We do not claim differences below about 20 %.</p>
<p><strong>What did not go in our favour</strong> and stayed in the tables: a single TCP flow through urMQTT carried 2 to 8 times less than through Mosquitto, with swings from zero to 7.7 thousand messages per second between runs, and the 20 ms handler watchdog <strong>cut live connections</strong> when the hypervisor stopped a vCPU (in the production setup <code>action = "drop"</code>, 6 to 8 connections per run under QoS 0 flooding). Both are described in the next section because they had causes in our code.</p>
<h2 id="why-the-single-flow-was-slow-causes-not-excuses">Why the single flow was slow: causes, not excuses</h2>
<p>The easiest thing would have been to say "shared VPS". We did not; with the same VPS and the same method we looked for causes. Five were found, four in the data path and one in the watchdog (ADR-221 to ADR-224):</p>
<table>
<thead>
<tr>
<th>Symptom in X11</th>
<th>Cause in urMQTT</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>single flow 1.9k messages/s, often <strong>0 delivered</strong></td>
<td><strong>Lost wakeup:</strong> the output loop returned the list of ready connections over the one just written; a subscriber whose output buffer had filled once was never served again until a PINGRESP woke it</td>
<td>fixed (ADR-223)</td>
</tr>
<tr>
<td>a few percent of loss turns 48k into 14k messages/s</td>
<td><strong>every segment after a lost one was dropped</strong> (in-order receive only), so recovery cost one network round trip per segment</td>
<td>out-of-order segment queue, SACK (ADR-222)</td>
</tr>
<tr>
<td>ceiling of 4.4 MB/s at 15 ms</td>
<td>16-bit window, a 64 KiB send queue and slow start that counted ACKs, not bytes</td>
<td>window scaling, timestamps, byte counting (ADR-222)</td>
</tr>
<tr>
<td>every round of a flow that fits waits 0 to 10 ms</td>
<td><strong>the dispatcher slept until the next 10 ms tick</strong> (virtio-net has no receive interrupt)</td>
<td>polling the NIC every 200 us while connections exist (ADR-224)</td>
</tr>
<tr>
<td>1 to 8 dropped connections per run</td>
<td><strong>the watchdog charged the handler for elapsed time</strong>, and the VPS stops vCPUs often and for long</td>
<td>the budget is the time the core actually ran (ADR-221)</td>
</tr>
</tbody>
</table>
<p>The stops are real: on this VPS cores 0 to 2 had a median of 36 to 80 late ticks per 5 seconds, and the worst stop in the window had a median of about 70 ms and went up to 0.9 s (core 3, database and journal: up to 1.55 s), while the host reports no stolen time (<code>host steal 0 us</code>). That is why the watchdog needed a second clock, not a larger budget.</p>
<figure><img loading="lazy" alt="Before and after, on the same VPS with the same method, median of three to six runs: single flow QoS 0, 64 B: published 1.9k, 23.7k (Mosquitto 29.3k), delivered 0 (median), 12.6k (29.2k). 16 pairs, flooding, 64 B: published 24.7k, 43.2k (31.4k), delivered 20.0k, 37.4k (30.7k)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/before-after.svg" /><figcaption>Before and after, on the same VPS with the same method, median of three to six runs: single flow QoS 0, 64 B: published 1.9k, 23.7k (Mosquitto 29.3k), delivered 0 (median), 12.6k (29.2k). 16 pairs, flooding, 64 B: published 24.7k, 43.2k (31.4k), delivered 20.0k, 37.4k (30.7k).</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>X11 code (7f9517e)</th>
<th>now (86a71ff)</th>
<th>Mosquitto</th>
</tr>
</thead>
<tbody>
<tr>
<td>single flow, QoS 0, 64 B, published / delivered per s</td>
<td>1.9k / 0</td>
<td>23.7k / 12.6k</td>
<td>29.3k / 29.2k</td>
</tr>
<tr>
<td>single flow, 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 pairs, flooding, 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 pairs, flooding, 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>lost connections per run (16 pairs, 64 B)</td>
<td>13 (7 to 16)</td>
<td>0 (0 to 0)</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Watchdog: 148 dropped connections in 30 loaded runs before, 0 in 24 runs after; on a local rig that delays and drops frames, at 0.1 % loss a single 64 B flow of 14.3k messages/s (the X11 kernel) now reaches 41.0k, and at 0.5 % from 5.0k to 17.7k.</p>
<p><strong>What this does not say.</strong> urMQTT <strong>is still not faster than Mosquitto on a single flow</strong>: at 64 B it publishes about 80 % of its speed and delivers about 40 %, because it drops QoS 0 when the subscriber's flow is slower than the publisher's (Mosquitto queues without a limit). At 1 KiB in the 16-pair flooding Mosquitto is ahead by about 15 %, which is within noise. The generator was again a shared VM with 58 to 77 % stolen time. The second round (heap locking moved out of the data path, send-side SACK and more) is on the unintegrated branch <code>perf-single-flow-wip</code>; its numbers are not claims yet and we do not give them here.</p>
<h2 id="a-promise-we-did-not-make-but-kept-configuration-is-data">A promise we did not make, but kept: configuration is data</h2>
<p>Until now every configuration was a new build: keys and limits were built into the image. C1 turns that around (ADR-215 to ADR-219): the kernel is built once, and the configuration is data.</p>
<ul>
<li><strong>The <code>urmqtt-cfg</code> blob</strong> (C1a): a versioned, canonical record (fields in ascending tag order, a SHA-256 fingerprint, an optional detached Ed25519 signature), 70 fields with defaults and bounds, and a decoder that rejects anything malformed.</li>
<li><strong>The kernel reads it as a Limine module</strong> (C1b), before anything else, on aarch64 and x86_64. There is <strong>one released kernel for every <code>ENV</code> except <code>dev</code></strong> (the aarch64 ELFs for <code>release</code>, <code>prod</code> and <code>bench</code> have the same SHA-256; verified only on aarch64). The released kernel rejects a development configuration, and without a configuration it prints "refusing to serve" and serves nothing.</li>
<li><strong>The installed disk carries the configuration</strong> and an update keeps it (C1c).</li>
<li><strong><code>urmqtt-config</code> and the ISO template</strong> (C1d): <code>pack</code>, <code>show</code>, <code>verify</code>, and <code>pack --iso</code> replaces both copies of the configuration slot and the 24 module digests in a finished ISO. The result is byte for byte the same as the ISO that <code>mkiso.sh --config</code> builds. The v0.2.0 release template is 63,111,168 bytes.</li>
<li><strong>The trust statement</strong> (ADR-219): a configured image prints the configuration fingerprint at boot (<code>config: fingerprint ...</code>), and the signature is optional and <strong>off by default</strong>. A modified ISO has no official digest; the fingerprint is the way to see what is in it.</li>
</ul>
<h2 id="the-builder-on-aldotech">The builder on aldo.tech</h2>
<p>What <code>urmqtt-config pack --iso</code> does, the web page does too, with the same code: the tool library compiled to WebAssembly (1.06 MB, no imports and no <code>unsafe</code> block). The page is at <a href="https://aldo.tech/en/products/urmqtt-builder/">aldo.tech/en/products/urmqtt-builder/</a> and exists in all 15 languages of the site (for example <a href="https://aldo.tech/proizvodi/urmqtt-builder/">hr</a>).</p>
<ul>
<li><strong>Nothing leaves the browser.</strong> Certificates, private keys and the finished ISO stay on your computer; the page downloads only the release files from this site, with no tracking and no third-party scripts. In the live test no request carried key material.</li>
<li><strong>The release mirror</strong> (ADR-227): hour by hour (a systemd timer, low priority) it copies releases from GitHub, checks each one before publishing, and the page in the browser verifies the <code>SHA256SUMS</code> signature with a key embedded in the page, and only then writes the configuration into the template. A failed check blocks the download.</li>
<li><strong>Simple by default, advanced when needed</strong> (ADR-232): the first screen is three steps (where it runs, your certificates, the image name). A single drop field sorts the certificate, key and CA by content. The advanced view opens each of the 70 fields, search, and TOML import and export; both views edit the same state. The template (63 MB) is downloaded while the user fills in the form. In a live trial, with a test mirror and a test key, building the ISO in the browser took 5 to 18 s.</li>
</ul>
<figure><img loading="lazy" alt="The simple view of the builder: three steps, &quot;Where will it run?&quot;, &quot;Your certificates&quot; and &quot;Image name&quot;, with a single next action in the sidebar." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-simple.png" /><figcaption>The simple view of the builder: three steps, &quot;Where will it run?&quot;, &quot;Your certificates&quot; and &quot;Image name&quot;, with a single next action in the sidebar.</figcaption></figure>
<figure><img loading="lazy" alt="The advanced view, captured on a test mirror with a test key (the release date and the signing key are not real): a release with a verified signature, templates, every configuration field." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-advanced.png" /><figcaption>The advanced view, captured on a test mirror with a test key (the release date and the signing key are not real): a release with a verified signature, templates, every configuration field.</figcaption></figure>
<p><strong>The emulator, optional.</strong> Next to "Download ISO" there is "Try in the emulator": QEMU compiled to WebAssembly runs the image you just built in a browser tab. Nothing of it is downloaded before the click (about 3 MB the first time). The emulator is BIOS-only, <strong>has no network</strong> (so that the user's traffic does not pass through some intermediary and break "nothing leaves the browser"), needs about 1.5 GB of memory and a desktop browser (Chrome tested; Firefox and Safari not). In it urMQTT reaches <code>URMQTT READY</code> in about 8 s (with the countdown skipped) and prints the same configuration fingerprint as the page, which is proof that exactly that configuration was booted. Boot is about 2 times slower than native QEMU on the same Mac (0.82 s against 1.9 s), and cryptography about 58 times slower because the hardware instructions are emulated. That is why <strong>we draw no claims about speed from the emulator</strong>, and the page says so before it starts. During the build four bytes in the <code>.wasm</code> had to be patched because of a bug in the translation of the POPCNT instruction in the upstream emulator (we have not reported it yet); the emulator itself is under the GPL, with an offer of the source code.</p>
<figure><img loading="lazy" alt="The Diagnostic entry in the emulator live on aldo.tech: the kernel printed the same configuration fingerprint as the page, and the screen shows diagnostics (in the emulator it finishes in about 20 s; its numbers describe the emulator, not urMQTT)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/emulator-diagnostic.png" /><figcaption>The Diagnostic entry in the emulator live on aldo.tech: the kernel printed the same configuration fingerprint as the page, and the screen shows diagnostics (in the emulator it finishes in about 20 s; its numbers describe the emulator, not urMQTT).</figcaption></figure>
<h2 id="plain-mqtt-only-for-networks-you-trust">Plain MQTT, only for networks you trust</h2>
<p>Plain MQTT (TCP 1883, no TLS) is very common in IoT: it is the default in Mosquitto, in most Arduino and ESP firmware, in Tasmota, Zigbee2MQTT and Home Assistant, and it is used inside segmented industrial networks, behind VPNs and on private LANs, including on devices without a clock (certificates need a date), without enough flash or CPU for TLS. Over an untrusted network it is not acceptable: whoever is on the path reads and modifies the traffic. That is why P1 (ADR-229 to ADR-231) is an explicit, limited choice, <strong>off by default</strong>, and the TLS port stays; TLS is not turned off by this, the plain port comes alongside it.</p>
<ul>
<li><strong>An allowlist of networks is mandatory</strong> (<code>allow_from</code>, CIDRs) and is checked at the SYN: outside the list there is no SYN-ACK and no state. The whole internet (<code>0.0.0.0/0</code>) requires a separate explicit option.</li>
<li><strong>Three login modes:</strong> password (the default when enabled; PBKDF2-HMAC-SHA-256, salt, 10,000 iterations, constant-time comparison, lockout per address), anonymous (one configured identity with its own ACL) and by address (<code>ip</code>). The builder hashes the password in the browser and keeps only the digest in memory.</li>
<li><strong>Fewer privileges than mTLS:</strong> a plain client never has service, operator or management privileges, has no <code>$SYS</code> and no console, has its own sessions (<code>$plain/&lt;identity&gt;/&lt;client id&gt;</code>, clean only), and its own limits and the registry of the allowed still bind it.</li>
<li><strong>What is lost is written in <code>SECURITY.md</code>:</strong> confidentiality, integrity, broker authenticity and replay of a recorded login. This cannot be fixed by configuration; that is how it is without TLS.</li>
</ul>
<p>Measured on a laptop (Apple silicon, HVF, QEMU user-mode network), 16 pairs, 64 B, three runs of 10 s: at 8,000 messages per second the TLS and plain paths deliver all messages with the same latency (p50 0.17 ms against 0.13 to 0.18 ms), new connections per second: TLS about 2,600 to 2,750, anonymous plain about 4,900 to 7,800, and <strong>password login is the expensive part</strong>: one PBKDF2 costs about 2.5 ms, so the number of new logins is limited to about 200 per data core per second (308 to 342 measured on two cores). Flooding on that machine is not a stable instrument (20k to 440k delivered per second between runs) and <strong>we draw no conclusion from it</strong>. The comparison of the plain path with Mosquitto on a VPS has not been done yet. Tests: the <code>plaintext</code> smoke group 7 of 7 on aarch64 HVF and on x86_64 TCG, a new fuzz target <code>plain_input</code> (2.3 million runs with no findings).</p>
<h2 id="bugs-found-by-live-use">Bugs found by live use</h2>
<p>Two things were not caught by tests, but by the first switch-on at the real site:</p>
<ul>
<li>The page looked for the release files <strong>one directory too high</strong>, so every signature check answered 404 (fix 13af301). The test used a test mirror at a different path.</li>
<li><strong>The emulator keyboard never reached the guest</strong>: the cause was not a bad translation of keys, but that the dialog did not listen for keys at all (fix in ADR-232, with a boot entry that needs no keys).</li>
</ul>
<h2 id="lessons">Lessons</h2>
<ul>
<li><strong>Look for the cause, do not blame the environment.</strong> Most of the single-flow weakness and all of the dropped connections were ours; the hypervisor is real, but it had to be measured, not assumed.</li>
<li><strong>A comparison with another product must include the losses.</strong> A table with only our wins would not be a comparison.</li>
<li><strong>The measuring instrument has limits.</strong> A shared generator with 60 to 70 % stolen time changes what the numbers mean; that is why we write "lower bound" and do not claim differences below 20 %.</li>
<li><strong>Isolation is not proof.</strong> SQLite at EL0 reduces what a fault in it can do, not what it can lie about.</li>
<li><strong>A real installation finds what tests do not ask for</strong>: a wrongly assembled path and keys that nobody listens to.</li>
</ul>
<h2 id="what-is-still-not-proven">What is still not proven</h2>
<ul>
<li>The release signing key is for now a test key; v0.2.0 and v0.2.1 are pre-releases, and the images that come with them are development images with test certificates (except the template, which has no configuration).</li>
<li>The 13 translations of the builder page (and of this post) were written by AI and checked by tests and spot checks, not by native speakers.</li>
<li>The comparison with Mosquitto is one session, one path and one shared generator, on a shared VPS; differences below 20 % are not claims.</li>
<li>The plain path has not been measured on a VPS or against Mosquitto.</li>
<li>Lighthouse on the new simple view is not confirmed (old view: accessibility 95 or more).</li>
<li>Bare metal, the x86_64 worker cost on KVM, Firefox and Safari for the emulator: not measured.</li>
</ul>
<h2 id="what-comes-next">What comes next</h2>
<ul>
<li><strong>D1: a public demo broker.</strong> We cannot make the browser emulator public: it lives in one visitor's tab, is 20 to 100 times slower than KVM, has no inbound network and disappears when the tab is closed; routing the internet into someone else's browser would also be abuse. A real demo broker is possible: urMQTT on a VPS behind a name on aldo.tech, with a published test CA, a strict ACL and small limits. For now this is only an idea, not a build, and it needs explicit consent for public exposure.</li>
<li><strong>The second round for the single flow</strong> (branch <code>perf-single-flow-wip</code>): only once it is measured on a VPS and merged.</li>
<li><strong>The plain path against Mosquitto on a VPS</strong>, login and account revocation for plain MQTT over the signed management channel.</li>
<li><strong>After the release:</strong> bare metal (X12 to X16), IOMMU and Secure Boot, disk encryption.</li>
</ul>
<p>The code is at <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, and <code>docs/PROGRESS.md</code> is still the only source of truth about what actually works, <code>docs/PERFORMANCE.md</code> about what has been measured. Pre-alpha, as before; bug reports and security reports following the instructions in <code>SECURITY.md</code> are welcome.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT on a real VPS: the ISO, a 0.24 ms handshake, our own disk and a hardened kernel</title>
      <link>https://aldo.tech/en/blog/urmqtt-vps-handshake-disk-hardening/</link>
      <guid isPermaLink="true">https://aldo.tech/en/blog/urmqtt-vps-handshake-disk-hardening/</guid>
      <pubDate>Sat, 31 Oct 2026 08:00:00 +0000</pubDate>
      <description>What happened to the three promises from the first urMQTT post (a single ISO, a raw disk without a filesystem, provably secure) after the first four days of development, with measurements, tables and a list of what is still not done.</description>
      <content:encoded><![CDATA[<p>In the <a href="https://aldo.tech/blog/urmqtt-unikernel-mqtt-broker/">first post about urMQTT</a> we described a unikernel that <em>is</em> an MQTT broker, up to milestone M17, and we ended with three promises under the heading "What comes next": a single ISO that boots on multiple platforms, a raw disk without a filesystem, and security that can be proven rather than merely claimed. None of that had been built at the time. This post follows what happened to each promise, in the same order as before: what we planned, what we built, what surprised us and what is still open. Along the way we will also show how long it took, because that is part of the story.</p>
<p>One note up front: the project is still pre-alpha. The numbers in this post measure code and tests, not maturity in production, and the first release is deliberately limited to VPSs with KVM. Bare metal is left for later.</p>
<h2 id="what-it-looked-like-over-time">What it looked like over time</h2>
<p>The first commit was made on 28 October 2026 at 23:48 (M0). By 31 October at 23:00 the repository has 119 commits, distributed as 3, 39, 38 and 39 per calendar day. Let us say right away how that is possible: the project is built with Claude Code, and the commits carry <code>Co-Authored-By: Claude</code> trailers. Four calendar days of history do not mean that one person wrote 90,000 lines in four days; they mean that planning, implementation, tests and ADRs are written in short, closed loops, and every step ends with the same verification ritual as before. The numbers below describe the amount of code and tests, not how much of it has been exercised in real-world work.</p>
<figure><img loading="lazy" alt="Project growth over four days with milestones from M0 to today: thousands of lines of Rust (from 1.6 to 89.8), the number of #[test] functions (from 27 to 739) and the number of ADRs (from 13 to 104), along with bars for commits per day (39, 38 and 39 for 29, 30 and 31 October)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/growth.svg" /><figcaption>Project growth over four days with milestones from M0 to today: thousands of lines of Rust (from 1.6 to 89.8), the number of #[test] functions (from 27 to 739) and the number of ADRs (from 13 to 104), along with bars for commits per day (39, 38 and 39 for 29, 30 and 31 October).</figcaption></figure>
<p>The graph shows three curves that grow almost together, which is the intention: tests and recorded decisions keep pace with the code instead of lagging behind it. The slope is steepest on 29 October, when the whole path from the kernel to the broker comes into being. After that, lines are added more slowly, but tests grow faster than code (27 tests on 1.6 thousand lines at the start, 739 on 89.8 thousand today).</p>
<table>
<thead>
<tr>
<th>Milestone</th>
<th>Commit</th>
<th>Time</th>
<th style="text-align: right;">Rust lines</th>
<th style="text-align: right;">Tests</th>
<th style="text-align: right;">ADRs</th>
</tr>
</thead>
<tbody>
<tr>
<td>M0</td>
<td>9412a9c</td>
<td>28 Oct 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 Oct 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 Oct 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 Oct 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 Oct 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 Oct 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 Oct 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 Oct 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 Oct 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>today</td>
<td>HEAD</td>
<td>31 Oct 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>Lines are all <code>.rs</code> files outside <code>vendor/</code> and <code>fuzz/</code>, and tests are <code>#[test]</code> functions. What each part added, by date from the git history:</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Milestones</th>
<th>What was added</th>
</tr>
</thead>
<tbody>
<tr>
<td>29 Oct</td>
<td>M0 to M17</td>
<td>kernel, networking, TLS, broker, SQLite, multicore, our own TCP, hardening, reproducible release</td>
</tr>
<tr>
<td>29 Oct evening and 30 Oct</td>
<td>X0 to X10, S1, H2, H3 (X0 starts on 29 Oct at 20:54)</td>
<td>x86_64 port, universal ISO, a single disk and a self-installing ISO, TLS checks, attacker clients</td>
</tr>
<tr>
<td>31 Oct</td>
<td>O1 to O4, T1, PERF, H4, H5, H9, S2 to S6</td>
<td>insight and a console over MQTT, live mode and diagnostics, SNTP, speedups, hardening, verification, supply chain, storage</td>
</tr>
</tbody>
</table>
<h2 id="promise-1-one-iso-for-multiple-platforms">Promise 1: one ISO for multiple platforms</h2>
<p><strong>What we planned.</strong> A single file that boots on aarch64 UEFI, x86_64 UEFI and x86_64 BIOS. <strong>What we built.</strong> The x86_64 port (X0 to X10) and a universal ISO with the Limine bootloader: a single hybrid ISO that boots in all of those combinations; the smoke tests boot it on four virtual machines. The menu has three entries: urMQTT (live mode, runs from RAM, writes nothing to disk), install to the disk of the machine it was started on (the installer writes a GPT and the data, then powers the machine off) and Diagnostics (hardware detection and measurements).</p>
<figure><img loading="lazy" alt="The ISO boot menu captured on the VPS: the entries &quot;urMQTT&quot;, &quot;Install urMQTT on this machine's disk&quot; and &quot;Diagnostic: hardware detection and benchmarks&quot; with a countdown to automatic boot. The screenshot is from a test ISO with a longer countdown; the default is 10 seconds." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/boot-menu.png" /><figcaption>The ISO boot menu captured on the VPS: the entries &quot;urMQTT&quot;, &quot;Install urMQTT on this machine's disk&quot; and &quot;Diagnostic: hardware detection and benchmarks&quot; with a countdown to automatic boot. The screenshot is from a test ISO with a longer countdown; the default is 10 seconds.</figcaption></figure>
<p>The first real target was a VPS rather than a physical computer: the decision is that the first release supports only VPSs (KVM), with bare metal coming after it. We tested on a Contabo VPS with an AMD EPYC processor (family 23, Zen 1), 4 vCPUs and 8 GiB of RAM, with a virtio-net network and a virtio-scsi disk. The first attempt surprised us: the ISO attached as a virtual CD booted the default entry, the production image refused to serve from read-only media and reset, and we got an endless loop of menu, kernel, reset with nothing legible. Today a fatal error in the production image leaves a message on the console for 60 seconds before the reset, and from optical media it prints that it was booted from read-only media and stops. Live mode in RAM (O3) later worked around that problem for the usual case.</p>
<p>The Diagnostics entry grew out of that experience: a machine we cannot see must be something we can ask what it has. It prints the CPU, RAM, network, disks and PCI devices, measures memory throughput, disk and cryptography, and prints the same measurements as JSON on the serial port. Writing to disk is allowed only in unallocated space and only after you type <code>WIPE</code>; in the screenshot below that was skipped.</p>
<figure><img loading="lazy" alt="The Diagnostics screen on a Contabo VPS (AMD EPYC, KVM, 4 vCPUs): system, memory, disk, PCI devices, RAM throughput and latency and crypto measurements, including 244 us per mTLS handshake, X25519 48 us, ECDSA signing 35.7 us and verification 107 us." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/diag-vps.png" /><figcaption>The Diagnostics screen on a Contabo VPS (AMD EPYC, KVM, 4 vCPUs): system, memory, disk, PCI devices, RAM throughput and latency and crypto measurements, including 244 us per mTLS handshake, X25519 48 us, ECDSA signing 35.7 us and verification 107 us.</figcaption></figure>
<p><strong>What is still open.</strong> Real hardware. After the first release comes bare metal (USB boot, NVMe and AHCI, the network card of a real computer), together with an IOMMU and Secure Boot, because without an IOMMU a malicious or faulty device can write over all of memory. In this version we claim nothing about that.</p>
<h2 id="the-handshake-from-12-ms-to-024-ms">The handshake: from 1.2 ms to 0.24 ms</h2>
<p>The first diagnostics measurement on the VPS gave an awkward number: the mTLS handshake, server-side cryptography only, took 1.2 ms. On aarch64 we were already using hardware AES and SHA instructions, but x86_64 had nothing like that for public-key operations: RustCrypto is portable and constant-time, and therefore slow as well. Three changes brought it down.</p>
<ul>
<li>For P-256 we took over the <code>nistz256</code> assembly from OpenSSL and ring (ADR-130), with fixed-base tables for signing and for verifying the CA signature (ADR-110, ADR-111).</li>
<li>X25519 was rewritten to four 64-bit limbs with MULX/ADX instructions (ADR-131).</li>
<li>After that we profiled the rest (ADR-132, ADR-200).</li>
</ul>
<figure><img loading="lazy" alt="The handshake on the VPS: 1200 us before, 237 us after the assembly and tables; an A/B comparison of memcpy in the same run, 278.8 us with the old one, 229.4 us with the new one; and before and after for X25519 (146 to 48 us), ECDSA signing (673 to 34 us) and verification (309 to 90 us)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/handshake.svg" /><figcaption>The handshake on the VPS: 1200 us before, 237 us after the assembly and tables; an A/B comparison of memcpy in the same run, 278.8 us with the old one, 229.4 us with the new one; and before and after for X25519 (146 to 48 us), ECDSA signing (673 to 34 us) and verification (309 to 90 us).</figcaption></figure>
<table>
<thead>
<tr>
<th>Measurement on the VPS (server, microseconds)</th>
<th style="text-align: right;">Before</th>
<th style="text-align: right;">After</th>
</tr>
</thead>
<tbody>
<tr>
<td>mTLS handshake, all cryptography</td>
<td style="text-align: right;">1200</td>
<td style="text-align: right;">about 240 (237 in the graph measurement, 244 in the screenshot)</td>
</tr>
<tr>
<td>X25519</td>
<td style="text-align: right;">146</td>
<td style="text-align: right;">48</td>
</tr>
<tr>
<td>ECDSA signing</td>
<td style="text-align: right;">673</td>
<td style="text-align: right;">34</td>
</tr>
<tr>
<td>ECDSA verification</td>
<td style="text-align: right;">309</td>
<td style="text-align: right;">90 (107 in the screenshot)</td>
</tr>
<tr>
<td>handshake, old memcpy, same boot</td>
<td style="text-align: right;">278.8</td>
<td style="text-align: right;">229.4 (-17.7%)</td>
</tr>
</tbody>
</table>
<p>The numbers vary by about 10% between runs, so we recorded a range rather than a single value. Overall that is roughly five times less CPU time per handshake.</p>
<p>What we learned about memcpy. Once the cryptography dropped, the profile showed something we did not expect: the <code>mem*</code> functions (memcpy, memmove, memset) took 7% of the handshake, 13 to 39% of the TLS record path (39% for 64 B, 13% for 4 KiB) and 17 to 34% of PUBLISH fan-out. We replaced them with SSE2 assembly for x86_64 (ADR-200), with differential tests against the reference implementation for all sizes, alignments and overlaps. Per call, that is 55 to 75 cycles versus 7 to 12 for 1 to 64 bytes.</p>
<figure><img loading="lazy" alt="Left: the share of server time spent in mem* functions before the rewrite (handshake 7%, TLS record 64 B 39%, 256 B 37%, 1 KiB 24%, fan-out 17 to 34%). Right: how much faster each path is afterwards (handshake 17.7%, 64 B 26.4%, 256 B 19.6%, 1 KiB 16.1%, 4 KiB 6.6%, fan-out 7 to 34%)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/hotpaths.svg" /><figcaption>Left: the share of server time spent in mem* functions before the rewrite (handshake 7%, TLS record 64 B 39%, 256 B 37%, 1 KiB 24%, fan-out 17 to 34%). Right: how much faster each path is afterwards (handshake 17.7%, 64 B 26.4%, 256 B 19.6%, 1 KiB 16.1%, 4 KiB 6.6%, fan-out 7 to 34%).</figcaption></figure>
<table>
<thead>
<tr>
<th>Path</th>
<th style="text-align: right;">mem* share before</th>
<th style="text-align: right;">Faster after</th>
</tr>
</thead>
<tbody>
<tr>
<td>handshake</td>
<td style="text-align: right;">7%</td>
<td style="text-align: right;">17.7%</td>
</tr>
<tr>
<td>TLS record 64 B</td>
<td style="text-align: right;">39%</td>
<td style="text-align: right;">26.4%</td>
</tr>
<tr>
<td>TLS record 256 B</td>
<td style="text-align: right;">37%</td>
<td style="text-align: right;">19.6%</td>
</tr>
<tr>
<td>TLS record 1 KiB</td>
<td style="text-align: right;">24%</td>
<td style="text-align: right;">16.1%</td>
</tr>
<tr>
<td>TLS record 4 KiB</td>
<td style="text-align: right;">13%</td>
<td style="text-align: right;">6.6%</td>
</tr>
<tr>
<td>message fan-out</td>
<td style="text-align: right;">17 to 34%</td>
<td style="text-align: right;">7 to 34%</td>
</tr>
</tbody>
</table>
<p>Reading the graph: the smaller the record, the larger the share of time that goes to copying, and so the larger the gain. For small messages, which make up most MQTT traffic, that is what counts.</p>
<p>What this does not mean. On native x86_64 we measured OpenSSL 3.5.5 only as a reference for the assembly (ADR-130), not as a product comparison. The comparison of Linux plus Mosquitto on the same VPS (task X11) has not been done yet. So we do not claim that urMQTT is faster than Linux: it has not been measured. The numbers above compare urMQTT with itself.</p>
<p>Overload was the second result. On the VPS, from a single Mac (RTT 35 ms), 128 clients produced 618 handshakes per second, 256 clients 393, and 512 clients 67, with thousands of rejected connections. That was not a plateau but a collapse. We found the causes in the code (two were confirmed in QEMU/HVF and could not be reproduced on the VPS), several at once: a rejected connection sent the client over the next segment carrying a valid SYN cookie, the rejection told the client by RST to retry immediately, and receive was O(queue). The fix (ADR-080 to ADR-082) drops a SYN that would not be accepted, remembers rejected flows, keeps receive at O(1) and counts handshakes per core. It is possible (a hypothesis, not verified) that part of the cause was on the client as well: the load tool closed first, so a single Mac with 16,384 ephemeral ports and a TIME_WAIT of 30 seconds is limited to about 550 connections per second. That is why we have not yet measured the server's true ceiling.</p>
<h2 id="promise-2-a-raw-disk-but-a-careful-one">Promise 2: a raw disk, but a careful one</h2>
<p><strong>What we planned.</strong> To keep our own layout without a filesystem and add everything a filesystem normally provides: alignment, growth, discard, backups and checks. <strong>Why not a filesystem.</strong> We debated that and the decision was confirmed: urMQTT writes one database, one telemetry log and its own metadata, and needs no directories, permissions or file names, and every additional layer would be more code in the trusted part and one more way for a write to silently disappear. The disk is a GPT with an ESP partition and one urMQTT data partition, on which sits our own layout.</p>
<p><strong>What we built (S2 to S6).</strong></p>
<ul>
<li><strong>Layout v2 (S2).</strong> Disk topology, alignment to 1 MiB or the optimal I/O size, whole physical blocks, an A/B pair of superblocks with a generation (ADR-140). The kernel rejects layout v1 with a clear message, and the offline tool <code>urmqtt-disk migrate</code> migrates it (ADR-141): a dry run is the default, it copies and only then switches over, with rollback and fault injection on every write.</li>
<li><strong>Growth (S3).</strong> At boot the disk can grow (it never shrinks), and on virtio-blk also at runtime, through the configuration-change interrupt (ADR-142, ADR-143).</li>
<li><strong>Discard and write-zeroes (S4).</strong> Thin-provisioned disks give space back to the host. In the test, a qcow2 file on the host dropped from 62 to 16 MiB after the telemetry segments expired, and recovery never trusts the contents of a discarded range.</li>
<li><strong>On-disk database backup (S5).</strong> An A/B copy using the SQLite online backup API in steps of 16 pages: the longest step is 4.6 ms versus 445 ms when copying in a single call. Commits during the copy had a maximum of 2.4 ms versus 2.2 ms without it. If <code>quick_check</code> fails at boot, the database is restored from the copy.</li>
<li><strong>Scrubbing and bounded boot (S6).</strong> The scrubber reads the disk within a bounded I/O budget and reports corrupted records, and boot reads only the newest segment of each partition, so boot time does not grow with the size of the telemetry region. That closed a problem we had calculated in advance: on a large disk, full recovery would take anywhere from minutes to tens of minutes.</li>
</ul>
<p>The whole group of storage smoke tests passes on both architectures: 79 passed, 0 failed, on aarch64 under HVF and on x86_64 under TCG. We also measured that S4 did not slow the database down: 53.3 thousand inserts per second in batches before and after, with no measurable difference.</p>
<p><strong>What surprised us.</strong> How much of a "simple" layout comes down to edge cases: virtio-scsi and virtio-blk report topology in different ways, and a partial write in the middle of a migration must always leave at least one valid version. That is why every write on the migration and growth path went through fault injection.</p>
<p><strong>What is still open.</strong> SQLite pages have no checksum, so a flipped bit inside a value in a row will not be detected by the database; telemetry records and the contents of the copies have a CRC and are detected. Disk encryption (S7) is not done.</p>
<h2 id="promise-3-provably-secure-as-far-as-we-can-manage">Promise 3: provably secure, as far as we can manage</h2>
<p><strong>Hardening (H4).</strong> The allocator wipes freed memory and checks it on reuse, detects double frees, links free-list pointers with XOR and zeroes keys when they are freed (ADR-170). Stack protection (<code>-Zstack-protector=strong</code>, 1315 functions, and SQLite with <code>-fstack-protector-strong</code>, ADR-171). Error reports in production contain no registers or addresses (ADR-172). KASLR on both architectures: the kernel is a PIE and Limine shifts it by about 19 bits, and the heap, stacks and MMIO window are placed randomly (ADR-173). On aarch64, MTE tags the heap (ADR-174), but only under TCG: HVF and x86 do not have it. We evaluated kCFI and did not adopt it because the compiler on the pinned nightly crashes with an internal error; the IOMMU is deferred along with bare metal (ADR-175, ADR-176).</p>
<p><strong>The cost is measured, not assumed.</strong> On the handshake path (<code>dp-bench</code>, aarch64 HVF, full TLS 1.3 + mTLS + CONNECT handshakes):</p>
<table>
<thead>
<tr>
<th>Step</th>
<th style="text-align: right;">1 core</th>
<th style="text-align: right;">4 cores</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>no hardening</td>
<td style="text-align: right;">1941</td>
<td style="text-align: right;">7620</td>
<td></td>
</tr>
<tr>
<td>+ allocator</td>
<td style="text-align: right;">1898</td>
<td style="text-align: right;">7429</td>
<td>-2.2% to -2.5%</td>
</tr>
<tr>
<td>+ stack protection</td>
<td style="text-align: right;">1845</td>
<td style="text-align: right;">7243</td>
<td>together -4.9% to -5%</td>
</tr>
<tr>
<td>+ PIE and tags</td>
<td style="text-align: right;">about 1800</td>
<td style="text-align: right;">about 6750</td>
<td>on a loaded machine, noisy</td>
</tr>
</tbody>
</table>
<p>Overall, then, the cost on the handshake path is about 5% for the parts measured cleanly, and with PIE and tags it comes to about 7% on one core and about 11% on four, though that measurement is noisy. We consider it acceptable, but we have not repeated it on an x86_64 VPS.</p>
<p>It is worth comparing with the figure from the first post (862 handshakes per second on one core). The same tool today, with hardware cryptography enabled on aarch64 (X0b) and a series of speedups that have arrived since, gives 1941 before hardening. We have not isolated the contribution of each change, so we claim nothing about the cause.</p>
<p><strong>How it was verified, and what was not.</strong> H5 added Miri on a subset of host crates, Kani proofs, sanitizers, a model fuzzer for the ACL and coverage reports. Kani proved <code>record_len</code> in TLS, <code>parse</code> in TCP and two timer-wheel arithmetics. <strong>Not proven</strong> (timed out): both MQTT decoders, both configuration parsers and the behavior of the timer wheel; the CI job for Kani therefore does not block yet. Miri cannot execute assembly. Supply chain (H9): a CycloneDX SBOM with 215 components, <code>cargo-vet</code> passes with 39 fully audited and 140 exemptions, and cargo-deny is configured to treat vulnerability advisories as a CI failure (those CI jobs have so far been verified only locally, not on GitHub, and the negative test has not been run), together with <code>VULNERABILITY-HANDLING.md</code>. The honest state: of 67 kernel-linked third-party crate versions, 53 are exemptions rather than audits. Vet proves that the set of dependencies cannot change without a visible difference, not that the TCB has been reviewed. We have not written our own audit of any crate.</p>
<p>SNTP (T1) is a small security story in that sense: it is optional, only toward private IPv4 servers, with a maximum step and the condition that the sources agree (median, majority within one second). A clock that anyone on the path can move is not a clock we may trust for certificate validity.</p>
<h2 id="on-the-mac-aarch64-natively-and-x86_64-by-emulation">On the Mac: aarch64 natively and x86_64 by emulation</h2>
<p>We develop on a Mac, which has two ways of running the same code, and they are very different.</p>
<table>
<thead>
<tr>
<th></th>
<th>aarch64 under HVF</th>
<th>x86_64 under TCG</th>
</tr>
</thead>
<tbody>
<tr>
<td>What it is</td>
<td>native execution, speed close to a real processor</td>
<td>QEMU emulates the instructions</td>
</tr>
<tr>
<td>Handshake in the guest</td>
<td>about 0.58 ms (first measured around M13); <code>dp-bench</code> today 1941/s per core</td>
<td>about 3.3 ms</td>
</tr>
<tr>
<td>TLS smoke scenarios</td>
<td>fast</td>
<td>about 15 minutes per scenario</td>
</tr>
<tr>
<td>MTE</td>
<td>no (not available on HVF)</td>
<td>x86 does not have it; heap tagging works only for an aarch64 guest under TCG</td>
</tr>
<tr>
<td>What it is for</td>
<td>real measurements</td>
<td>correctness: the x86 path is verified, the numbers are not performance</td>
</tr>
</tbody>
</table>
<p>The rule is simple: numbers under TCG are emulation and we do not cite them as performance. We measure x86 performance only on a real VPS. That is why the cryptography numbers above come from the Contabo VPS and not from the Mac.</p>
<h2 id="lessons">Lessons</h2>
<ul>
<li><strong>Measure on a real machine before optimizing.</strong> The first approach was to optimize what we thought was slow; only diagnostics on the VPS showed 1.2 ms and exactly where the time goes.</li>
<li><strong>Overload is a separate feature.</strong> A system that is fast under load can collapse above it; rejecting must be cheaper than accepting.</li>
<li><strong>The profile surprises.</strong> The biggest single gain for small messages was not in the cryptography but in memory copying.</li>
<li><strong>A disk without a filesystem takes more work, not less.</strong> What a filesystem normally provides (alignment, growth, discard, copies, checks) we have to write and verify ourselves, every step with fault injection.</li>
<li><strong>Honesty in numbers.</strong> Memory measurements on a shared VPS vary up to 10 times between runs and say nothing about the code, so we do not cite them. Crypto numbers are stable to about 10%.</li>
<li><strong>Development speed is not maturity.</strong> Tests and ADRs grow alongside the code, but four days is not real production.</li>
</ul>
<h2 id="what-comes-next">What comes next</h2>
<ul>
<li><strong>H6:</strong> SQLite isolated at EL0, so that a bug in the database does not open up the kernel.</li>
<li><strong>H7:</strong> the rest of time security (NTS and step handling); SNTP is only the first step.</li>
<li><strong>X11:</strong> a comparison with Linux and Mosquitto on the same VPS; only then does it make sense to talk about relative speed.</li>
<li><strong>Release:</strong> the first public release for VPSs (KVM), with signed artifacts and instructions.</li>
<li><strong>After the release:</strong> bare metal (X12 to X16), IOMMU and Secure Boot, and disk encryption.</li>
</ul>
<p>The code is at <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, and <code>docs/PROGRESS.md</code> is still the single source of truth about what actually works. Pre-alpha, as before; bug reports and security reports following the instructions in <code>SECURITY.md</code> are welcome.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT: building an MQTT broker that is also an operating system</title>
      <link>https://aldo.tech/en/blog/urmqtt-unikernel-mqtt-broker/</link>
      <guid isPermaLink="true">https://aldo.tech/en/blog/urmqtt-unikernel-mqtt-broker/</guid>
      <pubDate>Thu, 29 Oct 2026 08:00:00 +0000</pubDate>
      <description>Development notes on urMQTT, a unikernel written in Rust that is itself an MQTT broker: a single ELF with its own TCP/IP, TLS 1.3, broker and SQLite, booted from a single ISO.</description>
      <content:encoded><![CDATA[<p>Devices in vehicles, in parking lots and in electronics cabinets have one thing in common: they send small messages, rarely, but there are many of them and they are always connected. For years we have been connecting them to MQTT brokers, and for years the same question has bothered us. Why do we need a whole Linux, OpenSSL, a language runtime, SSH, a package manager and only then a broker for something this simple? Each of those layers has its own vulnerabilities, its own memory and its own moving parts, while a fleet of devices needs only three things from a server: to hold a great many mostly idle connections, to authenticate every device reliably, and to carry messages without loss.</p>
<p>That is how <a href="https://github.com/zeljan-alduk/urMQTT">urMQTT</a> came about: a unikernel that <em>is</em> an MQTT broker. The kernel, TCP/IP, TLS 1.3, MQTT and an embedded database in a single Rust binary booted from a single ISO. There are no processes, no system calls, no shell. The first platform it is built and tested on is aarch64, and the same code is heading for x86_64, by UEFI and classic BIOS, and for real hardware: the goal is one ISO that boots on all of them. The project is open source under the MIT or Apache 2.0 license, and in this post we want to record where we have got to, how it is put together inside, and what we learned along the way.</p>
<h2 id="what-unikernel-means-exactly">What "unikernel" means exactly</h2>
<p>A conventional broker is a program that runs on an operating system. urMQTT has no operating system beneath it: it boots itself through UEFI firmware and the Limine bootloader, sets up its own page tables, programs the interrupt controller and the timer, finds virtio devices over PCIe, obtains an address via DHCP and listens on a single port. Everything that would normally be done by the kernel, the drivers, the network stack and the application is here one ELF running at EL1.</p>
<p>A comparison with a typical installation speaks for itself:</p>
<table>
<thead>
<tr>
<th></th>
<th>Typical broker on Linux</th>
<th>urMQTT</th>
</tr>
</thead>
<tbody>
<tr>
<td>Exposed services</td>
<td>SSH, the broker, often a metrics or administration port as well</td>
<td><strong>TCP 8883 only</strong> (MQTT over TLS 1.3 with mTLS)</td>
</tr>
<tr>
<td>Code in the image</td>
<td>kernel, userland, libc, OpenSSL, runtime</td>
<td><strong>a single Rust ELF</strong> (plus vendored SQLite)</td>
</tr>
<tr>
<td>Remote management</td>
<td>shell, configuration files, package updates</td>
<td><strong>none.</strong> A new version is a new ISO</td>
</tr>
<tr>
<td>Memory per idle connection</td>
<td>kilobytes (socket buffers, TLS objects, heap)</td>
<td><strong>target ≤ 512 B</strong></td>
</tr>
<tr>
<td>Multicore model</td>
<td>threads and locks, or a single event loop</td>
<td><strong>one thread per core, no shared state, run to completion</strong></td>
</tr>
<tr>
<td>Code integrity</td>
<td>mappings that are both writable and executable are possible</td>
<td><strong>W^X, locked page tables, PAC/BTI</strong></td>
</tr>
</tbody>
</table>
<p>The priorities are, in this order: security, scalability, multicore performance, small footprint and an immutable installation. Whenever two goals conflict, the one higher on that list wins.</p>
<h2 id="where-we-are-now">Where we are now</h2>
<p>The project is in the pre-alpha phase, but seventeen development milestones have been closed, from M0 to M17. Today urMQTT serves MQTT 3.1.1 over TLS 1.3 with mandatory client certificates and keeps its state in SQLite on a raw disk. In short, here is what it already does:</p>
<ul>
<li><strong>Kernel:</strong> its own exception vectors with decoded reports, its own page tables with W^X enforced, guard pages, PAC and BTI where the CPU has them, GICv3 and GICv2, the generic timer, SMP up to 64 cores.</li>
<li><strong>Hardware discovery</strong> from the DTB or ACPI into a single platform description, with identical results from both sources.</li>
<li><strong>Drivers:</strong> PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi and virtio-rng, and a CSPRNG seeded with hardware entropy.</li>
<li><strong>Networking:</strong> its own DHCPv4, ARP, ICMP and strict IPv4, then its own server-side-only TCP: SYN cookies, a hierarchical timer wheel, NewReno, ACK coalescing, no TIME_WAIT state.</li>
<li><strong>TLS:</strong> TLS 1.3 only, mandatory mTLS, device identity from the CN or a SAN URI; the handshake is done by rustls, followed by our own record layer with AES-128-GCM and ChaCha20-Poly1305. Session resumption with PSK tickets, a revocation list, admission control already at the handshake.</li>
<li><strong>MQTT:</strong> a zero-copy codec, fuzzed and tested with real mosquitto clients; QoS 0 and 1, wildcards, retained messages, last will (LWT), persistent sessions, an ACL restricted to <code>devices/{id}/#</code>, per-device rate limits.</li>
<li><strong>Storage:</strong> SQLite inside the kernel through a VFS over the raw disk, in WAL mode, with an integrity check at boot; a device registry, revocations, retained messages, persistent sessions with message queues for offline devices; a separate per-core telemetry log with a CRC on every record and recovery after a crash.</li>
<li><strong>Operations and monitoring:</strong> a watchdog timer for handlers, a health check, rate-limited logging, syslog and statsd over UDP, admission that survives reconnect storms.</li>
<li><strong>Delivery:</strong> bit-for-bit identical rebuilds verified in CI, an ISO and a raw GPT disk image, minisign signatures, instructions for KVM, Proxmox and Hetzner.</li>
</ul>
<p>Each step is labeled with the milestone that delivered it, and the file <code>docs/PROGRESS.md</code> in the repository is the single source of truth about what actually works.</p>
<h2 id="architecture-in-three-pictures">Architecture in three pictures</h2>
<p>The whole design is described in <code>docs/ARCHITECTURE.md</code>, and the reasons for every major decision are recorded as ADRs in <code>docs/DECISIONS.md</code>. Here is an overview.</p>
<h3 id="layers">Layers</h3>
<figure><img loading="lazy" alt="urMQTT layers: from the broker through the MQTT codec, TLS, TCP and the network layer down to the virtio drivers and the architecture layer, all in a single ELF at EL1." src="https://aldo.tech/blog/urmqtt/img/layers.svg" /><figcaption>urMQTT layers: from the broker through the MQTT codec, TLS, TCP and the network layer down to the virtio drivers and the architecture layer, all in a single ELF at EL1.</figcaption></figure>
<p>From top to bottom: the broker (sessions, topic tree, QoS, retained messages, wills, ACL, limits), the MQTT codec, TLS, TCP, the network layer, and then the drivers. Alongside that main path sits storage (SQLite and the telemetry log), and underneath everything are memory management, platform discovery and the configuration, which is compiled into constants at build time, together with the embedded CA certificate, the server certificate and the key. The hypervisor with its virtio devices is the only thing outside the image.</p>
<h3 id="one-thread-per-core-no-shared-state">One thread per core, no shared state</h3>
<figure><img loading="lazy" alt="Execution model: each data core has its own loop and its own shard of the broker, and the highest core runs only SQLite. The cores communicate exclusively through SPSC rings." src="https://aldo.tech/blog/urmqtt/img/cores.svg" /><figcaption>Execution model: each data core has its own loop and its own shard of the broker, and the highest core runs only SQLite. The cores communicate exclusively through SPSC rings.</figcaption></figure>
<p>Each data core runs its own loop with its own TCP stack, TLS states, a shard of the broker (sessions and the topic tree), a heap cache and a telemetry partition. The cores share no mutable state, only bounded lock-free rings, one for each ordered pair of cores. The highest core runs SQLite and nothing else.</p>
<p>The network card currently has a single pair of queues, held by core 0. It steers every MQTT flow to one of the worker cores according to the Toeplitz hash of its four-tuple, and sends out whatever the worker cores return. A session lives on its home core, determined by the hash of the client identifier, so takeover, wills and durability never cross cores. A connection that lands on another core keeps its TCP and TLS there, and forwards plain MQTT to the home core with credit-based flow control. Every routed message is also fanned out to the other shards of the broker, each of which keeps a replica of the retained messages.</p>
<p>Three rules hold this model together. <strong>Run to completion:</strong> a received packet is processed entirely on one core, from TCP through in-place TLS decryption, MQTT parsing and routing to the encrypted reply, with no task queues and no copying into application buffers. <strong>Adaptive polling:</strong> under load the receive queues are actively polled, and at idle the core falls back to interrupts and <code>WFI</code>; interrupt handlers only acknowledge the interrupt and mark the work. <strong>Watchdog timer:</strong> a 10 ms tick on each core catches handlers that exceed their budget.</p>
<h3 id="the-life-of-a-publish">The life of a PUBLISH</h3>
<figure><img loading="lazy" alt="The path of a single QoS 1 PUBLISH message: from the TLS record on the network card through decryption, the ACL check and routing to the PUBACK merged with the TCP ACK in a single segment." src="https://aldo.tech/blog/urmqtt/img/publish.svg" /><figcaption>The path of a single QoS 1 PUBLISH message: from the TLS record on the network card through decryption, the ACL check and routing to the PUBACK merged with the TCP ACK in a single segment.</figcaption></figure>
<p>A device sends a TLS record. The network card hands it to the core's loop by descriptor, without copying. The loop checks TCP, decrypts the TLS in place and parses the MQTT from the borrowed buffer, then checks the ACL, the rate limit and the path through the topic tree in memory. If another core is interested in that topic, it receives a routing message with a reference to the payload and the topic identifier; the database receives a telemetry record or a retained-message update, in a batch. The PUBACK reply is encrypted directly into the outgoing buffer and merged with the TCP ACK, so a single segment goes out to the device. For the other core, the message costs one encryption per subscriber at send time.</p>
<h2 id="what-a-connection-costs">What a connection costs</h2>
<p>The target is <strong>at most 512 bytes per idle TLS connection</strong>, aiming for 256. While a connection is idle, nothing is held in a buffer for it: received data is processed directly from the network frame, and the send, record and MQTT buffers exist only while something is unfinished or unacknowledged. The kernel measures this itself: exact heap counters in requested bytes and in the blocks they occupy, written every ten seconds as heap above baseline per open connection.</p>
<table>
<thead>
<tr>
<th>Part</th>
<th style="text-align: right;">Size</th>
<th>How</th>
</tr>
</thead>
<tbody>
<tr>
<td>TCP control block</td>
<td style="text-align: right;">96 B</td>
<td>packed flags, 16-bit RTT/RTO, a slot in the timer wheel, no buffers at idle</td>
</tr>
<tr>
<td>Slot in the TCP table</td>
<td style="text-align: right;">4–8 B</td>
<td>only the connection index, table at most half full</td>
</tr>
<tr>
<td>Connection and TLS 1.3 record state</td>
<td style="text-align: right;">152 B</td>
<td>secret and sequence per direction (40 B); key and IV are derived per record</td>
</tr>
<tr>
<td>MQTT connection</td>
<td style="text-align: right;">48 B</td>
<td>identity and will on the heap only while they exist</td>
</tr>
<tr>
<td>MQTT session, hot form</td>
<td style="text-align: right;">64 B</td>
<td>one subscription inline, interned node identifiers</td>
</tr>
<tr>
<td>Subscription <code>devices/&lt;id&gt;/cmd/#</code></td>
<td style="text-align: right;">0 tree nodes</td>
<td>stored relative to the session identifier, shared by all devices</td>
</tr>
</tbody>
</table>
<p>Measured under HVF with four virtual cores, three of them workers, with every device holding an mTLS connection and one QoS 1 subscription, including allocator rounding:</p>
<table>
<thead>
<tr>
<th style="text-align: right;">Connections</th>
<th style="text-align: right;">Bytes per connection</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>The network frame pool is a fixed ~2 MiB, which is roughly 200 B per connection at ten thousand connections and about 2 B at a million. We could not measure beyond that: QEMU's user-mode networking on macOS stopped accepting connections at 10,197, roughly at the limit of 10,240 descriptors of the QEMU process. The figure falls as the fixed parts are spread over more connections, so it does not grow with their number.</p>
<h2 id="security-model">Security model</h2>
<p>Security is the first priority and wins every conflict with the other goals. The model has several layers:</p>
<ul>
<li><strong>One port.</strong> TCP 8883, TLS 1.3 only, mandatory client certificates signed by the CA embedded in the image. The MQTT client identifier must equal the device identifier from the certificate.</li>
<li><strong>No entry points.</strong> No shell, no console input, no SSH, no administration API, no dynamic code, no SQL reachable over the network and no filesystem.</li>
<li><strong>Memory safety.</strong> Rust, with <code>forbid(unsafe_code)</code> in the protocol crates; every <code>unsafe</code> block has a <code>// SAFETY:</code> comment that a lint checks, and external input never causes a panic: clippy in the crates on the data path forbids <code>unwrap</code>, <code>expect</code>, indexing and <code>panic!</code>.</li>
<li><strong>Exploit mitigation.</strong> W^X is enforced through descriptor encoding and the <code>SCTLR_EL1.WXN</code> bit and audited on every boot. After boot the page tables are read-only, with no writable alias. The lower half of the address space is disabled. Every stack has guard pages, and exceptions run on a separate stack. PAC and BTI are enabled when the CPU supports them, and smoke tests verify this.</li>
<li><strong>Abuse resistance.</strong> SYN cookies; admission rejects new connections, before any cryptography, when the heap is low, when the database is lagging, when too many handshakes are in flight per core or per source address, or when the handshake rate is too high. Per-device rate limits, bounded queues, a TCP window tied to broker backpressure, rate-limited logging, a watchdog timer and a health check.</li>
<li><strong>Immutability.</strong> The configuration, the CA and the server key are embedded in the image. Changing anything means a new image.</li>
</ul>
<p>The threat model is recorded in <code>docs/SECURITY.md</code>.</p>
<h2 id="storage-without-a-filesystem">Storage without a filesystem</h2>
<p>The data lives on a separate disk, virtio-blk or virtio-scsi, with no filesystem on it. The layout is minimal and our own:</p>
<pre><code>LBA 0      [ superblock: magic · layout version · uuid · region table · CRC-32C ]
LBA 8, 16  [ file size table, slots A/B (the newest valid one wins) ]
LBA 64     [ SQLite main database ½ ][ SQLite WAL ⅛ ][ journal 1 MiB ][ telemetry log: 8 partitions … ]
last 4 KiB [ scratch space for the block device self-test at boot ]
</code></pre>
<p>SQLite (vendored 3.54.1, with a digest check at build time) is compiled into the kernel with clang, without the standard library, and uses a fixed <code>memsys5</code> heap. Its VFS maps three fixed files onto disk regions. It runs in WAL mode with <code>locking_mode=EXCLUSIVE</code>, so it needs no shared-memory index, and with <code>synchronous=FULL</code>; if <code>quick_check</code> finds an error at boot, the system halts safely. The database holds the device registry, revocations, retained messages and persistent sessions with QoS 1 message queues for devices that are not connected.</p>
<p>The database core, the one with the highest number, runs SQLite and nothing else. The data plane sends it state changes over a lock-free SPSC ring; it gathers them into one transaction every 5 ms or every 512 operations and returns the acknowledgments over a second ring. An empty disk is formatted on first boot; anything else is never formatted over.</p>
<p>Telemetry takes a different path. Every accepted PUBLISH on topics from <code>storage.telemetry_topics</code> goes into its core's partition of the telemetry log: a ring of segments with a CRC on every record, salted with the segment sequence so that old passes of the ring never reappear. The cores hand records to the database over their own ring, without copying, and it writes them out every 5 ms or every 256 KiB. At boot, recovery keeps every record that was written out, cuts off the unfinished tail and continues in a new segment. Retention is by size, through the ring, and by age, through <code>retention_days</code>. We verified this with a million records and with <code>kill -9</code> in the middle of a write.</p>
<h2 id="the-numbers">The numbers</h2>
<p>The load tool in <code>tools/loadgen</code> does three things: a storm of TLS 1.3 handshakes with mTLS and CONNECT, long-lived connections with PINGREQ that publish telemetry every second, and reconnect storms. <code>make bench</code> on an Apple M4 Pro under HVF gives:</p>
<table>
<thead>
<tr>
<th>Worker cores</th>
<th>handshakes/s in the guest</th>
<th>scaling</th>
<th>through QEMU user networking (connections/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>The in-guest figure, where the client and the server are inside the same virtual machine and each worker core works on its own, shows that the data plane scales linearly. The network figure is limited by QEMU's single-threaded user networking, not by the guest; this is recorded as ADR-032.</p>
<p>We test reconnect storms by having all devices connect at once, retry rejected attempts with a backoff, and then all drop without a DISCONNECT. The <code>heap_limit_mb=</code> parameter in development mode makes admission treat the heap as smaller than it is, so a storm larger than the heap can be tried with few sockets. In one such round of 3,000 devices, 1,368 connected in 36 seconds and 5,619 attempts were cleanly rejected, with a limit of 8 MiB; the memory returned to baseline after the storm.</p>
<h2 id="how-we-work">How we work</h2>
<p>The project is driven by a written specification, <code>PROMPT.md</code>, with a milestone plan, and every milestone ends with the same ritual: <code>make check</code> and <code>make smoke</code> green, a single commit tagged <code>M&lt;n&gt;</code>, an entry in <code>docs/PROGRESS.md</code> and an ADR for every decision that is not obvious. The rules are short and we do not negotiate them: no panics on external input, <code>unsafe</code> only in hardware and FFI crates and always with a comment, no allocations on the hot path, a comment next to every atomic ordering, every limit comes from configuration, and deliberate temporary pieces fail loudly.</p>
<p>CI does more than we could manage by hand: fmt, clippy for the host and for <code>aarch64-unknown-none</code>, typos, actionlint and shellcheck; tests on Linux and macOS on arm64; a kernel build for the development and production configurations with the ELF size in the summary; a boot under QEMU with all smoke-test scenarios and saved serial logs; cargo-deny for advisories, licenses and sources; one minute of fuzzing on each of eleven targets, and ten minutes per target at night with a cached corpus. On top of that come CodeQL, OpenSSF Scorecard, Dependabot and a release with SLSA provenance. Every third-party action is pinned to a full commit SHA.</p>
<p>We take reproducibility literally: <code>make repro</code> builds HEAD twice in fresh worktrees and compares the ELF, the ISO and the raw image byte by byte. Releases are signed with minisign, and the artifacts can be verified with <code>gh attestation verify</code>. The production image contains the server's private key, so the ISO itself is a secret; the images attached to GitHub releases are development ones, with temporary keys, and are meant only for trying things out.</p>
<h2 id="what-we-learned">What we learned</h2>
<p>A few lessons that changed the plan:</p>
<ul>
<li><strong>Our own TCP paid off after all.</strong> The prototype ran on smoltcp and served us well up to M13. But a server holding hundreds of thousands of connections does not need a client TCP; it needs SYN cookies, a timer wheel and 96 bytes of state. In M14 we replaced smoltcp with our own server-side-only stack, with simulated-loss tests and segment fuzzing.</li>
<li><strong>Measure memory from the inside.</strong> Per-connection estimates were useless until the kernel started counting the heap in allocator blocks rather than in requested bytes. Only then did we see where the 512 bytes really go, and we brought it down to 494.</li>
<li><strong>The test tooling is part of the product.</strong> The biggest limit was not the kernel but QEMU's user-mode networking and the number of ephemeral ports on the development machine. For a hundred thousand connections and more you need <code>vmnet-shared</code> on macOS, or Linux with KVM, a tap interface and multiple client addresses.</li>
<li><strong>SQLite without an operating system is possible</strong>, but only under strict conditions: a fixed heap, exclusive locking without shared memory, a VFS that knows nothing about files, and admission that does not let the database lag.</li>
<li><strong>Reconnect storms are the normal state</strong>, not the exception. When a mobile network drops and comes back, thousands of devices knock at the same time. That is why admission rejects a connection before any cryptography, and every rejected connection costs almost nothing.</li>
</ul>
<h2 id="what-comes-next">What comes next</h2>
<p>Work after M17 is planned in three directions, and the order among them is recorded in <code>docs/PLAN.md</code>. None of this has been built yet.</p>
<ul>
<li><strong>One ISO for multiple platforms.</strong> The same file <code>urmqtt-&lt;env&gt;-&lt;version&gt;.iso</code> and one raw GPT image that boot on aarch64 UEFI, x86_64 UEFI and x86_64 BIOS, in virtual machines and on real hardware, starting with a single Intel PC. Still an appliance with everything inside, reproducible and signed.</li>
<li><strong>A raw disk, no filesystem.</strong> A byte-level format specification, a host tool <code>urmqtt-disk</code> that shares its parsers with the kernel, region alignment to 1 MiB, growing the disk with the data, discard for thin-provisioned disks, A/B database snapshots, bit-rot checking and, optionally, AES-256-XTS encryption with hardware AES.</li>
<li><strong>Provably secure.</strong> Runtime revocation and a device allowlist over a signed control channel, without a new image; differential and conformance testing of our own TLS and TCP; KASLR, stack protection, kCFI, MTE and a hardened allocator; SQLite isolated at EL0; Secure Boot; SBOM and cargo-vet; and finally a package for an external audit.</li>
</ul>
<p>Platforms that offer only non-virtio devices, such as AWS ENA or Azure MANA, are not supported for now. Raspberry Pi 4 and 5 as a KVM host, Hetzner CAX and Oracle Ampere A1 are documented and supported in code, but have not yet been tried on the real service.</p>
<h2 id="try-it">Try it</h2>
<p>The repository is at <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>. With Rust on the pinned nightly toolchain and QEMU, <code>make certs</code>, <code>make run</code> and a single mosquitto client are enough for a first PUBLISH over mTLS on your own machine. The warning from the beginning holds at the end too: this is pre-alpha, and everything that works is written in <code>docs/PROGRESS.md</code>. Bug reports, design questions and security reports following the instructions in <code>SECURITY.md</code> are welcome. We will report back here on each next milestone.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>Starting a blog: notes from the field</title>
      <link>https://aldo.tech/en/blog/starting-a-blog/</link>
      <guid isPermaLink="true">https://aldo.tech/en/blog/starting-a-blog/</guid>
      <pubDate>Sat, 17 Oct 2026 08:00:00 +0000</pubDate>
      <description>Why a company that builds terminals, apps and electronics for public transport is starting to write, and what you will read about here.</description>
      <content:encoded><![CDATA[<p>Most of what we build is invisible. The software in the terminal at the bus door, the firmware in a device that never gets a screen, the server that turns a day of transactions into reports overnight. When it all works, it works quietly. When it doesn't, everyone notices.</p>
<p>This blog is where we write down what we have learned along the way. Not marketing copy, but concrete notes: what went wrong, how we fixed it and what we would do differently today.</p>
<h2 id="what-we-will-write-about">What we will write about</h2>
<ul>
<li><strong>Fare collection in public transport.</strong> Contactless cards, SAM modules, working offline and everything a terminal has to survive in a vehicle that shakes for twelve hours a day.</li>
<li><strong>Mobile apps in the field.</strong> Android and Flutter on devices that are not ordinary phones: with card readers, printers and batteries that have to last a whole shift.</li>
<li><strong>Backend and fleet monitoring.</strong> How to know a device in a vehicle has a problem before the driver reports it.</li>
<li><strong>Hardware.</strong> From a prototype on the bench to a board that goes into series production, and all the mistakes in between.</li>
<li><strong>Local AI models.</strong> Where models on your own hardware make sense, and where the cloud and an API are simply the better choice.</li>
</ul>
<h2 id="how-we-will-write">How we will write</h2>
<p>Short and concrete, with code and numbers where we have them. We will publish when we have something to say, not on a schedule. Posts are written in Croatian and translated into every language of this site, and you can follow along by <a href="https://aldo.tech/en/blog/feed.xml">RSS</a>.</p>
<p>If there is a topic you would like us to cover, or you are working on a similar problem, write to us at <a href="mailto:info@aldo.tech">info@aldo.tech</a>.</p>]]></content:encoded>
      <category>intro</category>
    </item>
  </channel>
</rss>
