urMQTT su un vero VPS: ISO, handshake da 0,24 ms, disco proprio e kernel rinforzato
Che cosa è successo alle tre promesse del primo articolo su urMQTT (un solo ISO, disco raw senza file system, sicurezza dimostrabile) dopo i primi quattro giorni di sviluppo, con misure, tabelle e l'elenco di ciò che non è ancora finito.

Nel primo articolo su urMQTT abbiamo descritto un unikernel che è un broker MQTT, fino alla milestone M17, e abbiamo chiuso con tre promesse sotto il titolo "Che cosa viene dopo": un solo ISO che si avvia su più piattaforme, un disco raw senza file system e una sicurezza che si possa dimostrare, non solo dichiarare. Allora nulla di tutto questo era stato costruito. Questo articolo segue che cosa è successo a ciascuna promessa, nello stesso ordine di prima: che cosa avevamo pianificato, che cosa abbiamo costruito, che cosa ci ha sorpreso e che cosa è ancora aperto. Lungo la strada mostreremo anche quanto tempo è servito, perché fa parte della storia.
Una premessa: il progetto è ancora pre-alfa. I numeri nel testo misurano codice e test, non la maturità in produzione, e la prima pubblicazione è volutamente limitata ai VPS con KVM. Il bare metal resta per dopo.
Come si presenta nel tempo
Il primo commit risale al 28 ottobre 2026 alle 23:48 (M0). Al 31 ottobre alle 23:00 il repository conta 119 commit, distribuiti come 3, 39, 38 e 39 per giorno di calendario. Diciamo subito anche come sia possibile: il progetto è costruito con Claude Code, e i commit portano trailer Co-Authored-By: Claude. Quattro giorni di calendario di storia non significano che una persona abbia scritto 90 000 righe in quattro giorni; significano che piano, implementazione, test e ADR vengono scritti in cicli brevi e chiusi, e che ogni passo termina con lo stesso rituale di verifica di prima. I numeri qui sotto parlano della quantità di codice e di test, non di quanto lavoro reale ci sia dietro tutto questo.
Il grafico mostra tre curve che crescono quasi insieme, ed è proprio l'intenzione: test e decisioni scritte seguono il codice, non restano indietro. La pendenza è massima il 29 ottobre, quando nasce l'intero percorso dal kernel al broker. In seguito le righe si aggiungono più lentamente, ma i test crescono più in fretta del codice (27 test su 1,6 migliaia di righe all'inizio, 739 su 89,8 migliaia oggi).
| Milestone | Commit | Ora | Righe di Rust | Test | ADR |
|---|---|---|---|---|---|
| M0 | 9412a9c | 28.10. 23:48 | 1 592 | 27 | 13 |
| M5 | a61083b | 29.10. 01:32 | 9 314 | 88 | 24 |
| M9 | 4be8cc9 | 29.10. 02:21 | 15 729 | 149 | 28 |
| M11 | 4800904 | 29.10. 03:43 | 22 737 | 180 | 30 |
| M13 | a558d5e | 29.10. 05:22 | 26 556 | 193 | 32 |
| M17 | 7df4f98 | 29.10. 08:48 | 33 504 | 232 | 36 |
| S1 | ecf72ec | 30.10. 00:46 | 41 910 | 299 | 45 |
| X8 | fb1504a | 30.10. 19:08 | 56 341 | 456 | 59 |
| PERF | 76f7c36 | 31.10. 14:19 | 72 511 | 618 | 76 |
| oggi | HEAD | 31.10. 23:00 | 89 790 | 739 | 104 |
Le righe sono tutti i file .rs al di fuori di vendor/ e fuzz/, i test sono funzioni #[test]. Che cosa ha aggiunto ciascuna fase, in base alla data nella storia di git:
| Data | Milestone | Che cosa è stato aggiunto |
|---|---|---|
| 29.10. | da M0 a M17 | kernel, rete, TLS, broker, SQLite, più core, TCP proprio, rinforzo, rilascio riproducibile |
| 29.10. sera e 30.10. | da X0 a X10, S1, H2, H3 (X0 inizia il 29.10. alle 20:54) | port su x86_64, ISO universale, un solo disco e ISO di auto-installazione, controlli TLS, client ostili |
| 31.10. | da O1 a O4, T1, PERF, H4, H5, H9, da S2 a S6 | visibilità e console tramite MQTT, modalità live e diagnostica, SNTP, ottimizzazioni, rinforzo, verifica, supply chain, archiviazione |
Promessa 1: un solo ISO per più piattaforme
Che cosa avevamo pianificato. Un unico file che si avvia su UEFI aarch64, UEFI x86_64 e BIOS x86_64. Che cosa abbiamo costruito. Il port su x86_64 (da X0 a X10) e un ISO universale con il bootloader Limine, un unico ISO ibrido da cui si avviano tutte queste combinazioni; gli smoke test lo avviano su quattro macchine virtuali. Il menu ha tre voci: urMQTT (modalità live, gira dalla RAM, non scrive nulla sul disco), installazione sul disco della macchina su cui è avviato (l'installazione scrive GPT e dati, poi spegne la macchina) e Diagnostica (rilevamento dell'hardware e misure).

Il primo bersaglio reale è stato un VPS, non un computer fisico: la decisione è che la prima versione supporti solo VPS (KVM), mentre il bare metal arriva dopo. Abbiamo provato su un VPS Contabo con processore AMD EPYC (famiglia 23, Zen 1), 4 vCPU e 8 GiB di RAM, con rete virtio-net e disco virtio-scsi. Il primo tentativo ci ha sorpreso: l'ISO collegato come CD virtuale avviava la voce predefinita, l'immagine di produzione si è rifiutata di servire da un supporto di sola lettura e si è riavviata, così abbiamo ottenuto un ciclo infinito di menu, kernel, reset senza nulla di leggibile. Oggi un errore fatale nell'immagine di produzione lascia un messaggio sulla console per 60 secondi prima del riavvio e, da un supporto ottico, segnala che l'avvio è avvenuto da un supporto di sola lettura e si ferma. La modalità live in RAM (O3) ha poi aggirato il problema per il caso comune.
La voce Diagnostica è nata da questa esperienza: una macchina che non vediamo dobbiamo poterla interrogare su ciò che ha. Stampa CPU, RAM, rete, dischi e dispositivi PCI, misura la banda della memoria, il disco e la crittografia, e le stesse misure le emette come JSON sulla porta seriale. La scrittura su disco è consentita solo nello spazio non allocato e solo dopo aver digitato WIPE; nell'immagine qui sotto è stata saltata.

Che cosa è ancora aperto. L'hardware reale. Dopo la prima versione arriva il bare metal (avvio da USB, NVMe e AHCI, scheda di rete di un computer vero), insieme a IOMMU e Secure Boot, perché senza IOMMU un dispositivo malevolo o difettoso può scrivere su tutta la memoria. In questa versione non affermiamo nulla al riguardo.
L'handshake: da 1,2 ms a 0,24 ms
La prima misura della diagnostica sul VPS ha dato un numero scomodo: l'handshake mTLS, solo la crittografia lato server, richiedeva 1,2 ms. Su aarch64 usavamo già le istruzioni hardware AES e SHA, ma x86_64 non aveva nulla di simile per la chiave pubblica: RustCrypto è portabile e constant-time, e quindi anche lento. Tre modifiche lo hanno ridotto.
- Per P-256 abbiamo adottato l'assembly
nistz256di OpenSSL e ring (ADR-130), con tabelle a base fissa per la firma e per la verifica delle firme della CA (ADR-110, ADR-111). - X25519 è stato riscritto su quattro arti a 64 bit con le istruzioni MULX/ADX (ADR-131).
- Dopodiché abbiamo profilato anche il resto (ADR-132, ADR-200).
| Misura sul VPS (server, microsecondi) | Prima | Dopo |
|---|---|---|
| handshake mTLS, tutta la crittografia | 1200 | circa 240 (237 nella misura del grafico, 244 nell'immagine) |
| X25519 | 146 | 48 |
| firma ECDSA | 673 | 34 |
| verifica ECDSA | 309 | 90 (107 nell'immagine) |
| handshake, vecchio memcpy, stesso boot | 278,8 | 229,4 (-17,7%) |
I numeri variano di circa il 10% da un'esecuzione all'altra, quindi abbiamo annotato un intervallo anziché un singolo valore. Nel complesso è circa un quinto del tempo di CPU per un handshake.
Che cosa abbiamo imparato su memcpy. Quando la crittografia è scesa, il profilo ha mostrato qualcosa che non ci aspettavamo: le funzioni mem* (memcpy, memmove, memset) consumavano il 7% dell'handshake, dal 13 al 39% del percorso dei record TLS (39% per 64 B, 13% per 4 KiB) e dal 17 al 34% della distribuzione dei PUBLISH. Le abbiamo sostituite con assembly SSE2 per x86_64 (ADR-200), con test differenziali rispetto all'implementazione di riferimento per tutte le dimensioni, gli allineamenti e le sovrapposizioni. Per chiamata sono da 55 a 75 cicli contro da 7 a 12 per 1–64 byte.
| Percorso | Quota di mem* prima | Più veloce dopo |
|---|---|---|
| handshake | 7% | 17,7% |
| record TLS 64 B | 39% | 26,4% |
| record TLS 256 B | 37% | 19,6% |
| record TLS 1 KiB | 24% | 16,1% |
| record TLS 4 KiB | 13% | 6,6% |
| distribuzione dei messaggi | dal 17 al 34% | dal 7 al 34% |
Come leggere il grafico: più piccolo è il record, maggiore è la parte di tempo dedicata alla copia, e quindi maggiore è il guadagno. Per i messaggi piccoli, che sono la maggior parte del traffico MQTT, è questo che conta.
Che cosa questo non significa. Su x86_64 nativo abbiamo misurato OpenSSL 3.5.5 solo come riferimento per l'assembly (ADR-130), non come confronto fra prodotti. Il confronto Linux più Mosquitto sullo stesso VPS (attività X11) non l'abbiamo ancora fatto. Per questo non affermiamo che urMQTT sia più veloce di Linux: non è stato misurato. I numeri qui sopra confrontano urMQTT con se stesso.
Il sovraccarico è stato il secondo risultato. Sul VPS, da un singolo Mac (RTT 35 ms), 128 client hanno prodotto 618 handshake al secondo, 256 client 393 e 512 client 67, con migliaia di connessioni rifiutate. Non era un plateau, ma un crollo. Le cause le abbiamo trovate nel codice (due sono confermate in QEMU/HVF, sul VPS non si sono potute riprodurre), più di una insieme: una connessione rifiutata riportava il client oltre il segmento successivo che portava un SYN cookie valido, il rifiuto con RST diceva al client di riprovare subito, e la ricezione era O(coda). La correzione (da ADR-080 a ADR-082) scarta il SYN che non sarebbe stato accettato, ricorda i flussi rifiutati, mantiene la ricezione in O(1) e conta gli handshake per core. È possibile (ipotesi, non verificata) che parte della causa fosse anche sul client: lo strumento di carico chiudeva per primo, per cui un singolo Mac con 16 384 porte effimere e un TIME_WAIT di 30 secondi era limitato a circa 550 connessioni al secondo. Per questo il vero limite del server non l'abbiamo ancora misurato.
Promessa 2: disco raw, ma con cura
Che cosa avevamo pianificato. Mantenere il layout proprio senza file system e aggiungere tutto ciò che un file system di solito offre: allineamento, crescita, discard, copie di sicurezza e verifiche. Perché non un file system. Ne abbiamo discusso e la decisione è confermata: urMQTT scrive un solo database, un solo log di telemetria e metadati propri, non servono directory, permessi né nomi di file, e ogni strato in più sarebbe altro codice nella parte fidata e un altro modo in cui una scrittura può sparire in silenzio. Il disco è GPT con una partizione ESP e una partizione dati urMQTT, su cui sta il layout proprio.
Che cosa abbiamo costruito (da S2 a S6).
- Layout v2 (S2). Topologia del disco, allineamento a 1 MiB o alla dimensione I/O ottimale, blocchi fisici interi, coppia A/B di superblocchi con generazione (ADR-140). Il layout v1 il kernel lo rifiuta con un messaggio chiaro, e lo migra lo strumento offline
urmqtt-disk migrate(ADR-141): per impostazione predefinita è una prova a secco, si copia e solo dopo si passa alla nuova versione, con ripristino e iniezione di errori a ogni scrittura. - Crescita (S3). All'avvio il disco può crescere (mai ridursi), e su virtio-blk anche durante il funzionamento, tramite l'interrupt di cambio di configurazione (ADR-142, ADR-143).
- Discard e write-zeroes (S4). I dischi thin provisioned restituiscono lo spazio all'host. In un test il file qcow2 sull'host è sceso da 62 a 16 MiB dopo la scadenza dei segmenti di telemetria, e il ripristino non si fida mai del contenuto che è stato scartato.
- Copia di sicurezza del database su disco (S5). Copia A/B con l'API di backup online di SQLite a passi di 16 pagine: il passo più lungo 4,6 ms contro 445 ms quando si copia in una sola chiamata. I commit durante la copia hanno avuto un massimo di 2,4 ms contro 2,2 ms senza copia. Se
quick_checkall'avvio fallisce, il database viene ripristinato dalla copia. - Scrubbing e avvio limitato (S6). Lo scrubber legge il disco con un budget I/O limitato e segnala i record danneggiati, mentre l'avvio legge solo il segmento più recente di ogni partizione, così il tempo di avvio non cresce con la dimensione della regione di telemetria. Con questo abbiamo chiuso un problema che avevamo calcolato in anticipo: su un disco grande il ripristino completo richiederebbe da minuti a decine di minuti.
L'intero gruppo di smoke test per l'archiviazione passa su entrambe le architetture: 79 riusciti, 0 falliti, su aarch64 sotto HVF e su x86_64 sotto TCG. Abbiamo misurato anche che S4 non ha rallentato il database: 53,3 mila inserimenti al secondo in batch prima e dopo, senza differenza misurabile.
Che cosa ci ha sorpreso. Quanto di un layout "semplice" sia fatto di casi limite: virtio-scsi e virtio-blk riportano la topologia in modi diversi, e una scrittura incompleta a metà migrazione deve sempre lasciare almeno una versione valida. Per questo ogni scrittura sul percorso di migrazione e di crescita è passata dall'iniezione di errori.
Che cosa è ancora aperto. Le pagine di SQLite non hanno checksum, quindi un bit modificato all'interno di un valore in una riga il database non lo rileva; i record di telemetria e il contenuto delle copie hanno un CRC e vengono individuati. La cifratura del disco (S7) non è finita.
Promessa 3: sicurezza dimostrabile, per quanto ci riesca
Rinforzo (H4). L'allocatore azzera la memoria liberata e la verifica al riutilizzo, rileva il double free, collega i puntatori della free list con XOR e azzera le chiavi alla liberazione (ADR-170). Protezione dello stack (-Zstack-protector=strong, 1315 funzioni, mentre SQLite con -fstack-protector-strong, ADR-171). I report di errore in produzione non contengono registri né indirizzi (ADR-172). KASLR su entrambe le architetture: il kernel è PIE e Limine lo sposta di circa 19 bit, mentre heap, stack e finestra MMIO sono posizionati in modo casuale (ADR-173). Su aarch64 MTE marca l'heap (ADR-174), ma solo sotto TCG: HVF e x86 non ce l'hanno. kCFI lo abbiamo valutato e non l'abbiamo adottato perché il compilatore sul nightly fissato va in crash con un errore interno; IOMMU è rinviato insieme al bare metal (ADR-175, ADR-176).
Il costo è misurato, non presunto. Sul percorso dell'handshake (dp-bench, aarch64 HVF, handshake completi TLS 1.3 + mTLS + CONNECT):
| Passo | 1 core | 4 core | Variazione |
|---|---|---|---|
| senza rinforzo | 1941 | 7620 | |
| + allocatore | 1898 | 7429 | da -2,2% a -2,5% |
| + protezione dello stack | 1845 | 7243 | insieme da -4,9% a -5% |
| + PIE e marcatura | circa 1800 | circa 6750 | su un computer carico, rumoroso |
Nel complesso, quindi, il costo sul percorso dell'handshake è circa il 5% per le parti misurate in modo pulito, e con PIE e marcatura risulta circa il 7% su un core e circa l'11% su quattro, ma questa misura è rumorosa. La riteniamo accettabile, ma non l'abbiamo ripetuta sul VPS x86_64.
Vale la pena confrontarlo con il numero del primo articolo (862 handshake al secondo su un solo core). Lo stesso strumento oggi, con il motore di cifratura hardware su aarch64 (X0b) e una serie di ottimizzazioni arrivate da allora, dà 1941 prima del rinforzo. Non abbiamo isolato il contributo di ogni modifica, quindi sulla causa non affermiamo nulla.
Come è stato verificato, e che cosa no. H5 ha aggiunto Miri su un sottoinsieme di crate host, dimostrazioni con Kani, sanitizer, un model-fuzzer per le ACL e report di copertura. Kani ha dimostrato record_len in TLS, parse in TCP e due aritmetiche della timer wheel. Non è dimostrato (tempo scaduto): entrambi i decoder MQTT, entrambi i parsing della configurazione e il comportamento della timer wheel; il job CI per Kani quindi non è ancora bloccante. Miri non può eseguire assembly. Supply chain (H9): SBOM CycloneDX con 215 componenti, cargo-vet passa con 39 revisioni complete e 140 eccezioni, e cargo-deny è configurato per trattare gli avvisi di vulnerabilità come fallimento della CI (questi job CI finora sono stati provati solo in locale, non su GitHub, e il test negativo non è stato eseguito), insieme a VULNERABILITY-HANDLING.md. Lo stato onesto: delle 67 versioni di crate di terzi collegate al kernel, 53 sono eccezioni, non revisioni. Vet dimostra che l'insieme delle dipendenze non può cambiare senza una differenza visibile, non che il TCB sia stato revisionato. Una nostra revisione di un crate non l'abbiamo scritta.
SNTP (T1) è in questo senso una piccola storia di sicurezza: è opzionale, solo verso server IPv4 privati, con salto massimo e con la condizione che le fonti concordino (mediana, maggioranza entro un secondo). Un orologio che chiunque sul percorso può spostare non è un orologio di cui ci si possa fidare per la validità dei certificati.
Sul Mac: aarch64 nativo e x86_64 in emulazione
Sviluppiamo su un Mac, e questo ha due modi di eseguire lo stesso codice, molto diversi tra loro.
| aarch64 sotto HVF | x86_64 sotto TCG | |
|---|---|---|
| Che cos'è | esecuzione nativa, velocità vicina a quella di un processore reale | QEMU emula le istruzioni |
| Handshake nel guest | circa 0,58 ms (misurato la prima volta intorno a M13); dp-bench oggi 1941/s per core |
circa 3,3 ms |
| Scenari smoke TLS | veloci | circa 15 minuti per scenario |
| MTE | no (su HVF non c'è) | x86 non ce l'ha; la marcatura dell'heap funziona solo per un guest aarch64 sotto TCG |
| A che cosa serve | misure reali | correttezza: il percorso x86 viene verificato, i numeri non sono prestazioni |
La regola è semplice: i numeri sotto TCG sono emulazione e non li citiamo come prestazioni. Le prestazioni x86 le misuriamo solo su un VPS vero. Per questo i numeri crittografici qui sopra vengono dal VPS Contabo, non dal Mac.
Lezioni
- Misurate su una macchina reale prima di ottimizzare. Il primo approccio è stato ottimizzare ciò che pensavamo fosse lento; solo la diagnostica sul VPS ha mostrato 1,2 ms e dove va a finire esattamente il tempo.
- Il sovraccarico è una funzionalità a sé. Un sistema veloce sotto carico può crollare oltre di esso; rifiutare deve costare meno che accettare.
- Il profilo sorprende. Il maggior guadagno singolo per i messaggi piccoli non era nella crittografia, ma nella copia della memoria.
- Un disco senza file system richiede più lavoro, non meno. Ciò che un file system di solito offre (allineamento, crescita, discard, copie, verifiche) dobbiamo scriverlo e verificarlo da soli, ogni passo con iniezione di errori.
- Onestà nei numeri. Le misure di memoria su un VPS condiviso variano fino a 10 volte tra le esecuzioni e non dicono nulla sul codice, quindi non le riportiamo. I numeri crittografici sono stabili entro circa il 10%.
- La velocità di sviluppo non è maturità. Test e ADR crescono insieme al codice, ma quattro giorni non sono un vero esercizio.
Che cosa viene dopo
- H6: SQLite isolato a livello EL0, così che un errore nel database non apra il kernel.
- H7: il resto della sicurezza del tempo (NTS e gestione dei salti); SNTP è solo il primo passo.
- X11: confronto con Linux e Mosquitto sullo stesso VPS, solo allora ha senso parlare di velocità relativa.
- Rilascio: la prima pubblicazione per VPS (KVM), con artefatti firmati e istruzioni.
- Dopo il rilascio: bare metal (da X12 a X16), IOMMU e Secure Boot, e cifratura del disco.
Il codice è su github.com/zeljan-alduk/urMQTT, e docs/PROGRESS.md resta l'unica fonte di verità su ciò che funziona davvero. Pre-alfa, come prima; segnalazioni di errori e di sicurezza secondo le indicazioni in SECURITY.md sono benvenute.
Commenti
Regole dei commenti