<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Blog de ALDO TECH</title>
    <link>https://aldo.tech/es/blog/</link>
    <atom:link href="https://aldo.tech/es/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Notas desde el terreno: transporte público, cobro de billetes, hardware y software que tienen que funcionar en la carretera.</description>
    <language>es</language>
    <item>
      <title>urMQTT tras el VPS: SQLite en modo usuario, comparación con Mosquitto, configuración como dato y un constructor en el navegador</title>
      <link>https://aldo.tech/es/blog/urmqtt-config-builder-plaintext-mosquitto/</link>
      <guid isPermaLink="true">https://aldo.tech/es/blog/urmqtt-config-builder-plaintext-mosquitto/</guid>
      <pubDate>Sun, 01 Nov 2026 08:00:00 +0000</pubDate>
      <description>Qué pasó después del segundo texto sobre urMQTT: SQLite aislado fuera del núcleo, mediciones frente a Mosquitto en el mismo VPS (y lo que no salió a nuestro favor), las causas encontradas del flujo único lento, la configuración como dato con un constructor web de ISO y un emulador en el navegador, MQTT en claro opcional y lo que aún no está demostrado.</description>
      <content:encoded><![CDATA[<p>El segundo texto, <a href="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/">urMQTT en un VPS real</a>, terminó con una lista "Lo que viene": H6 (SQLite fuera del núcleo), H7 (seguridad del tiempo), X11 (comparación con Mosquitto en el mismo VPS) y la primera publicación. Este texto sigue esa lista en el mismo orden y después añade dos cosas que no estaban en ella porque surgieron por el camino: la configuración como dato, con un constructor web de ISO, y MQTT en claro opcional, sin TLS. Como antes: qué habíamos planeado, qué construimos, qué nos sorprendió y qué sigue abierto. En este texto hay también algunos resultados que no favorecen a urMQTT; los dejamos porque forman parte de la verdad.</p>
<p>La advertencia previa sigue igual: el proyecto es pre-alfa, la primera publicación es solo para VPS con KVM, y las cifras miden código, pruebas y experimentos concretos, no madurez en producción.</p>
<h2 id="como-se-vio-en-el-tiempo">Cómo se vio en el tiempo</h2>
<p>Desde el texto anterior (31.10. a las 23:00) hasta hoy (1.11. a las 20:41) hay 59 commits nuevos en el repositorio, 178 en total. Todos se hicieron en un solo día natural, así que vale la misma advertencia de antes: el proyecto se construye con Claude Code (los commits llevan <code>Co-Authored-By: Claude</code>), el plan, el código, las pruebas y los ADR se escriben en bucles cortos y cerrados, y las cifras de abajo hablan de la cantidad de código y de comprobaciones, no de cuánto trabajo humano hay en ellas.</p>
<figure><img loading="lazy" alt="Crecimiento desde el texto anterior: líneas de Rust de 89 790 a 112 408, funciones #[test] de 739 a 926, ADR de 104 a 126 y commits de 119 a 178. Cada fila tiene su propia escala." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/growth-since.svg" /><figcaption>Crecimiento desde el texto anterior: líneas de Rust de 89 790 a 112 408, funciones #[test] de 739 a 926, ADR de 104 a 126 y commits de 119 a 178. Cada fila tiene su propia escala.</figcaption></figure>
<p>Las cifras se calcularon con el mismo método que en el texto anterior: todos los archivos <code>.rs</code> fuera de <code>vendor/</code> y <code>fuzz/</code>, las funciones <code>#[test]</code> y los títulos <code>## ADR-</code> en <code>docs/DECISIONS.md</code>.</p>
<table>
<thead>
<tr>
<th>Momento</th>
<th>Commit</th>
<th>Hora</th>
<th style="text-align: right;">Líneas de Rust</th>
<th style="text-align: right;">Pruebas</th>
<th style="text-align: right;">ADR</th>
</tr>
</thead>
<tbody>
<tr>
<td>texto anterior</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 terminado</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>constructor web y espejo de versiones</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>MQTT en claro, documentación</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>hoy</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>Se trabajó en paralelo en varias ramas, así que el orden de los commits no es estrictamente el orden del trabajo. Los ADR están numerados hasta el 232, pero la numeración tiene huecos (una parte se renombró al fusionar ramas); contamos los títulos, no el número más alto. Versiones: v0.2.0 (1.11., 18:45 UTC) y v0.2.1 (1.11., 20:22 UTC), ambas marcadas como pre-release.</p>
<h2 id="h6-sqlite-en-modo-usuario">H6: SQLite en modo usuario</h2>
<p><strong>Qué habíamos planeado.</strong> SQLite son 254 268 líneas de C que analizan la base de datos, el WAL y el diario desde el disco, y el disco puede estar fabricado o modificado. Hasta entonces funcionaba dentro del núcleo, con los privilegios del núcleo. <strong>Qué construimos</strong> (ADR-210 a ADR-212): SQLite, sus partes de libc y el heap se trasladaron a un programa aparte, <code>dbworker</code>, que se ejecuta en EL0 (aarch64) o en ring 3 (x86_64), en su propio espacio de direcciones. El núcleo ya no enlaza SQLite. El worker habla con el núcleo a través de una ventana de memoria compartida y doce llamadas al sistema, y cada longitud y cada desplazamiento devueltos se comprueban antes de usarse. Un fallo en el worker (acceso no permitido, instrucción indefinida, superado el presupuesto de tiempo de 30 s) termina la llamada, el worker se reinicia y la base de datos se abre desde el WAL como en el arranque; tras 5 reinicios en un minuto el almacenamiento se declara defectuoso y no se reinicia indefinidamente.</p>
<p>El coste, medido en aarch64 bajo HVF, la misma VM, núcleo con SQLite integrado frente a núcleo con worker, seis ejecuciones alternas de cada uno:</p>
<table>
<thead>
<tr>
<th>Medición</th>
<th>Antes (en el núcleo)</th>
<th>Después (worker)</th>
<th>Diferencia</th>
</tr>
</thead>
<tbody>
<tr>
<td>entrada y salida del modo usuario</td>
<td>-</td>
<td>1,1 us por vuelta</td>
<td></td>
</tr>
<tr>
<td><code>db-bench</code>, 5000 inserciones en 50 transacciones, mejor / mediana</td>
<td>61,9k / 58,4k op/s</td>
<td>54,7k / 53,3k op/s</td>
<td>-11,6 % / -8,7 %</td>
</tr>
<tr>
<td>una inserción por transacción, mejor / mediana</td>
<td>2319 / 2148 op/s</td>
<td>2307 / 2126 op/s</td>
<td>-0,5 % / -1 %</td>
</tr>
<tr>
<td>copia de seguridad completa en una llamada (23 MiB)</td>
<td>514 ms</td>
<td>635 ms</td>
<td>+23 %</td>
</tr>
</tbody>
</table>
<p>Las escrituras en disco (1572) y los flushes (319) son idénticos, como debe ser. Lo que no está aislado es esto: <strong>el núcleo sigue confiando en lo que devuelve el worker</strong>, tras comprobar su forma. Un worker comprometido puede mentir (por ejemplo, devolver una lista vacía de certificados revocados y así "desrevocarlos"), pero no puede leer la memoria del núcleo, ni las claves TLS, ni las claves de los tickets. No hay KPTI, así que las fugas de tipo Spectre/Meltdown no están impedidas y no afirmamos nada sobre ellas; la clave PAC la comparten EL0 y EL1. En x86_64 no medimos el coste del cambio en KVM (bajo TCG la cifra no tendría sentido), y el fuzzing del propio worker y del protocolo no está terminado. El conjunto de pruebas de almacenamiento (79 escenarios, cada uno ejecuta SQLite en el worker) pasa en aarch64 HVF y en x86_64 TCG, y en aarch64 TCG pasan 76 (uno se omite porque TCG no tiene MSI-X).</p>
<p><strong>H7 está cerrado, pero no como anunciamos.</strong> El límite inferior del tiempo (ADR-050) y SNTP con consenso (T1) cubren el alcance de H7; NTS (RFC 8915) queda fuera del alcance y está anotado como riesgo restante, no como algo que funcione. No consideramos que la seguridad del tiempo esté "resuelta", sino que su parte demostrable está hecha.</p>
<p><strong>Nueva opción de red.</strong> Contabo no da DHCP a una ISO personalizada, así que hasta entonces el VPS necesitaba una imagen distinta de la de la VM de laboratorio. Ahora <code>network.mode = "auto"</code> (ADR-214) pregunta primero al DHCP y, si no hay concesión en el plazo configurado (<code>dhcp_timeout_s</code>, por defecto 15 s) o la pierde, aplica la dirección estática por defecto. Probado a mano en QEMU (con DHCP usa la concesión, sin red vuelve a la dirección estática a los 5 s); todavía no hay un escenario de smoke para esto.</p>
<h2 id="x11-comparacion-con-mosquitto-en-el-mismo-vps">X11: comparación con Mosquitto en el mismo VPS</h2>
<p>El mismo tipo de máquina que en el texto anterior (Contabo, 4 vCPU AMD EPYC, 8 GiB), carga desde un host Linux a 15 ms, TLS 1.3 mutuo con los mismos certificados en ambos lados, Mosquitto 2.0.21 en Debian 13 sin persistencia. El método y las decisiones están en el ADR-213, los datos en bruto en <code>docs/perf-results/</code>. Mediana de tres ejecuciones.</p>
<figure><img loading="lazy" alt="Handshakes completos por segundo (TLS 1.3 + certificado de cliente + CONNECT, sin reanudación de sesión) con 32, 64 y 128 clientes: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. Son límites inferiores: el generador de carga estaba cerca de su propio límite." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/mosquitto.svg" /><figcaption>Handshakes completos por segundo (TLS 1.3 + certificado de cliente + CONNECT, sin reanudación de sesión) con 32, 64 y 128 clientes: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. Son límites inferiores: el generador de carga estaba cerca de su propio límite.</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 clientes (perfil bench)</td>
<td>169 / 278 / 413</td>
<td>82 / 140 / 184</td>
</tr>
<tr>
<td>lo mismo, con los límites con los que se entrega</td>
<td>185 / 262 / 333 (con 128: 202 a 286 errores, resets de handshake)</td>
<td>-</td>
</tr>
<tr>
<td>memoria por conexión inactiva (20 000 conexiones)</td>
<td>0,50 KB de heap del núcleo (0,66 KB con 5000)</td>
<td>26,8 KB de crecimiento del proceso</td>
</tr>
<tr>
<td>latencia p50, QoS 0 / QoS 1, 800 mensajes/s, 64 B</td>
<td>14,0 / 14,8 ms</td>
<td>21,4 / 39,5 ms</td>
</tr>
<tr>
<td>QoS 1, 4800 mensajes/s, entregados/s (p50)</td>
<td>4209 (77 ms)</td>
<td>4235 (197 ms)</td>
</tr>
<tr>
<td>100 suscriptores, 50 mensajes/s: p50</td>
<td>16,6 ms</td>
<td>99 ms</td>
</tr>
<tr>
<td>un flujo, QoS 0, 64 B, publicados/s</td>
<td>2962 (0 a 7733)</td>
<td>22 749</td>
</tr>
</tbody>
</table>
<p>Qué significa y qué no. Los handshakes son <strong>límites inferiores para ambos servidores</strong>: el generador de carga es una VM compartida con 60 a 70 % de tiempo de procesador robado y con 128 hilos estaba cerca de su propio límite, mientras que los núcleos de trabajo de urMQTT estaban ocupados solo un 12 a 13 %. La proporción con 128 clientes (unas 2,2 veces) es la más significativa; con 32 y 64 mide sobre todo la espera de los clientes. Las cifras de memoria no cuentan lo mismo (heap del núcleo frente a RSS del proceso), así que "unas 50 veces menos" es un orden de magnitud, no una proporción precisa. La latencia de 14 ms es una vuelta de red (unos 15 ms), es decir, "sin tiempo medible del broker"; las colas (p99 de unos 100 a 200 ms) están dominadas en ambos brokers por los hosts compartidos. No afirmamos diferencias inferiores a un 20 % aproximadamente.</p>
<p><strong>Lo que no salió a nuestro favor</strong> y quedó en las tablas: un solo flujo TCP a través de urMQTT transportó de 2 a 8 veces menos que a través de Mosquitto, con variaciones de cero a 7,7 mil mensajes por segundo entre ejecuciones, y el watchdog de 20 ms del manejador <strong>cortaba conexiones vivas</strong> cuando el hipervisor detenía una vCPU (en la configuración de producción <code>action = "drop"</code>, de 6 a 8 conexiones por ejecución con inundación QoS 0). Ambas cosas se describen en la sección siguiente porque tenían causas en nuestro código.</p>
<h2 id="por-que-el-flujo-unico-era-lento-causas-no-excusas">Por qué el flujo único era lento: causas, no excusas</h2>
<p>Lo más fácil habría sido decir "VPS compartido". No lo dijimos; con el mismo VPS y el mismo método buscamos las causas. Se encontraron cinco, cuatro en el camino de datos y una en el watchdog (ADR-221 a ADR-224):</p>
<table>
<thead>
<tr>
<th>Síntoma en X11</th>
<th>Causa en urMQTT</th>
<th>Cambio</th>
</tr>
</thead>
<tbody>
<tr>
<td>un flujo 1,9k mensajes/s, a menudo <strong>0 entregados</strong></td>
<td><strong>Despertar perdido:</strong> el bucle de salida devolvía la lista de conexiones listas por encima de la que acababa de escribir; un suscriptor cuyo búfer de salida se llenaba una vez no volvía a ser atendido hasta que un PINGRESP lo despertaba</td>
<td>corregido (ADR-223)</td>
</tr>
<tr>
<td>unos pocos por ciento de pérdida convierten 48k en 14k mensajes/s</td>
<td><strong>todo segmento posterior a uno perdido se descartaba</strong> (recepción solo en orden), así que la recuperación costaba una vuelta de red por segmento</td>
<td>cola de segmentos fuera de orden, SACK (ADR-222)</td>
</tr>
<tr>
<td>techo de 4,4 MB/s con 15 ms</td>
<td>ventana de 16 bits, cola de envío de 64 KiB y un arranque lento que contaba ACK en lugar de bytes</td>
<td>escalado de ventana, marcas de tiempo, conteo por bytes (ADR-222)</td>
</tr>
<tr>
<td>cada vuelta de un flujo que cabe espera de 0 a 10 ms</td>
<td><strong>el despachador dormía hasta el siguiente tick de 10 ms</strong> (virtio-net no tiene interrupción de recepción)</td>
<td>sondeo de la NIC cada 200 us mientras haya conexiones (ADR-224)</td>
</tr>
<tr>
<td>de 1 a 8 conexiones cortadas por ejecución</td>
<td><strong>el watchdog le cobraba al manejador el tiempo transcurrido</strong>, y el VPS detiene las vCPU a menudo y durante mucho tiempo</td>
<td>el presupuesto es el tiempo que el núcleo realmente trabajó (ADR-221)</td>
</tr>
</tbody>
</table>
<p>Las detenciones son reales: en este VPS los núcleos 0 a 2 tuvieron una mediana de 36 a 80 ticks retrasados por cada 5 segundos, y la peor detención en la ventana tuvo una mediana de unos 70 ms y llegó a 0,9 s (núcleo 3, base de datos y diario: hasta 1,55 s), mientras que el host no informa de tiempo robado (<code>host steal 0 us</code>). Por eso el watchdog necesitaba otro reloj, no un presupuesto mayor.</p>
<figure><img loading="lazy" alt="Antes y después, en el mismo VPS con el mismo método, mediana de tres a seis ejecuciones: un flujo QoS 0, 64 B: publicados 1,9k, 23,7k (Mosquitto 29,3k), entregados 0 (mediana), 12,6k (29,2k). 16 pares, inundación, 64 B: publicados 24,7k, 43,2k (31,4k), entregados 20,0k, 37,4k (30,7k)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/before-after.svg" /><figcaption>Antes y después, en el mismo VPS con el mismo método, mediana de tres a seis ejecuciones: un flujo QoS 0, 64 B: publicados 1,9k, 23,7k (Mosquitto 29,3k), entregados 0 (mediana), 12,6k (29,2k). 16 pares, inundación, 64 B: publicados 24,7k, 43,2k (31,4k), entregados 20,0k, 37,4k (30,7k).</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>código de X11 (7f9517e)</th>
<th>ahora (86a71ff)</th>
<th>Mosquitto</th>
</tr>
</thead>
<tbody>
<tr>
<td>un flujo, QoS 0, 64 B, publicados / entregados por s</td>
<td>1,9k / 0</td>
<td>23,7k / 12,6k</td>
<td>29,3k / 29,2k</td>
</tr>
<tr>
<td>un flujo, 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 pares, inundación, 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 pares, inundación, 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>conexiones perdidas por ejecución (16 pares, 64 B)</td>
<td>13 (7 a 16)</td>
<td>0 (0 a 0)</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Watchdog: 148 conexiones cortadas en 30 ejecuciones con carga antes, 0 en 24 ejecuciones después; en un aparato local que retrasa y pierde tramas, con una pérdida del 0,1 % un flujo de 64 B con 14,3k mensajes/s (núcleo de X11) ahora tiene 41,0k, y con 0,5 % pasa de 5,0k a 17,7k.</p>
<p><strong>Lo que esto no dice.</strong> urMQTT <strong>sigue sin ser más rápido que Mosquitto en un solo flujo</strong>: con 64 B publica alrededor del 80 % de su velocidad y entrega alrededor del 40 %, porque descarta QoS 0 cuando el flujo del suscriptor es más lento que el del publicador (Mosquitto, sin límite, lo pone en cola). Con 1 KiB en la inundación de 16 pares Mosquitto va por delante en un 15 % aproximadamente, lo cual está dentro del ruido. El generador volvió a ser una VM compartida con 58 a 77 % de tiempo robado. La segunda ronda (bloqueo del heap fuera del camino de datos, SACK en el lado de envío y otras cosas) está en la rama no integrada <code>perf-single-flow-wip</code>; sus cifras todavía no son afirmaciones y no las damos aquí.</p>
<h2 id="una-promesa-que-no-hicimos-pero-cumplimos-la-configuracion-es-un-dato">Una promesa que no hicimos, pero cumplimos: la configuración es un dato</h2>
<p>Hasta ahora cada configuración era una compilación nueva: las claves y los límites estaban integrados en la imagen. C1 lo invierte (ADR-215 a ADR-219): el núcleo se compila una vez y la configuración es un dato.</p>
<ul>
<li><strong>Blob <code>urmqtt-cfg</code></strong> (C1a): un registro versionado y canónico (campos por etiquetas crecientes, huella SHA-256, firma Ed25519 separada opcional), 70 campos con valores por defecto y límites, y un decodificador que rechaza todo lo que sea incorrecto.</li>
<li><strong>El núcleo lo lee como módulo de Limine</strong> (C1b), antes que cualquier otra cosa, en aarch64 y x86_64. Hay <strong>un núcleo publicado para cada <code>ENV</code> excepto <code>dev</code></strong> (los ELF de aarch64 para <code>release</code>, <code>prod</code> y <code>bench</code> tienen el mismo SHA-256; comprobado solo en aarch64). El núcleo publicado rechaza una configuración de desarrollo y, sin configuración, escribe "refusing to serve" y no sirve nada.</li>
<li><strong>El disco instalado lleva la configuración</strong> y la actualización la conserva (C1c).</li>
<li><strong><code>urmqtt-config</code> y la plantilla de ISO</strong> (C1d): <code>pack</code>, <code>show</code>, <code>verify</code>, y <code>pack --iso</code> sustituye las dos copias del espacio de configuración y los 24 resúmenes de módulos en una ISO ya hecha. El resultado es idéntico byte a byte a la ISO que construye <code>mkiso.sh --config</code>. La plantilla de la versión v0.2.0 tiene 63 111 168 bytes.</li>
<li><strong>Declaración de confianza</strong> (ADR-219): una imagen configurada escribe al arrancar la huella de la configuración (<code>config: fingerprint ...</code>), y la firma es opcional y <strong>está desactivada por defecto</strong>. Una ISO modificada no tiene el resumen oficial; la huella es la forma de ver qué contiene.</li>
</ul>
<h2 id="el-constructor-en-aldotech">El constructor en aldo.tech</h2>
<p>Lo mismo que hace <code>urmqtt-config pack --iso</code> lo hace la página web, con el mismo código: la biblioteca de la herramienta compilada a WebAssembly (1,06 MB, sin importaciones y sin bloques <code>unsafe</code>). La página está en <a href="https://aldo.tech/es/productos/urmqtt-builder/">aldo.tech/es/productos/urmqtt-builder/</a> y existe en los 15 idiomas del sitio (por ejemplo <a href="https://aldo.tech/proizvodi/urmqtt-builder/">hr</a>).</p>
<ul>
<li><strong>Nada sale del navegador.</strong> Los certificados, las claves privadas y la ISO terminada se quedan en el ordenador; la página solo descarga los archivos de la versión desde este sitio, sin seguimiento ni scripts de terceros. En la prueba en vivo ninguna petición llevó material de claves.</li>
<li><strong>Espejo de versiones</strong> (ADR-227): hora a hora (temporizador de systemd, de baja prioridad) copia las versiones desde GitHub, verifica cada una antes de publicarla, y la página comprueba en el navegador la firma de <code>SHA256SUMS</code> con una clave integrada en la página, y solo entonces escribe la configuración en la plantilla. Si la comprobación falla, se bloquea la descarga.</li>
<li><strong>Sencillo por defecto, avanzado si hace falta</strong> (ADR-232): la primera pantalla son tres pasos (dónde funciona, sus certificados, nombre de la imagen). Un único campo para arrastrar archivos clasifica certificado, clave y CA por su contenido. La vista avanzada abre cada uno de los 70 campos, la búsqueda, la importación y la exportación de TOML; ambas vistas editan el mismo estado. La plantilla (63 MB) se descarga mientras el usuario rellena el formulario. En la prueba en vivo, con un espejo de prueba y una clave de prueba, la construcción de la ISO en el navegador tardó de 5 a 18 s.</li>
</ul>
<figure><img loading="lazy" alt="Vista sencilla del constructor: tres pasos, &quot;¿Dónde va a funcionar?&quot;, &quot;Sus certificados&quot; y &quot;Nombre de la imagen&quot;, con una única acción siguiente en la barra lateral." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-simple.png" /><figcaption>Vista sencilla del constructor: tres pasos, &quot;¿Dónde va a funcionar?&quot;, &quot;Sus certificados&quot; y &quot;Nombre de la imagen&quot;, con una única acción siguiente en la barra lateral.</figcaption></figure>
<figure><img loading="lazy" alt="Vista avanzada, capturada en un espejo de prueba con una clave de prueba (la fecha de la versión y la clave de firma no son reales): versión con firma verificada, plantillas, cada campo de la configuración." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-advanced.png" /><figcaption>Vista avanzada, capturada en un espejo de prueba con una clave de prueba (la fecha de la versión y la clave de firma no son reales): versión con firma verificada, plantillas, cada campo de la configuración.</figcaption></figure>
<p><strong>El emulador, opcional.</strong> Junto a "Descargar ISO" está "Probar en el emulador": QEMU compilado a WebAssembly ejecuta la imagen recién construida en una pestaña del navegador. Nada de él se descarga antes del clic (unos 3 MB la primera vez). El emulador es solo BIOS, <strong>sin red</strong> (para que el tráfico del usuario no pase por algún intermediario y rompa el "nada sale del navegador"), necesita unos 1,5 GB de memoria y un navegador de escritorio (probado Chrome; Firefox y Safari no). En él urMQTT llega a <code>URMQTT READY</code> en unos 8 s (con la cuenta atrás omitida) y escribe la misma huella de configuración que la página, lo que demuestra que se arrancó justo esa configuración. El arranque es unas 2 veces más lento que en el QEMU nativo en el mismo Mac (0,82 s frente a 1,9 s), y la criptografía unas 58 veces más lenta porque las instrucciones de hardware están emuladas. Por eso <strong>del emulador no sacamos ninguna afirmación de velocidad</strong>, y la página lo dice antes de iniciarlo. En la construcción hubo que parchear cuatro bytes en el <code>.wasm</code> por un fallo en la traducción de la instrucción POPCNT en el emulador original (todavía no lo hemos notificado); el emulador en sí está bajo GPL, con oferta del código fuente.</p>
<figure><img loading="lazy" alt="Entrada de diagnóstico en el emulador en vivo en aldo.tech: el núcleo escribió la misma huella de configuración que la página, y la pantalla muestra el diagnóstico (en el emulador termina en unos 20 s; sus cifras describen el emulador, no urMQTT)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/emulator-diagnostic.png" /><figcaption>Entrada de diagnóstico en el emulador en vivo en aldo.tech: el núcleo escribió la misma huella de configuración que la página, y la pantalla muestra el diagnóstico (en el emulador termina en unos 20 s; sus cifras describen el emulador, no urMQTT).</figcaption></figure>
<h2 id="mqtt-en-claro-solo-para-redes-en-las-que-confia">MQTT en claro, solo para redes en las que confía</h2>
<p>MQTT en claro (TCP 1883, sin TLS) es muy común en IoT: es el valor por defecto en Mosquitto, en la mayoría de los firmwares de Arduino y ESP, en Tasmota, Zigbee2MQTT y Home Assistant, y se usa también dentro de redes industriales segmentadas, detrás de VPN y en LAN privadas, e incluso en dispositivos sin reloj (los certificados exigen una fecha), sin suficiente flash o procesador para TLS. Sobre una red no fiable no es aceptable: quien está en el camino lee y modifica el tráfico. Por eso P1 (ADR-229 a ADR-231) es una opción explícita y limitada, <strong>desactivada por defecto</strong>, y el puerto TLS se mantiene; con esto no se desactiva TLS, el puerto en claro viene además.</p>
<ul>
<li><strong>La lista de redes permitidas es obligatoria</strong> (<code>allow_from</code>, CIDR) y se comprueba en el SYN: fuera de la lista no hay SYN-ACK ni estado. Todo internet (<code>0.0.0.0/0</code>) exige una opción explícita aparte.</li>
<li><strong>Tres modos de inicio de sesión:</strong> contraseña (por defecto cuando está activado; PBKDF2-HMAC-SHA-256, sal, 10 000 iteraciones, comparación en tiempo constante, bloqueo por dirección), anónimo (una identidad configurada con su propia ACL) y por dirección (<code>ip</code>). El constructor calcula el hash de la contraseña en el navegador y en memoria solo conserva el resumen.</li>
<li><strong>Menos privilegios que mTLS:</strong> un cliente en claro nunca tiene privilegios de servicio, de operador ni de gestión, no tiene <code>$SYS</code> ni consola, tiene sus propias sesiones (<code>$plain/&lt;identidad&gt;/&lt;id del cliente&gt;</code>, solo limpias), y sus propios límites y el registro de permitidos lo siguen obligando.</li>
<li><strong>Lo que se pierde está en <code>SECURITY.md</code>:</strong> confidencialidad, integridad, autenticidad del broker y repetición de un inicio de sesión capturado. Esto no se puede arreglar con la configuración; así es cuando no hay TLS.</li>
</ul>
<p>Medido en un portátil (Apple silicon, HVF, red de usuario de QEMU), 16 pares, 64 B, tres ejecuciones de 10 s: con 8000 mensajes por segundo, el camino TLS y el camino en claro entregan todos los mensajes con la misma latencia (p50 0,17 ms frente a 0,13 a 0,18 ms); conexiones nuevas por segundo: TLS unas 2600 a 2750, en claro anónimo unas 4900 a 7800, y <strong>el inicio de sesión con contraseña es la parte cara</strong>: un PBKDF2 cuesta unos 2,5 ms, así que el número de inicios de sesión nuevos se limita a unos 200 por núcleo de datos por segundo (medido: 308 a 342 en dos núcleos). La inundación en esa máquina no es un instrumento estable (de 20k a 440k entregados por segundo entre ejecuciones) y <strong>no sacamos conclusiones de ella</strong>. La comparación del camino en claro con Mosquitto en el VPS todavía no se ha hecho. Pruebas: el grupo de smoke <code>plaintext</code> 7 de 7 en aarch64 HVF y en x86_64 TCG, un nuevo objetivo de fuzz <code>plain_input</code> (2,3 millones de ejecuciones sin hallazgos).</p>
<h2 id="errores-que-encontro-el-uso-en-vivo">Errores que encontró el uso en vivo</h2>
<p>Dos cosas no las atraparon las pruebas, sino la primera puesta en marcha en el sitio real:</p>
<ul>
<li>La página buscaba los archivos de la versión <strong>un directorio más arriba de lo debido</strong>, así que toda comprobación de firma respondía 404 (corrección 13af301). La prueba usaba un espejo de prueba en otra ruta.</li>
<li><strong>El teclado del emulador nunca llegó al invitado</strong>: la causa no fue una mala traducción de teclas, sino que el diálogo no escuchaba las teclas en absoluto (corrección en el ADR-232, con una entrada de arranque que no necesita teclas).</li>
</ul>
<h2 id="lecciones">Lecciones</h2>
<ul>
<li><strong>Busque la causa, no culpe al entorno.</strong> La mayor parte de las debilidades del flujo único y todas las conexiones cortadas eran nuestras; el hipervisor es real, pero había que medirlo, no suponerlo.</li>
<li><strong>La comparación con otro producto debe incluir también las pérdidas.</strong> Una tabla en la que solo están nuestras victorias no sería una comparación.</li>
<li><strong>El instrumento de medida tiene límites.</strong> Un generador compartido con 60 a 70 % de tiempo robado cambia lo que significan las cifras; por eso escribimos "límite inferior" y no afirmamos diferencias inferiores al 20 %.</li>
<li><strong>El aislamiento no es una prueba.</strong> SQLite en EL0 reduce lo que puede hacer un fallo en él, no aquello en lo que puede mentir.</li>
<li><strong>Una instalación real encuentra lo que las pruebas no piden</strong>: una ruta mal armada y teclas que nadie escucha.</li>
</ul>
<h2 id="lo-que-aun-no-esta-demostrado">Lo que aún no está demostrado</h2>
<ul>
<li>La clave de firma de las versiones es por ahora una clave de prueba; v0.2.0 y v0.2.1 son pre-release, y las imágenes que las acompañan son de desarrollo con certificados de prueba (salvo la plantilla, que no tiene configuración).</li>
<li>13 traducciones de la página del constructor (y de este texto) las escribió una IA y las comprobaron pruebas y muestras, no hablantes nativos.</li>
<li>La comparación con Mosquitto es una sesión, un camino y un generador compartido, en un VPS compartido; las diferencias inferiores al 20 % no son afirmaciones.</li>
<li>El camino en claro no se ha medido en el VPS ni frente a Mosquitto.</li>
<li>Lighthouse en la nueva vista sencilla no está confirmado (vista anterior: accesibilidad 95 o más).</li>
<li>Bare metal, el coste del worker en x86_64 en KVM, Firefox y Safari para el emulador: no se ha medido.</li>
</ul>
<h2 id="lo-que-viene">Lo que viene</h2>
<ul>
<li><strong>D1: broker de demostración público.</strong> El emulador del navegador no lo podemos hacer público: vive en la pestaña de un visitante, es de 20 a 100 veces más lento que KVM, no tiene red entrante y desaparece al cerrar la pestaña; redirigir internet al navegador de otra persona sería además un abuso. Es posible un broker de demostración de verdad: urMQTT en un VPS detrás de un nombre en aldo.tech, con una CA de prueba publicada, una ACL estricta y límites pequeños. Por ahora es solo una idea, no una construcción, y necesita un consentimiento explícito para la exposición pública.</li>
<li><strong>Segunda ronda para el flujo único</strong> (rama <code>perf-single-flow-wip</code>): solo cuando esté medida en el VPS e integrada.</li>
<li><strong>El camino en claro frente a Mosquitto en el VPS</strong>, el alta y la revocación de cuentas para MQTT en claro a través de un canal de gestión firmado.</li>
<li><strong>Después de la publicación:</strong> bare metal (X12 a X16), IOMMU y Secure Boot, cifrado del disco.</li>
</ul>
<p>El código está en <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, y <code>docs/PROGRESS.md</code> sigue siendo la única fuente de verdad sobre lo que realmente funciona, <code>docs/PERFORMANCE.md</code> sobre lo que se ha medido. Pre-alfa, como antes; los informes de errores y los avisos de seguridad según las instrucciones de <code>SECURITY.md</code> son bienvenidos.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT en un VPS real: ISO, handshake de 0,24 ms, disco propio y núcleo reforzado</title>
      <link>https://aldo.tech/es/blog/urmqtt-vps-handshake-disk-hardening/</link>
      <guid isPermaLink="true">https://aldo.tech/es/blog/urmqtt-vps-handshake-disk-hardening/</guid>
      <pubDate>Sat, 31 Oct 2026 08:00:00 +0000</pubDate>
      <description>Qué pasó con las tres promesas del primer texto sobre urMQTT (una sola ISO, disco en bruto sin sistema de archivos, seguridad demostrable) tras los primeros cuatro días de desarrollo, con mediciones, tablas y la lista de lo que aún no está terminado.</description>
      <content:encoded><![CDATA[<p>En el <a href="https://aldo.tech/blog/urmqtt-unikernel-mqtt-broker/">primer texto sobre urMQTT</a> describimos un unikernel que es un broker MQTT, hasta el hito M17, y lo cerramos con tres promesas bajo el título "Lo que viene": una ISO que arranca en varias plataformas, un disco en bruto sin sistema de archivos y una seguridad que se pueda demostrar y no solo afirmar. Nada de eso estaba construido entonces. Este texto sigue lo ocurrido con cada promesa, en el mismo orden que antes: qué habíamos planeado, qué construimos, qué nos sorprendió y qué sigue abierto. De paso mostraremos cuánto tiempo llevó, porque eso también forma parte de la historia.</p>
<p>Una advertencia previa: el proyecto sigue siendo pre-alfa. Las cifras del texto miden código y pruebas, no madurez en producción, y la primera publicación se limita deliberadamente a VPS con KVM. El bare metal queda para más adelante.</p>
<h2 id="como-se-vio-en-el-tiempo">Cómo se vio en el tiempo</h2>
<p>El primer commit se hizo el 28 de octubre de 2026 a las 23:48 (M0). El 31 de octubre a las 23:00 el repositorio tiene 119 commits, repartidos como 3, 39, 38 y 39 por día natural. Digamos enseguida cómo es posible: el proyecto se construye con Claude Code y los commits llevan trailers <code>Co-Authored-By: Claude</code>. Cuatro días naturales de historial no significan que una persona haya escrito 90 000 líneas en cuatro días; significan que el plan, la implementación, las pruebas y los ADR se escriben en bucles cortos y cerrados, y que cada paso termina con el mismo ritual de verificación de siempre. Las cifras de abajo hablan de la cantidad de código y de pruebas, no de cuánto trabajo real se invirtió en todo ello.</p>
<figure><img loading="lazy" alt="Crecimiento del proyecto durante cuatro días con los hitos de M0 a hoy: miles de líneas de Rust (de 1,6 a 89,8), número de funciones #[test] (de 27 a 739) y número de ADR (de 13 a 104), con barras de commits por día (39, 38 y 39 el 29, 30 y 31 de octubre)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/growth.svg" /><figcaption>Crecimiento del proyecto durante cuatro días con los hitos de M0 a hoy: miles de líneas de Rust (de 1,6 a 89,8), número de funciones #[test] (de 27 a 739) y número de ADR (de 13 a 104), con barras de commits por día (39, 38 y 39 el 29, 30 y 31 de octubre).</figcaption></figure>
<p>El gráfico muestra tres curvas que crecen casi a la par, que es justo la intención: las pruebas y las decisiones escritas acompañan al código y no se quedan atrás. La pendiente es más pronunciada el 29 de octubre, cuando se construye todo el camino desde el núcleo hasta el broker. Después las líneas se añaden más despacio, pero las pruebas crecen más rápido que el código (27 pruebas con 1,6 mil líneas al principio, 739 con 89,8 mil hoy).</p>
<table>
<thead>
<tr>
<th>Hito</th>
<th>Commit</th>
<th>Hora</th>
<th style="text-align: right;">Líneas de Rust</th>
<th style="text-align: right;">Pruebas</th>
<th style="text-align: right;">ADR</th>
</tr>
</thead>
<tbody>
<tr>
<td>M0</td>
<td>9412a9c</td>
<td>28.10. 23:48</td>
<td style="text-align: right;">1 592</td>
<td style="text-align: right;">27</td>
<td style="text-align: right;">13</td>
</tr>
<tr>
<td>M5</td>
<td>a61083b</td>
<td>29.10. 01:32</td>
<td style="text-align: right;">9 314</td>
<td style="text-align: right;">88</td>
<td style="text-align: right;">24</td>
</tr>
<tr>
<td>M9</td>
<td>4be8cc9</td>
<td>29.10. 02:21</td>
<td style="text-align: right;">15 729</td>
<td style="text-align: right;">149</td>
<td style="text-align: right;">28</td>
</tr>
<tr>
<td>M11</td>
<td>4800904</td>
<td>29.10. 03:43</td>
<td style="text-align: right;">22 737</td>
<td style="text-align: right;">180</td>
<td style="text-align: right;">30</td>
</tr>
<tr>
<td>M13</td>
<td>a558d5e</td>
<td>29.10. 05:22</td>
<td style="text-align: right;">26 556</td>
<td style="text-align: right;">193</td>
<td style="text-align: right;">32</td>
</tr>
<tr>
<td>M17</td>
<td>7df4f98</td>
<td>29.10. 08:48</td>
<td style="text-align: right;">33 504</td>
<td style="text-align: right;">232</td>
<td style="text-align: right;">36</td>
</tr>
<tr>
<td>S1</td>
<td>ecf72ec</td>
<td>30.10. 00:46</td>
<td style="text-align: right;">41 910</td>
<td style="text-align: right;">299</td>
<td style="text-align: right;">45</td>
</tr>
<tr>
<td>X8</td>
<td>fb1504a</td>
<td>30.10. 19:08</td>
<td style="text-align: right;">56 341</td>
<td style="text-align: right;">456</td>
<td style="text-align: right;">59</td>
</tr>
<tr>
<td>PERF</td>
<td>76f7c36</td>
<td>31.10. 14:19</td>
<td style="text-align: right;">72 511</td>
<td style="text-align: right;">618</td>
<td style="text-align: right;">76</td>
</tr>
<tr>
<td>hoy</td>
<td>HEAD</td>
<td>31.10. 23:00</td>
<td style="text-align: right;">89 790</td>
<td style="text-align: right;">739</td>
<td style="text-align: right;">104</td>
</tr>
</tbody>
</table>
<p>Las líneas son todos los archivos <code>.rs</code> fuera de <code>vendor/</code> y <code>fuzz/</code>, y las pruebas son funciones <code>#[test]</code>. Qué aportó cada tramo, según la fecha del historial de git:</p>
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Hitos</th>
<th>Qué se añadió</th>
</tr>
</thead>
<tbody>
<tr>
<td>29.10.</td>
<td>M0 a M17</td>
<td>núcleo, red, TLS, broker, SQLite, varios núcleos, TCP propio, refuerzo, versión reproducible</td>
</tr>
<tr>
<td>29.10. por la noche y 30.10.</td>
<td>X0 a X10, S1, H2, H3 (X0 empieza el 29.10. a las 20:54)</td>
<td>port a x86_64, ISO universal, un solo disco e ISO autoinstalable, comprobaciones de TLS, clientes atacantes</td>
</tr>
<tr>
<td>31.10.</td>
<td>O1 a O4, T1, PERF, H4, H5, H9, S2 a S6</td>
<td>visibilidad y consola a través de MQTT, modo en vivo y diagnóstico, SNTP, aceleraciones, refuerzo, verificación, cadena de suministro, almacenamiento</td>
</tr>
</tbody>
</table>
<h2 id="promesa-1-una-iso-para-varias-plataformas">Promesa 1: una ISO para varias plataformas</h2>
<p><strong>Qué habíamos planeado.</strong> Un único archivo que arranque en UEFI aarch64, UEFI x86_64 y BIOS x86_64. <strong>Qué construimos.</strong> El port a x86_64 (X0 a X10) y una ISO universal con el bootloader Limine, una ISO híbrida desde la que arrancan todas esas combinaciones; las pruebas de humo la arrancan en cuatro máquinas virtuales. El menú tiene tres entradas: urMQTT (modo en vivo, se ejecuta desde la RAM, no escribe nada en el disco), instalación en el disco de la máquina en la que se ejecuta (la instalación escribe GPT y datos y luego apaga la máquina) y Diagnóstico (detección de hardware y mediciones).</p>
<figure><img loading="lazy" alt="El menú de arranque de la ISO, capturado en el VPS: las entradas &quot;urMQTT&quot;, &quot;Install urMQTT on this machine's disk&quot; y &quot;Diagnostic: hardware detection and benchmarks&quot; con cuenta atrás hasta el arranque automático. La captura es de una ISO de prueba con una cuenta atrás más larga; el valor por defecto es de 10 segundos." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/boot-menu.png" /><figcaption>El menú de arranque de la ISO, capturado en el VPS: las entradas &quot;urMQTT&quot;, &quot;Install urMQTT on this machine's disk&quot; y &quot;Diagnostic: hardware detection and benchmarks&quot; con cuenta atrás hasta el arranque automático. La captura es de una ISO de prueba con una cuenta atrás más larga; el valor por defecto es de 10 segundos.</figcaption></figure>
<p>El primer objetivo real fue un VPS y no un ordenador físico: la decisión es que la primera versión admita solo VPS (KVM) y que el bare metal llegue después. Probamos en un VPS de Contabo con un procesador AMD EPYC (familia 23, Zen 1), 4 vCPU y 8 GiB de RAM, con red virtio-net y disco virtio-scsi. El primer intento nos sorprendió: la ISO conectada como CD virtual arrancó la entrada por defecto, la imagen de producción se negó a servir desde un medio de solo lectura y se reinició, y nos quedamos con un bucle infinito de menú, núcleo y reinicio sin nada legible. Hoy un error fatal en la imagen de producción deja un mensaje en la consola durante 60 segundos antes de reiniciar, y desde un medio óptico escribe que se arrancó desde un medio de solo lectura y se detiene. El modo en vivo en RAM (O3) acabó eludiendo ese problema en el caso habitual.</p>
<p>La entrada de diagnóstico nació de esa experiencia: a una máquina que no vemos tenemos que poder preguntarle qué tiene. Muestra la CPU, la RAM, la red, los discos y los dispositivos PCI, mide el ancho de banda de memoria, el disco y la criptografía, y escribe esas mismas mediciones como JSON en el puerto serie. La escritura en disco solo se permite en el espacio sin asignar y únicamente después de teclear <code>WIPE</code>; en la captura de abajo se omitió.</p>
<figure><img loading="lazy" alt="Pantalla de diagnóstico en el VPS de Contabo (AMD EPYC, KVM, 4 vCPU): sistema, memoria, disco, dispositivos PCI, ancho de banda y latencia de la RAM y mediciones de criptografía, incluidos 244 us por handshake mTLS, X25519 48 us, firma ECDSA 35,7 us y verificación 107 us." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/diag-vps.png" /><figcaption>Pantalla de diagnóstico en el VPS de Contabo (AMD EPYC, KVM, 4 vCPU): sistema, memoria, disco, dispositivos PCI, ancho de banda y latencia de la RAM y mediciones de criptografía, incluidos 244 us por handshake mTLS, X25519 48 us, firma ECDSA 35,7 us y verificación 107 us.</figcaption></figure>
<p><strong>Lo que sigue abierto.</strong> El hardware real. Después de la primera versión viene el bare metal (arranque por USB, NVMe y AHCI, la tarjeta de red de un ordenador de verdad), junto con IOMMU y Secure Boot, porque sin IOMMU un dispositivo malicioso o defectuoso puede escribir en toda la memoria. En esta versión no afirmamos nada al respecto.</p>
<h2 id="el-handshake-de-12-ms-a-024-ms">El handshake: de 1,2 ms a 0,24 ms</h2>
<p>La primera medición del diagnóstico en el VPS dio una cifra incómoda: el handshake mTLS, solo la criptografía del lado del servidor, tardaba 1,2 ms. En aarch64 ya usábamos las instrucciones AES y SHA de hardware, pero x86_64 no tenía nada parecido para la clave pública: RustCrypto es portable y constant-time, y por eso también lento. Tres cambios lo bajaron.</p>
<ul>
<li>Para P-256 incorporamos el ensamblador <code>nistz256</code> de OpenSSL y ring (ADR-130), con tablas de base fija para firmar y para verificar firmas de la CA (ADR-110, ADR-111).</li>
<li>X25519 se reescribió con cuatro miembros de 64 bits y las instrucciones MULX/ADX (ADR-131).</li>
<li>Después perfilamos también el resto (ADR-132, ADR-200).</li>
</ul>
<figure><img loading="lazy" alt="Handshake en el VPS: 1200 us antes, 237 us tras el ensamblador y las tablas; comparación A/B de memcpy en la misma ejecución, 278,8 us con el antiguo, 229,4 us con el nuevo; y antes y después para X25519 (146 a 48 us), firma ECDSA (673 a 34 us) y verificación (309 a 90 us)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/handshake.svg" /><figcaption>Handshake en el VPS: 1200 us antes, 237 us tras el ensamblador y las tablas; comparación A/B de memcpy en la misma ejecución, 278,8 us con el antiguo, 229,4 us con el nuevo; y antes y después para X25519 (146 a 48 us), firma ECDSA (673 a 34 us) y verificación (309 a 90 us).</figcaption></figure>
<table>
<thead>
<tr>
<th>Medición en el VPS (servidor, microsegundos)</th>
<th style="text-align: right;">Antes</th>
<th style="text-align: right;">Después</th>
</tr>
</thead>
<tbody>
<tr>
<td>handshake mTLS, toda la criptografía</td>
<td style="text-align: right;">1200</td>
<td style="text-align: right;">alrededor de 240 (237 en la medición del gráfico, 244 en la captura)</td>
</tr>
<tr>
<td>X25519</td>
<td style="text-align: right;">146</td>
<td style="text-align: right;">48</td>
</tr>
<tr>
<td>firma ECDSA</td>
<td style="text-align: right;">673</td>
<td style="text-align: right;">34</td>
</tr>
<tr>
<td>verificación ECDSA</td>
<td style="text-align: right;">309</td>
<td style="text-align: right;">90 (107 en la captura)</td>
</tr>
<tr>
<td>handshake, memcpy antiguo, mismo arranque</td>
<td style="text-align: right;">278,8</td>
<td style="text-align: right;">229,4 (-17,7%)</td>
</tr>
</tbody>
</table>
<p>Las cifras varían alrededor de un 10% entre ejecuciones, así que anotamos un rango en lugar de un único valor. En total son aproximadamente cinco veces menos tiempo de CPU por handshake.</p>
<p>Lo que aprendimos sobre memcpy. Cuando la criptografía bajó, el perfil mostró algo que no esperábamos: las funciones <code>mem*</code> (memcpy, memmove, memset) consumían el 7% del handshake, del 13 al 39% del camino del registro TLS (39% con 64 B, 13% con 4 KiB) y del 17 al 34% de la distribución de PUBLISH. Las sustituimos por ensamblador SSE2 para x86_64 (ADR-200), con pruebas diferenciales contra la implementación de referencia para todos los tamaños, alineaciones y solapamientos. Por llamada son de 55 a 75 ciclos frente a 7 a 12 para 1 a 64 bytes.</p>
<figure><img loading="lazy" alt="Izquierda: proporción del tiempo del servidor en las funciones mem* antes de la reescritura (handshake 7%, registro TLS 64 B 39%, 256 B 37%, 1 KiB 24%, distribución 17 a 34%). Derecha: cuánto más rápido es cada camino después (handshake 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, distribución 7 a 34%)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/hotpaths.svg" /><figcaption>Izquierda: proporción del tiempo del servidor en las funciones mem* antes de la reescritura (handshake 7%, registro TLS 64 B 39%, 256 B 37%, 1 KiB 24%, distribución 17 a 34%). Derecha: cuánto más rápido es cada camino después (handshake 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, distribución 7 a 34%).</figcaption></figure>
<table>
<thead>
<tr>
<th>Camino</th>
<th style="text-align: right;">Proporción de mem* antes</th>
<th style="text-align: right;">Más rápido después</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>registro TLS 64 B</td>
<td style="text-align: right;">39%</td>
<td style="text-align: right;">26,4%</td>
</tr>
<tr>
<td>registro TLS 256 B</td>
<td style="text-align: right;">37%</td>
<td style="text-align: right;">19,6%</td>
</tr>
<tr>
<td>registro TLS 1 KiB</td>
<td style="text-align: right;">24%</td>
<td style="text-align: right;">16,1%</td>
</tr>
<tr>
<td>registro TLS 4 KiB</td>
<td style="text-align: right;">13%</td>
<td style="text-align: right;">6,6%</td>
</tr>
<tr>
<td>distribución de mensajes</td>
<td style="text-align: right;">17 a 34%</td>
<td style="text-align: right;">7 a 34%</td>
</tr>
</tbody>
</table>
<p>Cómo leer el gráfico: cuanto más pequeño es el registro, mayor parte del tiempo se va en copiar, y por eso la mejora es mayor. Para los mensajes pequeños, que son la mayoría del tráfico MQTT, eso es lo que cuenta.</p>
<p>Lo que esto no significa. En x86_64 nativo medimos OpenSSL 3.5.5 (ADR-130) solo como referencia para el ensamblador, no como comparación de productos. La comparación de Linux más Mosquitto en el mismo VPS (tarea X11) todavía no la hemos hecho. Por eso no afirmamos que urMQTT sea más rápido que Linux: no está medido. Las cifras de arriba comparan urMQTT consigo mismo.</p>
<p>La sobrecarga fue el segundo resultado. En el VPS, desde un solo Mac (RTT 35 ms), 128 clientes dieron 618 handshakes por segundo, 256 clientes 393 y 512 clientes 67, con miles de conexiones rechazadas. No fue una meseta sino un colapso. Encontramos las causas en el código (dos se confirmaron en QEMU/HVF y no pudieron reproducirse en el VPS), varias a la vez: una conexión rechazada devolvía al cliente, a través del siguiente segmento, una cookie SYN válida; el rechazo mediante RST le decía al cliente que lo intentara de nuevo de inmediato; y la recepción era O(cola). La corrección (ADR-080 a ADR-082) descarta el SYN que no sería aceptado, recuerda los flujos rechazados, mantiene la recepción en O(1) y cuenta los handshakes por núcleo. Es posible (hipótesis, sin verificar) que parte de la causa estuviera también en el cliente: la herramienta de carga cerraba primero, así que un Mac con 16 384 puertos efímeros y un TIME_WAIT de 30 segundos queda limitado a unas 550 conexiones por segundo. Por eso el techo real del servidor todavía no lo hemos medido.</p>
<h2 id="promesa-2-disco-en-bruto-pero-con-cuidado">Promesa 2: disco en bruto, pero con cuidado</h2>
<p><strong>Qué habíamos planeado.</strong> Mantener nuestra propia disposición sin sistema de archivos y añadir todo lo que un sistema de archivos suele dar: alineación, crecimiento, discard, copias de seguridad y comprobaciones. <strong>Por qué no un sistema de archivos.</strong> Lo discutimos y la decisión se confirmó: urMQTT escribe una base de datos, un registro de telemetría y sus propios metadatos, no necesita directorios, permisos ni nombres de archivo, y cada capa adicional sería más código en la parte de confianza y una manera más de que una escritura desaparezca en silencio. El disco es GPT con una partición ESP y una partición de datos de urMQTT, en la que está la disposición propia.</p>
<p><strong>Qué construimos (S2 a S6).</strong></p>
<ul>
<li><strong>Disposición v2 (S2).</strong> Topología del disco, alineación a 1 MiB o al tamaño óptimo de E/S, bloques físicos completos, un par A/B de superbloques con generación (ADR-140). El núcleo rechaza la disposición v1 con un mensaje claro, y la migra la herramienta sin conexión <code>urmqtt-disk migrate</code> (ADR-141): por defecto es una pasada de prueba, se copia y solo entonces se conmuta, con reversión e inyección de fallos en cada escritura.</li>
<li><strong>Crecimiento (S3).</strong> Al arrancar el disco puede crecer (nunca se reduce), y en virtio-blk también durante el funcionamiento, mediante la interrupción de cambio de configuración (ADR-142, ADR-143).</li>
<li><strong>Discard y write-zeroes (S4).</strong> Los discos con aprovisionamiento ligero devuelven el espacio al anfitrión. En la prueba, el archivo qcow2 del anfitrión bajó de 62 a 16 MiB después de que expiraran los segmentos de telemetría, y la recuperación nunca confía en el contenido que se ha descartado.</li>
<li><strong>Copia de seguridad de la base en disco (S5).</strong> Copia A/B con la API de backup en línea de SQLite en pasos de 16 páginas: el paso más largo fue de 4,6 ms frente a 445 ms al copiar en una sola llamada. Los commits durante la copia tuvieron un máximo de 2,4 ms frente a 2,2 ms sin ella. Si <code>quick_check</code> falla al arrancar, la base se restaura desde la copia.</li>
<li><strong>Limpieza y arranque acotado (S6).</strong> El scrubber lee el disco con un presupuesto de E/S limitado e informa de los registros dañados, y el arranque lee solo el segmento más reciente de cada partición, de modo que el tiempo de arranque no crece con el tamaño de la región de telemetría. Con eso cerramos un problema que habíamos calculado de antemano: en un disco grande, la recuperación completa tardaría de minutos a decenas de minutos.</li>
</ul>
<p>Todo el grupo de pruebas de humo de almacenamiento pasa en ambas arquitecturas: 79 correctas, 0 fallidas, en aarch64 bajo HVF y en x86_64 bajo TCG. También medimos que S4 no ralentizó la base: 53,3 mil inserciones por segundo en lotes antes y después, sin diferencia medible.</p>
<p><strong>Lo que nos sorprendió.</strong> Cuánto de una disposición "sencilla" son casos límite: virtio-scsi y virtio-blk informan de la topología de maneras distintas, y una escritura incompleta a mitad de la migración debe dejar siempre al menos una versión válida. Por eso cada escritura en el camino de la migración y del crecimiento pasó por inyección de fallos.</p>
<p><strong>Lo que sigue abierto.</strong> Las páginas de SQLite no tienen suma de comprobación, de modo que la base no detectará un bit cambiado dentro de un valor de una fila; los registros de telemetría y el contenido de las copias tienen CRC y sí se detectan. El cifrado de disco (S7) no está terminado.</p>
<h2 id="promesa-3-demostrablemente-seguro-hasta-donde-lleguemos">Promesa 3: demostrablemente seguro, hasta donde lleguemos</h2>
<p><strong>Refuerzo (H4).</strong> El asignador borra la memoria liberada y la verifica al reutilizarla, detecta la doble liberación, enlaza los punteros de la lista libre con XOR y pone a cero las claves al liberarlas (ADR-170). Protección de pila (<code>-Zstack-protector=strong</code>, 1315 funciones, y SQLite con <code>-fstack-protector-strong</code>, ADR-171). Los informes de errores en producción no contienen registros ni direcciones (ADR-172). KASLR en ambas arquitecturas: el núcleo es PIE y Limine lo desplaza unos 19 bits, y el heap, las pilas y la ventana MMIO se colocan al azar (ADR-173). En aarch64 MTE etiqueta el heap (ADR-174), pero solo bajo TCG: HVF y x86 no lo tienen. Evaluamos kCFI y no lo adoptamos porque el compilador del nightly fijado falla con un error interno; IOMMU se pospuso junto con el bare metal (ADR-175, ADR-176).</p>
<p><strong>El coste está medido, no supuesto.</strong> En el camino del handshake (<code>dp-bench</code>, aarch64 HVF, handshakes completos de TLS 1.3 + mTLS + CONNECT):</p>
<table>
<thead>
<tr>
<th>Paso</th>
<th style="text-align: right;">1 núcleo</th>
<th style="text-align: right;">4 núcleos</th>
<th>Cambio</th>
</tr>
</thead>
<tbody>
<tr>
<td>sin refuerzo</td>
<td style="text-align: right;">1941</td>
<td style="text-align: right;">7620</td>
<td></td>
</tr>
<tr>
<td>+ asignador</td>
<td style="text-align: right;">1898</td>
<td style="text-align: right;">7429</td>
<td>-2,2% a -2,5%</td>
</tr>
<tr>
<td>+ protección de pila</td>
<td style="text-align: right;">1845</td>
<td style="text-align: right;">7243</td>
<td>juntos -4,9% a -5%</td>
</tr>
<tr>
<td>+ PIE y etiquetas</td>
<td style="text-align: right;">alrededor de 1800</td>
<td style="text-align: right;">alrededor de 6750</td>
<td>en un ordenador cargado, con ruido</td>
</tr>
</tbody>
</table>
<p>En total, el coste en el camino del handshake es de aproximadamente el 5% para las piezas medidas limpiamente, y con PIE y etiquetas sale alrededor del 7% con un núcleo y alrededor del 11% con cuatro, pero esa medición tiene ruido. La consideramos aceptable, pero no la hemos repetido en el VPS x86_64.</p>
<p>Conviene compararlo con la cifra del primer texto (862 handshakes por segundo con un núcleo). La misma herramienta hoy, con el cifrado por hardware activado en aarch64 (X0b) y una serie de aceleraciones llegadas desde entonces, da 1941 antes del refuerzo. No aislamos la contribución de cada cambio, así que no afirmamos nada sobre la causa.</p>
<p><strong>Cómo se verificó, y qué no.</strong> H5 añadió Miri sobre un subconjunto de crates del host, pruebas de Kani, sanitizers, un model-fuzzer para la ACL e informes de cobertura. Kani demostró <code>record_len</code> en TLS, <code>parse</code> en TCP y dos aritméticas de la rueda de temporizadores. <strong>No se demostró</strong> (tiempo agotado): los dos decodificadores MQTT, los dos análisis de configuración y el comportamiento de la rueda de temporizadores; por eso el trabajo de CI de Kani todavía no bloquea. Miri no puede ejecutar ensamblador. Cadena de suministro (H9): SBOM CycloneDX con 215 componentes, <code>cargo-vet</code> pasa con 39 totalmente auditados y 140 excepciones, y cargo-deny está configurado para tratar los avisos de vulnerabilidades como fallo de CI (esos trabajos de CI hasta ahora se han comprobado solo en local, no en GitHub, y la prueba negativa no se ha ejecutado), junto con <code>VULNERABILITY-HANDLING.md</code>. El estado sincero: de 67 versiones de crates de terceros enlazadas en el núcleo, 53 son excepciones y no auditorías. Vet demuestra que el conjunto de dependencias no puede cambiar sin una diferencia visible, no que la TCB esté revisada. No hemos escrito una auditoría propia de ningún crate.</p>
<p>SNTP (T1) es en ese sentido una pequeña historia de seguridad: es opcional, solo hacia servidores IPv4 privados, con un salto máximo y la condición de que las fuentes coincidan (mediana, mayoría dentro de un segundo). Un reloj que cualquiera en el camino puede mover no es un reloj en el que podamos confiar para la validez de los certificados.</p>
<h2 id="en-el-mac-aarch64-de-forma-nativa-y-x86_64-por-emulacion">En el Mac: aarch64 de forma nativa y x86_64 por emulación</h2>
<p>Desarrollamos en un Mac, y este tiene dos maneras de ejecutar el mismo código que son muy distintas.</p>
<table>
<thead>
<tr>
<th></th>
<th>aarch64 bajo HVF</th>
<th>x86_64 bajo TCG</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qué es</td>
<td>ejecución nativa, velocidad cercana a la de un procesador real</td>
<td>QEMU emula las instrucciones</td>
</tr>
<tr>
<td>Handshake en el invitado</td>
<td>unos 0,58 ms (medido por primera vez hacia M13); <code>dp-bench</code> hoy 1941/s por núcleo</td>
<td>unos 3,3 ms</td>
</tr>
<tr>
<td>Escenarios de humo de TLS</td>
<td>rápidos</td>
<td>unos 15 minutos por escenario</td>
</tr>
<tr>
<td>MTE</td>
<td>no (no existe en HVF)</td>
<td>x86 no lo tiene; el etiquetado del heap solo funciona para un invitado aarch64 bajo TCG</td>
</tr>
<tr>
<td>Para qué sirve</td>
<td>mediciones reales</td>
<td>corrección: se verifica el camino x86, las cifras no son rendimiento</td>
</tr>
</tbody>
</table>
<p>La regla es sencilla: las cifras bajo TCG son emulación y no las citamos como rendimiento. El rendimiento de x86 lo medimos solo en un VPS real. Por eso las cifras de criptografía de arriba vienen del VPS de Contabo y no del Mac.</p>
<h2 id="lecciones">Lecciones</h2>
<ul>
<li><strong>Mida en una máquina real antes de optimizar.</strong> Nuestro primer enfoque fue optimizar lo que creíamos lento; solo el diagnóstico en el VPS mostró los 1,2 ms y por dónde se iba exactamente el tiempo.</li>
<li><strong>La sobrecarga es una característica aparte.</strong> Un sistema que es rápido bajo carga puede colapsar por encima de ella; rechazar debe ser más barato que aceptar.</li>
<li><strong>El perfil sorprende.</strong> La mayor mejora individual para los mensajes pequeños no estuvo en la criptografía sino en la copia de memoria.</li>
<li><strong>Un disco sin sistema de archivos exige más trabajo, no menos.</strong> Lo que un sistema de archivos suele dar (alineación, crecimiento, discard, copias, comprobaciones) tenemos que escribirlo y verificarlo nosotros, cada paso con inyección de fallos.</li>
<li><strong>Honestidad en las cifras.</strong> Las mediciones de memoria en un VPS compartido varían hasta 10 veces entre ejecuciones y no dicen nada sobre el código, así que no las citamos. Las cifras de criptografía son estables hasta aproximadamente el 10%.</li>
<li><strong>La velocidad de desarrollo no es madurez.</strong> Las pruebas y los ADR crecen junto con el código, pero cuatro días no son un entorno de producción real.</li>
</ul>
<h2 id="lo-que-viene">Lo que viene</h2>
<ul>
<li><strong>H6:</strong> SQLite aislado a nivel EL0, para que un error en la base de datos no abra el núcleo.</li>
<li><strong>H7:</strong> el resto de la seguridad del tiempo (NTS y tratamiento de saltos); SNTP es solo el primer paso.</li>
<li><strong>X11:</strong> comparación con Linux y Mosquitto en el mismo VPS; solo entonces tiene sentido hablar de velocidad relativa.</li>
<li><strong>Versión:</strong> la primera publicación pública para VPS (KVM), con artefactos firmados e instrucciones.</li>
<li><strong>Después de la versión:</strong> bare metal (X12 a X16), IOMMU y Secure Boot, y cifrado de disco.</li>
</ul>
<p>El código está en <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, y <code>docs/PROGRESS.md</code> sigue siendo la única fuente de verdad sobre lo que realmente funciona. Pre-alfa, como antes; los informes de errores y los avisos de seguridad, según las instrucciones de <code>SECURITY.md</code>, son bienvenidos.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT: construimos un broker MQTT que es también un sistema operativo</title>
      <link>https://aldo.tech/es/blog/urmqtt-unikernel-broker-mqtt/</link>
      <guid isPermaLink="true">https://aldo.tech/es/blog/urmqtt-unikernel-broker-mqtt/</guid>
      <pubDate>Thu, 29 Oct 2026 08:00:00 +0000</pubDate>
      <description>Notas de desarrollo sobre urMQTT, un unikernel escrito en Rust que es por sí mismo un broker MQTT: un único ELF con su propio TCP/IP, TLS 1.3, broker y SQLite, que arranca desde una sola ISO.</description>
      <content:encoded><![CDATA[<p>Los dispositivos en vehículos, en aparcamientos y en armarios de electrónica tienen una cosa en común: envían mensajes pequeños, con poca frecuencia, pero son muchos y están siempre conectados. Llevamos años conectándolos a brokers MQTT, y llevamos años con la misma pregunta. ¿Por qué necesitamos un Linux completo, OpenSSL, el runtime de algún lenguaje, SSH, un gestor de paquetes y, solo entonces, el broker, para algo tan sencillo? Cada una de esas capas aporta sus propias vulnerabilidades, consume su propia memoria y añade una pieza más que puede fallar, y una flota de dispositivos solo necesita tres cosas del servidor: que mantenga muchas conexiones casi inactivas, que autentique de forma fiable a cada dispositivo y que transmita los mensajes sin pérdidas.</p>
<p>Así nació <a href="https://github.com/zeljan-alduk/urMQTT">urMQTT</a>: un unikernel que <em>es</em> un broker MQTT. Núcleo, TCP/IP, TLS 1.3, MQTT y una base de datos integrada en un único binario de Rust que arranca desde una sola ISO. No hay procesos, ni llamadas al sistema, ni shell. La primera plataforma en la que se construye y se prueba es aarch64, y el mismo código avanza hacia x86_64, tanto por UEFI como por BIOS clásico, y hacia hardware real: el objetivo es una única ISO que arranque en todos ellos. El proyecto es de código abierto, con licencia MIT o Apache 2.0, y en este texto queremos dejar constancia de dónde estamos, de cómo está organizado por dentro y de lo que hemos aprendido por el camino.</p>
<h2 id="que-significa-exactamente-unikernel">Qué significa exactamente "unikernel"</h2>
<p>Un broker habitual es un programa que se ejecuta sobre un sistema operativo. urMQTT no tiene ningún sistema operativo debajo: arranca por sí mismo a través del firmware UEFI y del bootloader Limine, configura sus propias tablas de páginas, programa el controlador de interrupciones y el temporizador, encuentra los dispositivos virtio a través de PCIe, obtiene una dirección por DHCP y escucha en un único puerto. Todo lo que normalmente harían el núcleo, los controladores, la pila de red y la aplicación es aquí un único ELF que se ejecuta en el nivel EL1.</p>
<p>La comparación con una instalación típica habla por sí sola:</p>
<table>
<thead>
<tr>
<th></th>
<th>Broker típico en Linux</th>
<th>urMQTT</th>
</tr>
</thead>
<tbody>
<tr>
<td>Servicios expuestos</td>
<td>SSH, broker, a menudo también un puerto de métricas o de administración</td>
<td><strong>solo TCP 8883</strong> (MQTT sobre TLS 1.3 con mTLS)</td>
</tr>
<tr>
<td>Código en la imagen</td>
<td>núcleo, userland, libc, OpenSSL, runtime</td>
<td><strong>un único ELF de Rust</strong> (más SQLite incorporado como dependencia vendorizada)</td>
</tr>
<tr>
<td>Gestión remota</td>
<td>shell, archivos de configuración, actualizaciones de paquetes</td>
<td><strong>ninguna.</strong> Una versión nueva es una ISO nueva</td>
</tr>
<tr>
<td>Memoria por conexión inactiva</td>
<td>kilobytes (búferes de socket, objetos TLS, heap)</td>
<td><strong>objetivo ≤ 512 B</strong></td>
</tr>
<tr>
<td>Modelo multinúcleo</td>
<td>hilos y cerrojos, o un único bucle de eventos</td>
<td><strong>un hilo por núcleo, sin estado compartido, procesamiento hasta el final</strong></td>
</tr>
<tr>
<td>Integridad del código</td>
<td>son posibles mapeos que son a la vez escribibles y ejecutables</td>
<td><strong>W^X, tablas de páginas bloqueadas, PAC/BTI</strong></td>
</tr>
</tbody>
</table>
<p>Las prioridades son, por este orden: seguridad, escalabilidad, rendimiento multinúcleo, huella reducida e instalación inmutable. Siempre que dos objetivos entran en conflicto, gana el que está más arriba en esa lista.</p>
<h2 id="donde-estamos-ahora">Dónde estamos ahora</h2>
<p>El proyecto está en fase pre-alfa, pero se han cerrado diecisiete hitos de desarrollo, de M0 a M17. Hoy urMQTT atiende MQTT 3.1.1 sobre TLS 1.3 con certificados de cliente obligatorios, y guarda el estado en SQLite sobre un disco en bruto. En resumen, esto es lo que ya funciona:</p>
<ul>
<li><strong>Núcleo:</strong> vectores de excepción propios con informes decodificados, tablas de páginas propias con W^X aplicado, páginas de protección, PAC y BTI donde el procesador los tiene, GICv3 y GICv2, temporizador genérico, SMP hasta 64 núcleos.</li>
<li><strong>Detección de hardware</strong> a partir de DTB o ACPI en una única descripción de plataforma, con un resultado idéntico desde ambas fuentes.</li>
<li><strong>Controladores:</strong> PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi y virtio-rng, además de un CSPRNG sembrado con entropía de hardware.</li>
<li><strong>Red:</strong> DHCPv4, ARP, ICMP e IPv4 estricto propios, y un TCP propio solo para el lado servidor: cookies SYN, rueda de temporizadores jerárquica, NewReno, fusión de ACK, sin estado TIME_WAIT.</li>
<li><strong>TLS:</strong> solo TLS 1.3, mTLS obligatorio, identidad del dispositivo a partir del CN o del URI del SAN; el handshake lo realiza rustls y, después, una capa de registros propia con AES-128-GCM y ChaCha20-Poly1305. Reanudación de sesión con tickets PSK, lista de revocación, control de admisión ya en el handshake.</li>
<li><strong>MQTT:</strong> códec sin copias, con fuzzing y probado con clientes mosquitto reales; QoS 0 y 1, comodines, mensajes retenidos, testamento (LWT), sesiones persistentes, ACL limitada a <code>devices/{id}/#</code>, límites de tasa por dispositivo.</li>
<li><strong>Almacenamiento:</strong> SQLite dentro del núcleo mediante un VFS sobre el disco en bruto, en modo WAL, con comprobación de integridad al arrancar; registro de dispositivos, revocaciones, mensajes retenidos, sesiones persistentes con colas de mensajes para los dispositivos que están desconectados; por cada núcleo, un registro de telemetría independiente con CRC por entrada y recuperación tras una caída.</li>
<li><strong>Operación y supervisión:</strong> temporizador de vigilancia para los manejadores, comprobación de salud, registro limitado, syslog y statsd sobre UDP, una admisión que sobrevive a las tormentas de reconexión.</li>
<li><strong>Entrega:</strong> reconstrucciones idénticas bit a bit verificadas en CI, ISO e imagen de disco GPT en bruto, firmas minisign, instrucciones para KVM, Proxmox y Hetzner.</li>
</ul>
<p>Cada paso está marcado con el hito que lo aportó, y el archivo <code>docs/PROGRESS.md</code> del repositorio es la única fuente de verdad sobre lo que realmente funciona.</p>
<h2 id="la-arquitectura-en-tres-imagenes">La arquitectura en tres imágenes</h2>
<p>Todo el diseño está descrito en <code>docs/ARCHITECTURE.md</code>, y las razones de cada decisión importante están registradas como ADR en <code>docs/DECISIONS.md</code>. Aquí tiene un resumen.</p>
<h3 id="las-capas">Las capas</h3>
<figure><img loading="lazy" alt="Capas de urMQTT: desde el broker, pasando por el códec MQTT, TLS, TCP y la red, hasta los controladores virtio y la capa de arquitectura, todo en un único ELF en el nivel EL1." src="https://aldo.tech/blog/urmqtt/img/layers.svg" /><figcaption>Capas de urMQTT: desde el broker, pasando por el códec MQTT, TLS, TCP y la red, hasta los controladores virtio y la capa de arquitectura, todo en un único ELF en el nivel EL1.</figcaption></figure>
<p>De arriba abajo: el broker (sesiones, árbol de temas, QoS, mensajes retenidos, testamentos, ACL, límites), el códec MQTT, TLS, TCP, la capa de red y, por último, los controladores. Junto a ese camino principal están el almacenamiento (SQLite y el registro de telemetría) y, debajo de todo, la gestión de memoria, la detección de la plataforma y la configuración, que se traduce a constantes en tiempo de compilación, junto con el certificado de CA incorporado, el certificado del servidor y la clave. El hipervisor con los dispositivos virtio es lo único que queda fuera de la imagen.</p>
<h3 id="un-hilo-por-nucleo-sin-estado-compartido">Un hilo por núcleo, sin estado compartido</h3>
<figure><img loading="lazy" alt="Modelo de ejecución: cada núcleo de datos tiene su propio bucle y su parte del broker, y el núcleo más alto ejecuta únicamente SQLite. Los núcleos se comunican exclusivamente mediante anillos SPSC." src="https://aldo.tech/blog/urmqtt/img/cores.svg" /><figcaption>Modelo de ejecución: cada núcleo de datos tiene su propio bucle y su parte del broker, y el núcleo más alto ejecuta únicamente SQLite. Los núcleos se comunican exclusivamente mediante anillos SPSC.</figcaption></figure>
<p>Cada núcleo de datos hace girar su propio bucle con su propia pila TCP, sus estados TLS, una parte del broker (sesiones y árbol de temas), una caché del heap y una partición de telemetría. Los núcleos no comparten ningún estado mutable, solo anillos acotados y sin cerrojos, uno por cada par ordenado de núcleos. El núcleo más alto ejecuta SQLite y nada más.</p>
<p>La tarjeta de red tiene hoy un único par de colas, que mantiene el núcleo 0. Este dirige cada flujo MQTT, según el hash Toeplitz de su cuádruple, hacia uno de los núcleos de trabajo y envía lo que los núcleos de trabajo devuelven. La sesión vive en su núcleo de origen, determinado por el hash del identificador de cliente, de modo que la toma de control, los testamentos y la durabilidad no cruzan nunca de un núcleo a otro. Una conexión que aterriza en otro núcleo conserva allí su TCP y su TLS, y reenvía el MQTT en claro al núcleo de origen con un control de flujo basado en créditos. Cada mensaje enrutado se distribuye además a las demás partes del broker, cada una de las cuales mantiene una réplica de los mensajes retenidos.</p>
<p>Tres reglas mantienen unido este modelo. <strong>Procesamiento hasta el final:</strong> un paquete recibido se procesa por completo en un único núcleo, desde TCP, pasando por el descifrado de TLS en el sitio, el análisis de MQTT y el enrutamiento, hasta la respuesta cifrada, sin colas de tareas y sin copias a búferes de la aplicación. <strong>Sondeo adaptativo:</strong> bajo carga las colas de recepción se sondean activamente, y en reposo el núcleo vuelve a las interrupciones y a <code>WFI</code>; los manejadores de interrupciones solo reconocen la interrupción y marcan el trabajo. <strong>Temporizador de vigilancia:</strong> un pulso de 10 ms por núcleo atrapa a los manejadores que superan su presupuesto.</p>
<h3 id="la-vida-de-un-publish">La vida de un PUBLISH</h3>
<figure><img loading="lazy" alt="El recorrido de un mensaje PUBLISH con QoS 1: desde el registro TLS en la tarjeta de red, pasando por el descifrado, la comprobación de ACL y el enrutamiento, hasta el PUBACK fusionado con el ACK de TCP en un solo segmento." src="https://aldo.tech/blog/urmqtt/img/publish.svg" /><figcaption>El recorrido de un mensaje PUBLISH con QoS 1: desde el registro TLS en la tarjeta de red, pasando por el descifrado, la comprobación de ACL y el enrutamiento, hasta el PUBACK fusionado con el ACK de TCP en un solo segmento.</figcaption></figure>
<p>El dispositivo envía un registro TLS. La tarjeta de red lo entrega al bucle del núcleo mediante un descriptor, sin copias. El bucle comprueba TCP, descifra TLS en el sitio y analiza MQTT desde el búfer prestado; después, en memoria, comprueba la ACL, el límite de tasa y la ruta por el árbol de temas. Si a otro núcleo le interesa ese tema, recibe un mensaje de enrutamiento con una referencia al contenido y el identificador del tema; la base de datos recibe un registro de telemetría o la actualización de un mensaje retenido, por lotes. La respuesta PUBACK se cifra directamente en el búfer de salida y se fusiona con el ACK de TCP, de modo que al dispositivo le sale un solo segmento. Al otro núcleo, el mensaje le cuesta un cifrado por suscriptor en el momento del envío.</p>
<h2 id="cuanto-cuesta-una-conexion">Cuánto cuesta una conexión</h2>
<p>El objetivo es <strong>como máximo 512 bytes por conexión TLS inactiva</strong>, con la aspiración de llegar a 256. Mientras la conexión está en reposo no se guarda nada para ella en búferes: los datos recibidos se procesan directamente desde la trama de red, y los búferes de envío, de registros y de MQTT existen solo mientras haya algo inacabado o sin confirmar. El propio núcleo lo mide: contadores exactos del heap, tanto en bytes solicitados como en los bloques que ocupan, anotados cada diez segundos como heap por encima del nivel base por conexión abierta.</p>
<table>
<thead>
<tr>
<th>Parte</th>
<th style="text-align: right;">Tamaño</th>
<th>Cómo</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bloque de control TCP</td>
<td style="text-align: right;">96 B</td>
<td>banderas empaquetadas, RTT/RTO de 16 bits, ranura en la rueda de temporizadores, sin búferes en reposo</td>
</tr>
<tr>
<td>Ranura en la tabla TCP</td>
<td style="text-align: right;">4–8 B</td>
<td>solo el índice de la conexión, tabla llena como máximo a la mitad</td>
</tr>
<tr>
<td>Conexión y estado del registro TLS 1.3</td>
<td style="text-align: right;">152 B</td>
<td>secreto y secuencia por sentido (40 B); la clave y el IV se derivan por registro</td>
</tr>
<tr>
<td>Conexión MQTT</td>
<td style="text-align: right;">48 B</td>
<td>identidad y testamento en el heap solo mientras existen</td>
</tr>
<tr>
<td>Sesión MQTT, forma caliente</td>
<td style="text-align: right;">64 B</td>
<td>una suscripción en línea, identificadores de nodo internados</td>
</tr>
<tr>
<td>Suscripción <code>devices/&lt;id&gt;/cmd/#</code></td>
<td style="text-align: right;">0 nodos del árbol</td>
<td>almacenada de forma relativa al identificador de sesión, común a todos los dispositivos</td>
</tr>
</tbody>
</table>
<p>Medido bajo HVF con cuatro núcleos virtuales, tres de ellos de trabajo, con cada dispositivo manteniendo una conexión mTLS y una suscripción con QoS 1, incluido el redondeo del asignador:</p>
<table>
<thead>
<tr>
<th style="text-align: right;">Conexiones</th>
<th style="text-align: right;">Bytes por conexión</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>El pool de tramas de red es fijo, de unos 2 MiB, lo que equivale a unos 200 B por conexión con diez mil conexiones y a unos 2 B con un millón. No pudimos medir más allá: la red de usuario de QEMU en macOS dejó de aceptar conexiones en 10 197, aproximadamente en el límite de 10 240 descriptores del proceso de QEMU. La cifra baja a medida que las partes fijas se reparten entre más conexiones, por lo que no crece con su número.</p>
<h2 id="modelo-de-seguridad">Modelo de seguridad</h2>
<p>La seguridad es la prioridad número uno y gana todos los conflictos con los demás objetivos. El modelo tiene varias capas:</p>
<ul>
<li><strong>Un solo puerto.</strong> TCP 8883, solo TLS 1.3, certificados de cliente obligatorios firmados por la CA incorporada en la imagen. El identificador de cliente MQTT debe ser igual al identificador de dispositivo del certificado.</li>
<li><strong>Sin puntos de entrada.</strong> Sin shell, sin entrada por consola, sin SSH, sin API de administración, sin código dinámico, sin SQL accesible por la red y sin sistema de archivos.</li>
<li><strong>Seguridad de memoria.</strong> Rust, con <code>forbid(unsafe_code)</code> en los paquetes de protocolo; cada bloque <code>unsafe</code> lleva un comentario <code>// SAFETY:</code> que verifica un lint, y ante entradas externas no hay pánicos: clippy prohíbe <code>unwrap</code>, <code>expect</code>, la indexación y <code>panic!</code> en los paquetes de la ruta de datos.</li>
<li><strong>Mitigación de la explotación.</strong> W^X se aplica mediante la codificación de descriptores y el bit <code>SCTLR_EL1.WXN</code>, y se audita en cada arranque. Tras el arranque, las tablas de páginas son de solo lectura, sin alias escribible. La mitad inferior del espacio de direcciones queda excluida. Cada pila tiene páginas de protección y las excepciones se ejecutan en una pila aparte. PAC y BTI se activan cuando el procesador los admite, y las pruebas de humo lo comprueban.</li>
<li><strong>Resistencia al abuso.</strong> Cookies SYN; la admisión rechaza nuevas conexiones, antes de cualquier criptografía, cuando el heap está bajo, cuando la base de datos se retrasa, cuando hay demasiados handshakes en curso por núcleo o por dirección de origen, o cuando la tasa de handshakes es demasiado alta. Límites de tasa por dispositivo, colas acotadas, ventana TCP ligada a la contrapresión del broker, registro limitado, temporizador de vigilancia y comprobación de salud.</li>
<li><strong>Inmutabilidad.</strong> La configuración, la CA y la clave del servidor están incorporadas en la imagen. Cambiar cualquiera de ellas significa una imagen nueva.</li>
</ul>
<p>El modelo de amenazas está recogido en <code>docs/SECURITY.md</code>.</p>
<h2 id="almacenamiento-sin-sistema-de-archivos">Almacenamiento sin sistema de archivos</h2>
<p>Los datos viven en un disco aparte, virtio-blk o virtio-scsi, en el que no hay sistema de archivos. La disposición es mínima y propia:</p>
<pre><code>LBA 0      [ superbloque: magia · versión de la disposición · uuid · tabla de regiones · CRC-32C ]
LBA 8, 16  [ tabla de tamaños de archivo, ranuras A/B (gana la última válida) ]
LBA 64     [ base principal de SQLite ½ ][ WAL de SQLite ⅛ ][ diario 1 MiB ][ registro de telemetría: 8 particiones … ]
últimos 4 KiB [ espacio para la autocomprobación del dispositivo de bloques al arrancar ]
</code></pre>
<p>SQLite (3.54.1 vendorizado, con verificación del resumen en la compilación) se compila dentro del núcleo con clang, sin biblioteca estándar, y utiliza un heap <code>memsys5</code> fijo. Su VFS asigna tres archivos fijos a regiones del disco. Funciona en modo WAL con <code>locking_mode=EXCLUSIVE</code>, por lo que no necesita un índice en memoria compartida, y con <code>synchronous=FULL</code>; si <code>quick_check</code> encuentra un error al arrancar, el sistema se detiene de forma segura. En la base de datos están el registro de dispositivos, las revocaciones, los mensajes retenidos y las sesiones persistentes con colas de mensajes QoS 1 para los dispositivos que no están conectados.</p>
<p>El núcleo de la base de datos, el que tiene el número más alto, ejecuta SQLite y nada más. El plano de datos le envía los cambios de estado a través de un anillo SPSC sin cerrojos; este los agrupa en una transacción cada 5 ms o cada 512 operaciones y devuelve las confirmaciones por otro anillo. Un disco vacío se formatea en el primer arranque; en cualquier otro caso, nunca se formatea encima.</p>
<p>La telemetría sigue otro camino. Cada PUBLISH aceptado en los temas de <code>storage.telemetry_topics</code> va a la partición del registro de telemetría de su núcleo: un anillo de segmentos con CRC por entrada, salado con la secuencia del segmento, de modo que las pasadas antiguas del anillo no reaparezcan nunca. Los núcleos entregan las entradas a la base de datos mediante su propio anillo, sin copias, y esta las escribe cada 5 ms o cada 256 KiB. Al arrancar, la recuperación conserva cada entrada escrita, recorta la cola incompleta y continúa en un segmento nuevo. La retención es por tamaño, a través del anillo, y por antigüedad, mediante <code>retention_days</code>. Lo verificamos con un millón de entradas y con <code>kill -9</code> en mitad de la escritura.</p>
<h2 id="las-cifras">Las cifras</h2>
<p>La herramienta de carga de <code>tools/loadgen</code> hace tres cosas: una tormenta de handshakes TLS 1.3 con mTLS y CONNECT, conexiones de larga duración con PINGREQ que publican telemetría cada segundo, y tormentas de reconexión. <code>make bench</code> en un Apple M4 Pro bajo HVF da:</p>
<table>
<thead>
<tr>
<th>Núcleos de trabajo</th>
<th>handshakes/s en el invitado</th>
<th>escalado</th>
<th>a través de la red de usuario de QEMU (conexiones/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>La cifra en el invitado, donde el cliente y el servidor están dentro de la misma máquina virtual y cada núcleo de trabajo funciona por su cuenta, muestra que el plano de datos escala de forma lineal. La cifra de red está limitada por la red de usuario de un solo hilo de QEMU, no por el invitado; y así consta en el ADR-032.</p>
<p>Probamos las tormentas de reconexión haciendo que todos los dispositivos se conecten a la vez, repitan con espera los intentos rechazados y luego caigan todos sin DISCONNECT. El parámetro <code>heap_limit_mb=</code> en el modo de desarrollo hace que la admisión considere el heap más pequeño de lo que es, de modo que una tormenta mayor que el heap puede probarse con pocos sockets. En una de esas rondas con 3 000 dispositivos, se conectaron 1 368 en 36 segundos y 5 619 intentos fueron rechazados de forma ordenada, con un límite de 8 MiB; la memoria se recuperó tras la tormenta.</p>
<h2 id="como-trabajamos">Cómo trabajamos</h2>
<p>El proyecto se guía por una especificación escrita, <code>PROMPT.md</code>, con un plan de hitos, y cada hito termina con el mismo ritual: <code>make check</code> y <code>make smoke</code> en verde, un commit con la etiqueta <code>M&lt;n&gt;</code>, una entrada en <code>docs/PROGRESS.md</code> y un ADR para cada decisión que no sea obvia. Las reglas son breves y no negociamos sobre ellas: sin pánicos ante entradas externas, <code>unsafe</code> solo en los paquetes de hardware y FFI y siempre con comentario, sin asignaciones en la ruta caliente, un comentario junto a cada orden de operaciones atómicas, cada límite procede de la configuración, y las piezas provisionales deliberadas fallan de forma ruidosa.</p>
<p>El CI hace más de lo que podríamos hacer a mano: fmt, clippy para el host y para <code>aarch64-unknown-none</code>, typos, actionlint y shellcheck; pruebas en Linux y en macOS sobre arm64; compilación del núcleo para la configuración de desarrollo y la de producción con el tamaño del ELF en el resumen; arranque bajo QEMU con todos los escenarios de la prueba de humo y almacenamiento de los registros serie; cargo-deny para avisos, licencias y fuentes; un minuto de fuzzing por cada uno de los once objetivos y, por la noche, diez minutos por objetivo con un corpus en caché. Además, CodeQL, OpenSSF Scorecard, Dependabot y una publicación con procedencia SLSA. Cada acción de terceros está fijada al SHA completo del commit.</p>
<p>Entendemos la reproducibilidad de forma literal: <code>make repro</code> compila HEAD dos veces en árboles de trabajo nuevos y compara el ELF, la ISO y la imagen en bruto byte a byte. Las versiones publicadas están firmadas con minisign, y los artefactos pueden verificarse con <code>gh attestation verify</code>. La imagen de producción contiene la clave privada del servidor, por lo que la ISO misma es un secreto; las imágenes que acompañan a las versiones en GitHub son de desarrollo, con claves temporales, y sirven solo para probar.</p>
<h2 id="lo-que-hemos-aprendido">Lo que hemos aprendido</h2>
<p>Algunas lecciones que cambiaron el plan:</p>
<ul>
<li><strong>Un TCP propio terminó mereciendo la pena.</strong> El prototipo funcionó sobre smoltcp y nos sirvió bien hasta M13. Pero un servidor que mantiene cientos de miles de conexiones no necesita un TCP de cliente, necesita cookies SYN, una rueda de temporizadores y un estado de 96 bytes. En M14 sustituimos smoltcp por una pila propia solo para el lado servidor, con pruebas de pérdida simulada y fuzzing de segmentos.</li>
<li><strong>Mida la memoria desde dentro.</strong> Las estimaciones por conexión eran inútiles hasta que el núcleo empezó a contar el heap en bloques del asignador y no en bytes solicitados. Solo entonces vimos adónde van realmente los 512 bytes y bajamos hasta 494.</li>
<li><strong>La herramienta de pruebas forma parte del producto.</strong> La mayor limitación no fue el núcleo, sino la red de usuario de QEMU y el número de puertos efímeros del equipo de desarrollo. Para cien mil conexiones o más se necesita <code>vmnet-shared</code> en macOS o Linux con KVM, una interfaz tap y varias direcciones de cliente.</li>
<li><strong>SQLite sin sistema operativo es posible</strong>, pero solo con condiciones estrictas: un heap fijo, bloqueo exclusivo sin memoria compartida, un VFS que no sabe de archivos y una admisión que no deja que la base de datos se retrase.</li>
<li><strong>Las tormentas de reconexión son el estado normal</strong>, no la excepción. Cuando la red móvil se cae y vuelve, miles de dispositivos llaman a la puerta al mismo tiempo. Por eso la admisión rechaza la conexión antes de cualquier criptografía, y cada conexión rechazada cuesta casi nada.</li>
</ul>
<h2 id="lo-que-viene">Lo que viene</h2>
<p>El trabajo posterior a M17 está planificado en tres direcciones, y el orden entre ellas está recogido en <code>docs/PLAN.md</code>. Nada de esto está construido todavía.</p>
<ul>
<li><strong>Una ISO para varias plataformas.</strong> El mismo archivo <code>urmqtt-&lt;env&gt;-&lt;versión&gt;.iso</code> y una sola imagen GPT en bruto que arrancan en UEFI aarch64, UEFI x86_64 y BIOS x86_64, en máquinas virtuales y en hardware real, empezando por un PC Intel. Siempre un appliance con todo dentro, reproducible y firmado.</li>
<li><strong>Disco en bruto, sin sistema de archivos.</strong> Una especificación del formato a nivel de byte, la herramienta de host <code>urmqtt-disk</code> que comparte los analizadores con el núcleo, alineación de las regiones a 1 MiB, crecimiento del disco con los datos, discard para discos con aprovisionamiento ligero, instantáneas A/B de la base de datos, verificación de bits y, opcionalmente, cifrado AES-256-XTS con AES por hardware.</li>
<li><strong>Seguridad demostrable.</strong> Revocación y lista de dispositivos permitidos en tiempo de ejecución mediante un canal de control firmado, sin imagen nueva; pruebas diferenciales y de conformidad de nuestro propio TLS y TCP; KASLR, protección de pila, kCFI, MTE y un asignador reforzado; SQLite aislado en el nivel EL0; Secure Boot; SBOM y cargo-vet; y, por último, un paquete para una auditoría externa.</li>
</ul>
<p>Las plataformas que ofrecen solo dispositivos que no son virtio, como AWS ENA o Azure MANA, por ahora no son compatibles. Raspberry Pi 4 y 5 como anfitrión KVM, Hetzner CAX y Oracle Ampere A1 están documentados y soportados en el código, pero aún no se han probado en un servicio real.</p>
<h2 id="pruebelo">Pruébelo</h2>
<p>El repositorio está en <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>. Con Rust con la cadena de herramientas nightly fijada y QEMU, <code>make certs</code>, <code>make run</code> y un cliente mosquitto bastan para el primer PUBLISH sobre mTLS en su propio equipo. La advertencia del principio vale también al final: esto es pre-alfa, y todo lo que funciona figura en <code>docs/PROGRESS.md</code>. Los informes de errores, las preguntas sobre el diseño y los avisos de seguridad, según las instrucciones de <code>SECURITY.md</code>, son bienvenidos. Le informaremos aquí de cada nuevo hito.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>código abierto</category>
    </item>
    <item>
      <title>Empezamos un blog: notas desde el terreno</title>
      <link>https://aldo.tech/es/blog/empezamos-un-blog/</link>
      <guid isPermaLink="true">https://aldo.tech/es/blog/empezamos-un-blog/</guid>
      <pubDate>Sat, 17 Oct 2026 08:00:00 +0000</pubDate>
      <description>Por qué una empresa que construye terminales, aplicaciones y electrónica para el transporte público empieza a escribir, y sobre qué leerá aquí.</description>
      <content:encoded><![CDATA[<p>La mayor parte de lo que construimos no la ve nadie. El software del terminal en la puerta del autobús, el firmware de un dispositivo que nunca tendrá pantalla, el servidor que de noche convierte las transacciones del día en informes. Cuando todo funciona, funciona en silencio. Cuando no, todo el mundo se da cuenta.</p>
<p>En este blog anotamos lo que hemos aprendido por el camino. Nada de textos de marketing, sino notas concretas: qué salió mal, cómo lo resolvimos y qué haríamos distinto hoy.</p>
<h2 id="sobre-que-escribiremos">Sobre qué escribiremos</h2>
<ul>
<li><strong>Cobro de billetes en el transporte público.</strong> Tarjetas sin contacto, módulos SAM, funcionamiento sin red y todo lo que un terminal tiene que aguantar en un vehículo que vibra doce horas al día.</li>
<li><strong>Aplicaciones móviles sobre el terreno.</strong> Android y Flutter en dispositivos que no son teléfonos corrientes: con lectores de tarjetas, impresoras y baterías que deben durar un turno entero.</li>
<li><strong>Backend y supervisión de flotas.</strong> Cómo saber que un dispositivo del vehículo tiene un problema antes de que lo avise el conductor.</li>
<li><strong>Hardware.</strong> Del prototipo en la mesa a la placa que entra en producción en serie, y todos los errores intermedios.</li>
<li><strong>Modelos de IA en local.</strong> Dónde tienen sentido los modelos en hardware propio y dónde la nube y una API son sencillamente la mejor opción.</li>
</ul>
<h2 id="como-escribiremos">Cómo escribiremos</h2>
<p>Breve y concreto, con código y cifras cuando las tengamos. Publicaremos cuando tengamos algo que decir, no según un calendario. Las entradas se escriben en croata y se traducen a todos los idiomas de este sitio; también puede seguirnos por <a href="https://aldo.tech/es/blog/feed.xml">RSS</a>.</p>
<p>Si le interesa algún tema o trabaja en un problema parecido, escríbanos a <a href="mailto:info@aldo.tech">info@aldo.tech</a>.</p>]]></content:encoded>
      <category>introduccion</category>
    </item>
  </channel>
</rss>
