urMQTT: construímos um broker MQTT que é também um sistema operativo
Notas de desenvolvimento sobre o urMQTT, um unikernel escrito em Rust que é, ele próprio, um broker MQTT: um único ELF com TCP/IP próprio, TLS 1.3, broker e SQLite, arrancado a partir de um único ISO.
Os dispositivos instalados em veículos, em parques de estacionamento e em armários de eletrónica têm uma característica em comum: enviam mensagens pequenas, raramente, mas são muitos e estão sempre ligados. Há anos que os ligamos a brokers MQTT e há anos que a mesma pergunta nos incomoda. Porque precisamos de um Linux inteiro, do OpenSSL, do runtime de uma linguagem, de SSH, de um gestor de pacotes e só depois de um broker, para algo tão simples? Cada uma dessas camadas traz as suas próprias vulnerabilidades, consome a sua própria memória e acrescenta mais uma coisa que se pode avariar, e uma frota de dispositivos só precisa de três coisas do servidor: manter muitas ligações quase inativas, autenticar cada dispositivo de forma fiável e transportar as mensagens sem perdas.
Foi assim que nasceu o urMQTT: um unikernel que é um broker MQTT. Kernel, TCP/IP, TLS 1.3, MQTT e uma base de dados integrada num único binário Rust, que arranca a partir de um único ISO. Não há processos, nem chamadas de sistema, nem shell. A primeira plataforma em que é construído e testado é aarch64, e o mesmo código caminha para x86_64, por UEFI e por BIOS clássico, e para hardware real: o objetivo é um único ISO que arranque em todos eles. O projeto é de código aberto, sob licença MIT ou Apache 2.0, e neste texto queremos registar onde chegámos, como está organizado por dentro e o que aprendemos pelo caminho.
O que significa exatamente "unikernel"
Um broker habitual é um programa que corre sobre um sistema operativo. O urMQTT não tem sistema operativo por baixo: arranca sozinho através do firmware UEFI e do bootloader Limine, configura as suas próprias tabelas de páginas, programa o controlador de interrupções e o temporizador, encontra os dispositivos virtio através de PCIe, obtém um endereço por DHCP e escuta num único porto. Tudo o que normalmente seria feito pelo kernel, pelos controladores, pela pilha de rede e pela aplicação é aqui um único ELF que corre ao nível EL1.
A comparação com uma instalação típica fala por si:
| Broker típico em Linux | urMQTT | |
|---|---|---|
| Serviços expostos | SSH, broker, muitas vezes também um porto de métricas ou de administração | apenas TCP 8883 (MQTT sobre TLS 1.3 com mTLS) |
| Código na imagem | kernel, userland, libc, OpenSSL, runtime | um único ELF em Rust (mais o SQLite incorporado) |
| Gestão remota | shell, ficheiros de configuração, atualizações de pacotes | nenhuma. Uma nova versão é um novo ISO |
| Memória por ligação inativa | quilobytes (buffers de socket, objetos TLS, heap) | objetivo ≤ 512 B |
| Modelo multinúcleo | threads e locks ou um único ciclo de eventos | uma thread por núcleo, sem estado partilhado, processamento até ao fim |
| Integridade do código | são possíveis mapeamentos simultaneamente graváveis e executáveis | W^X, tabelas de páginas bloqueadas, PAC/BTI |
As prioridades são, por esta ordem: segurança, escalabilidade, desempenho multinúcleo, pequena pegada e instalação imutável. Sempre que dois objetivos entram em conflito, ganha o que está mais acima nesta lista.
Onde estamos agora
O projeto está numa fase pré-alfa, mas já fechámos dezassete marcos de desenvolvimento, de M0 a M17. Hoje o urMQTT serve MQTT 3.1.1 sobre TLS 1.3 com certificados de cliente obrigatórios e guarda o estado em SQLite num disco em bruto. Em resumo, o que já funciona:
- Kernel: vetores de exceção próprios com relatórios descodificados, tabelas de páginas próprias com W^X imposto, páginas de guarda, PAC e BTI onde o processador os tem, GICv3 e GICv2, temporizador genérico, SMP até 64 núcleos.
- Deteção de hardware a partir de DTB ou ACPI, numa única descrição da plataforma, com resultado idêntico a partir de ambas as fontes.
- Controladores: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi e virtio-rng, e um CSPRNG inicializado com entropia de hardware.
- Rede: DHCPv4, ARP, ICMP e IPv4 estrito próprios, e um TCP próprio apenas do lado do servidor: SYN cookies, roda de temporizadores hierárquica, NewReno, fusão de ACK, sem estado TIME_WAIT.
- TLS: apenas TLS 1.3, mTLS obrigatório, identidade do dispositivo a partir do CN ou do SAN URI; o handshake é feito pelo rustls e, depois dele, por uma camada de registos própria com AES-128-GCM e ChaCha20-Poly1305. Retoma de sessão com tickets PSK, lista de revogação, controlo de admissão logo no handshake.
- MQTT: codec sem cópia, testado com fuzzing e com clientes mosquitto reais; QoS 0 e 1, wildcards, mensagens retidas, testamento (LWT), sessões persistentes, ACL limitada a
devices/{id}/#, limites de débito por dispositivo. - Armazenamento: SQLite dentro do kernel através de um VFS sobre o disco em bruto, em modo WAL, com verificação de integridade no arranque; registo de dispositivos, revogações, mensagens retidas, sessões persistentes com filas de mensagens para dispositivos offline; por núcleo, um diário de telemetria separado com CRC por registo e recuperação após falha.
- Operação e supervisão: watchdog para os handlers, verificação de saúde, registo de logs limitado, syslog e statsd por UDP, admissão que sobrevive a tempestades de religações.
- Entrega: compilações bit a bit idênticas verificadas na CI, ISO e imagem de disco GPT em bruto, assinaturas minisign, instruções para KVM, Proxmox e Hetzner.
Cada passo está assinalado com o marco que o trouxe, e o ficheiro docs/PROGRESS.md no repositório é a única fonte de verdade sobre o que realmente funciona.
Arquitetura em três imagens
Todo o desenho está descrito em docs/ARCHITECTURE.md, e as razões de cada decisão importante estão registadas como ADR em docs/DECISIONS.md. Aqui fica uma visão geral.
Camadas
De cima para baixo: o broker (sessões, árvore de tópicos, QoS, mensagens retidas, testamentos, ACL, limites), o codec MQTT, TLS, TCP, a camada de rede e, por fim, os controladores. Ao lado deste caminho principal está o armazenamento (SQLite e diário de telemetria) e, por baixo de tudo, a gestão de memória, a deteção da plataforma e a configuração, que é convertida em constantes no momento da compilação, juntamente com o certificado da CA, o certificado do servidor e a chave incorporados. O hipervisor com os dispositivos virtio é a única coisa que fica fora da imagem.
Uma thread por núcleo, sem estado partilhado
Cada núcleo de dados executa o seu próprio ciclo, com a sua pilha TCP, os seus estados TLS, uma parte do broker (sessões e árvore de tópicos), a cache do heap e a partição de telemetria. Os núcleos não partilham qualquer estado mutável, apenas anéis limitados e sem locks, um para cada par ordenado de núcleos. O núcleo mais alto executa o SQLite e mais nada.
A placa de rede tem hoje um único par de filas, que pertence ao núcleo 0. Este encaminha cada fluxo MQTT para um dos núcleos de trabalho, segundo o hash Toeplitz do respetivo quádruplo, e envia o que os núcleos de trabalho devolvem. A sessão vive no seu núcleo de origem, determinado pelo hash do identificador do cliente, pelo que a tomada de controlo, os testamentos e a persistência nunca atravessam núcleos. Uma ligação que aterre noutro núcleo mantém aí o seu TCP e o seu TLS, mas reencaminha o MQTT simples para o núcleo de origem, com controlo de fluxo baseado em créditos. Cada mensagem encaminhada é também distribuída pelas restantes partes do broker, cada uma das quais mantém uma réplica das mensagens retidas.
Três regras mantêm este modelo coeso. Processamento até ao fim: um pacote recebido é tratado por inteiro num único núcleo, do TCP, passando pela decifragem TLS no local, pelo parsing de MQTT e pelo encaminhamento, até à resposta cifrada, sem filas de tarefas e sem cópias para buffers da aplicação. Polling adaptativo: sob carga, as filas de receção são consultadas ativamente e, em repouso, o núcleo regressa às interrupções e a WFI; os handlers de interrupção apenas confirmam a interrupção e marcam o trabalho. Watchdog: um tique de 10 ms por núcleo apanha os handlers que excedem o seu orçamento.
A vida de um PUBLISH
O dispositivo envia um registo TLS. A placa de rede entrega-o ao ciclo do núcleo através de um descritor, sem cópia. O ciclo verifica o TCP, decifra o TLS no local e faz o parsing do MQTT a partir do buffer emprestado; depois verifica em memória a ACL, o limite de débito e o caminho pela árvore de tópicos. Se outro núcleo estiver interessado nesse tópico, recebe uma mensagem de encaminhamento com uma referência ao conteúdo e o identificador do tópico; a base de dados recebe um registo de telemetria ou a atualização de uma mensagem retida, em lote. A resposta PUBACK é cifrada diretamente para o buffer de saída e fundida com o ACK de TCP, pelo que ao dispositivo chega um único segmento. A mensagem custa a outro núcleo uma cifragem por subscritor, no momento do envio.
Quanto custa uma ligação
O objetivo é no máximo 512 bytes por ligação TLS inativa, com a ambição de chegar a 256. Enquanto a ligação está em repouso, nada é retido para ela em buffers: os dados recebidos são processados diretamente a partir da trama de rede, e os buffers de envio, de registos e de MQTT só existem enquanto houver algo por concluir ou por confirmar. O kernel mede-o ele próprio: contadores exatos do heap, em bytes pedidos e em blocos ocupados, escritos de dez em dez segundos como heap acima do nível base por ligação aberta.
| Componente | Tamanho | Como |
|---|---|---|
| Bloco de controlo TCP | 96 B | flags compactadas, RTT/RTO de 16 bits, posição na roda de temporizadores, sem buffers em repouso |
| Posição na tabela TCP | 4–8 B | apenas o índice da ligação, tabela no máximo meio cheia |
| Ligação e estado de registos TLS 1.3 | 152 B | segredo e sequência por sentido (40 B); chave e IV derivados por registo |
| Ligação MQTT | 48 B | identidade e testamento no heap apenas enquanto existirem |
| Sessão MQTT, forma quente | 64 B | uma subscrição em linha, identificadores de nós internados |
Subscrição devices/<id>/cmd/# |
0 nós da árvore | guardada de forma relativa ao identificador da sessão, comum a todos os dispositivos |
Medido sob HVF com quatro núcleos virtuais, três deles de trabalho, em que cada dispositivo mantém uma ligação mTLS e uma subscrição com QoS 1, incluindo o arredondamento do alocador:
| Ligações | Bytes por ligação |
|---|---|
| 960 | 531 B |
| 9 922 | 494 B |
| 10 197 | 474 B |
O conjunto de tramas de rede é fixo, com cerca de 2 MiB, o que equivale a aproximadamente 200 B por ligação com dez mil ligações e a cerca de 2 B com um milhão. Não conseguimos medir mais do que isso: a rede de utilizador do QEMU no macOS deixou de aceitar ligações às 10 197, aproximadamente no limite de 10 240 descritores do processo QEMU. O valor desce à medida que as partes fixas se repartem por mais ligações, pelo que não cresce com o seu número.
Modelo de segurança
A segurança é a primeira prioridade e ganha todos os conflitos com os restantes objetivos. O modelo tem várias camadas:
- Um só porto. TCP 8883, apenas TLS 1.3, certificados de cliente obrigatórios assinados pela CA incorporada na imagem. O identificador do cliente MQTT tem de ser igual ao identificador do dispositivo constante do certificado.
- Sem entrada. Sem shell, sem entrada pela consola, sem SSH, sem API de administração, sem código dinâmico, sem SQL acessível pela rede e sem sistema de ficheiros.
- Segurança de memória. Rust, com
forbid(unsafe_code)nos pacotes de protocolo; cada blocounsafetem um comentário// SAFETY:verificado por lint, e perante entrada externa não há panic: o clippy, nos pacotes do caminho de dados, proíbeunwrap,expect, indexação epanic!. - Mitigação de exploração. O W^X é imposto pela codificação dos descritores e pelo bit
SCTLR_EL1.WXN, e é auditado em cada arranque. Depois do arranque, as tabelas de páginas são só de leitura, sem alias gravável. A metade inferior do espaço de endereçamento está excluída. Cada pilha tem páginas de guarda e as exceções correm numa pilha separada. PAC e BTI são ativados quando o processador os suporta, o que é verificado pelos smoke tests. - Resistência ao abuso. SYN cookies; a admissão recusa novas ligações, antes de qualquer criptografia, quando o heap está baixo, quando a base de dados está atrasada, quando há demasiados handshakes em curso por núcleo ou por endereço de origem, ou quando o ritmo de handshakes é demasiado elevado. Limites de débito por dispositivo, filas limitadas, janela TCP ligada à contrapressão do broker, registo de logs limitado, watchdog e verificação de saúde.
- Imutabilidade. A configuração, a CA e a chave do servidor estão incorporadas na imagem. Alterar seja o que for implica uma nova imagem.
O modelo de ameaças está registado em docs/SECURITY.md.
Armazenamento sem sistema de ficheiros
Os dados vivem num disco separado, virtio-blk ou virtio-scsi, no qual não existe sistema de ficheiros. A disposição é mínima e própria:
LBA 0 [ superbloco: magia · versão da disposição · uuid · tabela de regiões · CRC-32C ]
LBA 8, 16 [ tabela de tamanhos dos ficheiros, posições A/B (vence a mais recente válida) ]
LBA 64 [ base principal SQLite ½ ][ WAL do SQLite ⅛ ][ diário 1 MiB ][ diário de telemetria: 8 partições … ]
últimos 4 KiB [ espaço para a autoverificação do dispositivo de blocos no arranque ]
O SQLite (3.54.1 incorporado, com verificação do hash na compilação) é compilado para dentro do kernel com o clang, sem biblioteca padrão, e usa um heap memsys5 fixo. O seu VFS mapeia três ficheiros fixos para regiões do disco. Funciona em modo WAL com locking_mode=EXCLUSIVE, pelo que não precisa de um índice em memória partilhada, e com synchronous=FULL; se o quick_check no arranque encontrar um erro, o sistema pára em segurança. Na base de dados estão o registo de dispositivos, as revogações, as mensagens retidas e as sessões persistentes, com filas de mensagens QoS 1 para dispositivos que não estão ligados.
O núcleo da base de dados, o de número mais alto, executa o SQLite e mais nada. O plano de dados envia-lhe as alterações de estado através de um anel SPSC sem locks; este agrupa-as numa única transação a cada 5 ms ou a cada 512 operações e devolve as confirmações por outro anel. Um disco vazio é formatado no primeiro arranque; tudo o resto nunca é formatado por cima.
A telemetria segue outro caminho. Cada PUBLISH aceite em tópicos de storage.telemetry_topics vai para a partição do diário de telemetria do seu núcleo: um anel de segmentos com CRC por registo, salgado com a sequência do segmento, de modo que as passagens antigas do anel nunca voltam a aparecer. Os núcleos entregam os registos à base de dados através de um anel próprio, sem cópia, e esta escreve-os a cada 5 ms ou a cada 256 KiB. No arranque, a recuperação mantém todos os registos escritos, corta a cauda incompleta e continua num novo segmento. A retenção é por tamanho, através do anel, e por idade, através de retention_days. Verificámo-lo com um milhão de registos e com kill -9 a meio da escrita.
Os números
A ferramenta de carga em tools/loadgen faz três coisas: uma tempestade de handshakes TLS 1.3 com mTLS e CONNECT, ligações de longa duração com PINGREQ que publicam telemetria a cada segundo, e tempestades de religações. O make bench num Apple M4 Pro sob HVF dá:
| Núcleos de trabalho | handshakes/s no convidado | escalabilidade | através da rede de utilizador do QEMU (ligações/s) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
O valor no convidado, em que o cliente e o servidor estão dentro da mesma máquina virtual e cada núcleo de trabalho funciona por si, mostra que o plano de dados escala linearmente. O valor de rede é limitado pela rede de utilizador de uma só thread do QEMU, não pelo convidado; isto também está registado, como ADR-032.
As tempestades de religações testamo-las fazendo com que todos os dispositivos se liguem ao mesmo tempo, repitam as tentativas recusadas com atraso e depois caiam todos sem DISCONNECT. O parâmetro heap_limit_mb= no modo de desenvolvimento faz com que a admissão considere o heap mais pequeno do que é, pelo que uma tempestade maior do que o heap pode ser testada com poucos sockets. Numa ronda destas, com 3 000 dispositivos, ligaram-se 1 368 em 36 segundos e 5 619 tentativas foram devidamente recusadas, com um limite de 8 MiB; a memória voltou ao normal depois da tempestade.
Como trabalhamos
O projeto é conduzido por uma especificação escrita, PROMPT.md, com o plano de marcos, e cada marco termina com o mesmo ritual: make check e make smoke a verde, um commit com a etiqueta M<n>, uma entrada em docs/PROGRESS.md e um ADR para cada decisão que não seja óbvia. As regras são curtas e não se negoceiam: sem panic perante entrada externa, unsafe apenas nos pacotes de hardware e FFI e sempre com comentário, sem alocações no caminho quente, um comentário junto de cada ordenação de operações atómicas, cada limite vem da configuração, e as partes provisórias intencionais falham de forma ruidosa.
A CI faz mais do que conseguiríamos fazer à mão: fmt, clippy para o host e para aarch64-unknown-none, typos, actionlint e shellcheck; testes em Linux e em macOS em arm64; compilação do kernel para as configurações de desenvolvimento e de produção, com o tamanho do ELF no resumo; arranque sob QEMU com todos os cenários do smoke test e guardando os logs de série; cargo-deny para avisos, licenças e fontes; um minuto de fuzzing em cada um dos onze alvos e, durante a noite, dez minutos por alvo com corpus em cache. Além disso, CodeQL, OpenSSF Scorecard, Dependabot e publicação com proveniência SLSA. Cada action de terceiros está fixada ao SHA completo do commit.
Levamos a reprodutibilidade à letra: o make repro compila o HEAD duas vezes em árvores de trabalho limpas e compara o ELF, o ISO e a imagem em bruto byte a byte. As versões publicadas são assinadas com minisign, e os artefactos podem ser verificados com gh attestation verify. A imagem de produção contém a chave privada do servidor, pelo que o próprio ISO é um segredo; as imagens que acompanham as versões no GitHub são de desenvolvimento, com chaves temporárias, e servem apenas para experimentar.
O que aprendemos
Algumas lições que mudaram o plano:
- Afinal, o TCP próprio compensou. O protótipo funcionava com o smoltcp e serviu-nos bem até ao M13. Mas um servidor que mantém centenas de milhares de ligações não precisa de um TCP de cliente: precisa de SYN cookies, de uma roda de temporizadores e de um estado de 96 bytes. No M14 substituímos o smoltcp por uma pilha própria apenas do lado do servidor, com testes de perda simulada e fuzzing de segmentos.
- Meçam a memória por dentro. As estimativas por ligação eram inúteis até o kernel começar a contar ele próprio o heap em blocos do alocador, e não em bytes pedidos. Só então vimos para onde vão realmente os 512 bytes e baixámos para 494.
- A ferramenta de teste faz parte do produto. A maior limitação não foi o kernel, mas a rede de utilizador do QEMU e o número de portos efémeros no computador de desenvolvimento. Para cem mil ligações ou mais é preciso
vmnet-sharedno macOS ou Linux com KVM, interface tap e vários endereços de cliente. - SQLite sem sistema operativo é possível, mas só em condições estritas: heap fixo, bloqueio exclusivo sem memória partilhada, um VFS que desconhece ficheiros e uma admissão que não deixa a base de dados atrasar-se.
- As tempestades de religações são o estado normal, não a exceção. Quando a rede móvel cai e volta, milhares de dispositivos batem à porta ao mesmo tempo. Por isso a admissão recusa a ligação antes de qualquer criptografia, e cada ligação recusada custa quase nada.
O que se segue
O trabalho depois do M17 está planeado em três direções, e a ordem entre elas está registada em docs/PLAN.md. Nada disto está ainda construído.
- Um ISO para várias plataformas. O mesmo ficheiro
urmqtt-<env>-<versão>.isoe uma única imagem GPT em bruto que arrancam em UEFI aarch64, UEFI x86_64 e BIOS x86_64, em máquinas virtuais e em hardware real, começando por um PC Intel. Continua a ser um aparelho com tudo lá dentro, reprodutível e assinado. - Disco em bruto, sem sistema de ficheiros. Especificação do formato ao nível do byte, uma ferramenta de host
urmqtt-diskque partilha os parsers com o kernel, alinhamento das regiões a 1 MiB, crescimento do disco com os dados, discard para discos de provisionamento dinâmico, snapshots A/B da base de dados, verificação de bits e, opcionalmente, cifragem AES-256-XTS com AES por hardware. - Comprovadamente seguro. Revogação e lista de dispositivos permitidos em tempo de execução através de um canal de controlo assinado, sem nova imagem; testes diferenciais e de conformidade do TLS e do TCP próprios; KASLR, proteção da pilha, kCFI, MTE e um alocador reforçado; SQLite isolado ao nível EL0; Secure Boot; SBOM e cargo-vet; e, por fim, um pacote para auditoria externa.
As plataformas que oferecem apenas dispositivos não virtio, como o AWS ENA ou o Azure MANA, não são suportadas por agora. O Raspberry Pi 4 e 5 como anfitrião KVM, o Hetzner CAX e o Oracle Ampere A1 estão documentados e suportados no código, mas ainda não foram experimentados num serviço real.
Experimente
O repositório está em github.com/zeljan-alduk/urMQTT. Com o Rust com a toolchain nightly fixada e o QEMU, make certs, make run e um cliente mosquitto bastam para o primeiro PUBLISH sobre mTLS no seu próprio computador. O aviso do início vale também no fim: isto é pré-alfa, e tudo o que funciona está em docs/PROGRESS.md. Relatórios de erros, perguntas sobre o desenho e comunicações de segurança, seguindo as instruções em SECURITY.md, são bem-vindos. Voltaremos a falar aqui de cada marco seguinte.
Comentários
Regras dos comentários