urMQTT tras el VPS: SQLite en modo usuario, comparación con Mosquitto, configuración como dato y un constructor en el navegador
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.

El segundo texto, urMQTT en un VPS real, 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.
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.
Cómo se vio en el tiempo
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 Co-Authored-By: Claude), 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.
Las cifras se calcularon con el mismo método que en el texto anterior: todos los archivos .rs fuera de vendor/ y fuzz/, las funciones #[test] y los títulos ## ADR- en docs/DECISIONS.md.
| Momento | Commit | Hora | Líneas de Rust | Pruebas | ADR |
|---|---|---|---|---|---|
| texto anterior | 794f607 | 31.10. 23:00 | 89 790 | 739 | 104 |
| H6 terminado | a0423cc | 1.11. 03:59 | 95 078 | 754 | 107 |
| X11 | a29f49b | 1.11. 07:56 | 95 579 | 754 | 108 |
| constructor web y espejo de versiones | 582783f | 1.11. 16:49 | 105 645 | 848 | 122 |
| MQTT en claro, documentación | 8f959f1 | 1.11. 19:05 | 112 408 | 926 | 125 |
| hoy | d21e3ec | 1.11. 20:41 | 112 408 | 926 | 126 |
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.
H6: SQLite en modo usuario
Qué habíamos planeado. 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. Qué construimos (ADR-210 a ADR-212): SQLite, sus partes de libc y el heap se trasladaron a un programa aparte, dbworker, 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.
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:
| Medición | Antes (en el núcleo) | Después (worker) | Diferencia |
|---|---|---|---|
| entrada y salida del modo usuario | - | 1,1 us por vuelta | |
db-bench, 5000 inserciones en 50 transacciones, mejor / mediana |
61,9k / 58,4k op/s | 54,7k / 53,3k op/s | -11,6 % / -8,7 % |
| una inserción por transacción, mejor / mediana | 2319 / 2148 op/s | 2307 / 2126 op/s | -0,5 % / -1 % |
| copia de seguridad completa en una llamada (23 MiB) | 514 ms | 635 ms | +23 % |
Las escrituras en disco (1572) y los flushes (319) son idénticos, como debe ser. Lo que no está aislado es esto: el núcleo sigue confiando en lo que devuelve el worker, 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).
H7 está cerrado, pero no como anunciamos. 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.
Nueva opción de red. 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 network.mode = "auto" (ADR-214) pregunta primero al DHCP y, si no hay concesión en el plazo configurado (dhcp_timeout_s, 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.
X11: comparación con Mosquitto en el mismo VPS
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 docs/perf-results/. Mediana de tres ejecuciones.
| urMQTT | Mosquitto 2.0.21 | |
|---|---|---|
| handshakes/s, 32 / 64 / 128 clientes (perfil bench) | 169 / 278 / 413 | 82 / 140 / 184 |
| lo mismo, con los límites con los que se entrega | 185 / 262 / 333 (con 128: 202 a 286 errores, resets de handshake) | - |
| memoria por conexión inactiva (20 000 conexiones) | 0,50 KB de heap del núcleo (0,66 KB con 5000) | 26,8 KB de crecimiento del proceso |
| latencia p50, QoS 0 / QoS 1, 800 mensajes/s, 64 B | 14,0 / 14,8 ms | 21,4 / 39,5 ms |
| QoS 1, 4800 mensajes/s, entregados/s (p50) | 4209 (77 ms) | 4235 (197 ms) |
| 100 suscriptores, 50 mensajes/s: p50 | 16,6 ms | 99 ms |
| un flujo, QoS 0, 64 B, publicados/s | 2962 (0 a 7733) | 22 749 |
Qué significa y qué no. Los handshakes son límites inferiores para ambos servidores: 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.
Lo que no salió a nuestro favor 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 cortaba conexiones vivas cuando el hipervisor detenía una vCPU (en la configuración de producción action = "drop", 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.
Por qué el flujo único era lento: causas, no excusas
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):
| Síntoma en X11 | Causa en urMQTT | Cambio |
|---|---|---|
| un flujo 1,9k mensajes/s, a menudo 0 entregados | Despertar perdido: 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 | corregido (ADR-223) |
| unos pocos por ciento de pérdida convierten 48k en 14k mensajes/s | todo segmento posterior a uno perdido se descartaba (recepción solo en orden), así que la recuperación costaba una vuelta de red por segmento | cola de segmentos fuera de orden, SACK (ADR-222) |
| techo de 4,4 MB/s con 15 ms | ventana de 16 bits, cola de envío de 64 KiB y un arranque lento que contaba ACK en lugar de bytes | escalado de ventana, marcas de tiempo, conteo por bytes (ADR-222) |
| cada vuelta de un flujo que cabe espera de 0 a 10 ms | el despachador dormía hasta el siguiente tick de 10 ms (virtio-net no tiene interrupción de recepción) | sondeo de la NIC cada 200 us mientras haya conexiones (ADR-224) |
| de 1 a 8 conexiones cortadas por ejecución | el watchdog le cobraba al manejador el tiempo transcurrido, y el VPS detiene las vCPU a menudo y durante mucho tiempo | el presupuesto es el tiempo que el núcleo realmente trabajó (ADR-221) |
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 (host steal 0 us). Por eso el watchdog necesitaba otro reloj, no un presupuesto mayor.
| código de X11 (7f9517e) | ahora (86a71ff) | Mosquitto | |
|---|---|---|---|
| un flujo, QoS 0, 64 B, publicados / entregados por s | 1,9k / 0 | 23,7k / 12,6k | 29,3k / 29,2k |
| un flujo, QoS 0, 1 KiB | 0 / 0 | 3,9k / 2,3k | 5,4k / 5,4k |
| 16 pares, inundación, QoS 0, 64 B | 24,7k / 20,0k | 43,2k / 37,4k | 31,4k / 30,7k |
| 16 pares, inundación, QoS 0, 1 KiB | 3,7k / 2,9k | 5,0k / 4,5k | 5,9k / 5,7k |
| conexiones perdidas por ejecución (16 pares, 64 B) | 13 (7 a 16) | 0 (0 a 0) | 0 |
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.
Lo que esto no dice. urMQTT sigue sin ser más rápido que Mosquitto en un solo flujo: 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 perf-single-flow-wip; sus cifras todavía no son afirmaciones y no las damos aquí.
Una promesa que no hicimos, pero cumplimos: la configuración es un dato
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.
- Blob
urmqtt-cfg(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. - El núcleo lo lee como módulo de Limine (C1b), antes que cualquier otra cosa, en aarch64 y x86_64. Hay un núcleo publicado para cada
ENVexceptodev(los ELF de aarch64 pararelease,prodybenchtienen 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. - El disco instalado lleva la configuración y la actualización la conserva (C1c).
urmqtt-configy la plantilla de ISO (C1d):pack,show,verify, ypack --isosustituye 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 construyemkiso.sh --config. La plantilla de la versión v0.2.0 tiene 63 111 168 bytes.- Declaración de confianza (ADR-219): una imagen configurada escribe al arrancar la huella de la configuración (
config: fingerprint ...), y la firma es opcional y está desactivada por defecto. Una ISO modificada no tiene el resumen oficial; la huella es la forma de ver qué contiene.
El constructor en aldo.tech
Lo mismo que hace urmqtt-config pack --iso 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 unsafe). La página está en aldo.tech/es/productos/urmqtt-builder/ y existe en los 15 idiomas del sitio (por ejemplo hr).
- Nada sale del navegador. 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.
- Espejo de versiones (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
SHA256SUMScon 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. - Sencillo por defecto, avanzado si hace falta (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.


El emulador, opcional. 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, sin red (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 URMQTT READY 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 del emulador no sacamos ninguna afirmación de velocidad, y la página lo dice antes de iniciarlo. En la construcción hubo que parchear cuatro bytes en el .wasm 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.

MQTT en claro, solo para redes en las que confía
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, desactivada por defecto, y el puerto TLS se mantiene; con esto no se desactiva TLS, el puerto en claro viene además.
- La lista de redes permitidas es obligatoria (
allow_from, CIDR) y se comprueba en el SYN: fuera de la lista no hay SYN-ACK ni estado. Todo internet (0.0.0.0/0) exige una opción explícita aparte. - Tres modos de inicio de sesión: 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 (
ip). El constructor calcula el hash de la contraseña en el navegador y en memoria solo conserva el resumen. - Menos privilegios que mTLS: un cliente en claro nunca tiene privilegios de servicio, de operador ni de gestión, no tiene
$SYSni consola, tiene sus propias sesiones ($plain/<identidad>/<id del cliente>, solo limpias), y sus propios límites y el registro de permitidos lo siguen obligando. - Lo que se pierde está en
SECURITY.md: 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.
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 el inicio de sesión con contraseña es la parte cara: 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 no sacamos conclusiones de ella. La comparación del camino en claro con Mosquitto en el VPS todavía no se ha hecho. Pruebas: el grupo de smoke plaintext 7 de 7 en aarch64 HVF y en x86_64 TCG, un nuevo objetivo de fuzz plain_input (2,3 millones de ejecuciones sin hallazgos).
Errores que encontró el uso en vivo
Dos cosas no las atraparon las pruebas, sino la primera puesta en marcha en el sitio real:
- La página buscaba los archivos de la versión un directorio más arriba de lo debido, así que toda comprobación de firma respondía 404 (corrección 13af301). La prueba usaba un espejo de prueba en otra ruta.
- El teclado del emulador nunca llegó al invitado: 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).
Lecciones
- Busque la causa, no culpe al entorno. 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.
- La comparación con otro producto debe incluir también las pérdidas. Una tabla en la que solo están nuestras victorias no sería una comparación.
- El instrumento de medida tiene límites. 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 %.
- El aislamiento no es una prueba. SQLite en EL0 reduce lo que puede hacer un fallo en él, no aquello en lo que puede mentir.
- Una instalación real encuentra lo que las pruebas no piden: una ruta mal armada y teclas que nadie escucha.
Lo que aún no está demostrado
- 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).
- 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.
- 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.
- El camino en claro no se ha medido en el VPS ni frente a Mosquitto.
- Lighthouse en la nueva vista sencilla no está confirmado (vista anterior: accesibilidad 95 o más).
- Bare metal, el coste del worker en x86_64 en KVM, Firefox y Safari para el emulador: no se ha medido.
Lo que viene
- D1: broker de demostración público. 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.
- Segunda ronda para el flujo único (rama
perf-single-flow-wip): solo cuando esté medida en el VPS e integrada. - El camino en claro frente a Mosquitto en el VPS, el alta y la revocación de cuentas para MQTT en claro a través de un canal de gestión firmado.
- Después de la publicación: bare metal (X12 a X16), IOMMU y Secure Boot, cifrado del disco.
El código está en github.com/zeljan-alduk/urMQTT, y docs/PROGRESS.md sigue siendo la única fuente de verdad sobre lo que realmente funciona, docs/PERFORMANCE.md sobre lo que se ha medido. Pre-alfa, como antes; los informes de errores y los avisos de seguridad según las instrucciones de SECURITY.md son bienvenidos.
Comentarios
Normas de los comentarios