MQTT broker
skaidb nodes speak MQTT natively: Home Assistant, zigbee2mqtt, Tasmota, ESPHome, Shelly and any standard MQTT client connect directly to the database — no separate Mosquitto in the stack. Both MQTT 3.1.1 and 5.0 are served (plus read-compat MQTT 3.1), over TCP, TLS, and WebSocket.
What makes it different from a file-backed broker: broker state lives in
replicated skaidb tables (the reserved _mqtt database). Retained
messages, persistent sessions, subscriptions and offline queues survive a
node restart — and on a cluster they survive node loss: a client can
disconnect from one node and resume its session, with its queued messages,
on any other node. Broker state is also SQL-inspectable:
SELECT topic, stored_at FROM _mqtt.retained WHERE topic LIKE 'homeassistant/%';
SELECT client_id, disconnected_at FROM _mqtt.sessions;
Enabling
[mqtt]
enabled = true
port = 1883 # plain / opportunistic TLS per [encryption].client_tls
tls_port = 8883 # always-TLS; bound only when client_tls != "off"
allow_anonymous = false # see Authentication below
enabled and the ports need a restart; every limit is runtime-mutable via
config set. The full knob list with defaults is in config/skaidb.toml.
CLI/env overrides: --mqtt-enabled / SKAIDB_MQTT_ENABLED, --mqtt-port,
--mqtt-tls-port, --mqtt-allow-anonymous.
Transports
- TCP on
mqtt.port(1883). Withclient_tls = "opportunistic"the same port also accepts TLS; with"required", plaintext is refused. - TLS on
mqtt.tls_port(8883), always-TLS. The MQTT listeners present the[encryption]certificate unlessmqtt.tls_cert_file/mqtt.tls_key_filename their own pair (a public device endpoint on its own hostname while the SQL ports keep the internal one); either pair rotates live when its files change. The MQTT acceptor offers ALPNmqtt; clients that send no ALPN still connect. - WebSocket (
ws://andwss://): auto-detected on the same listeners — an HTTP GET upgrade (RFC 6455, subprotocolmqtt) instead of an MQTT CONNECT. No extra port or configuration.
Authentication & authorization
CONNECT username/password authenticates against the same user catalog as
every other endpoint (CREATE USER … PASSWORD …). Failures answer CONNACK
0x05/0x04 (3.1.1) or 0x86 (5.0).
Two ways in that never put a password on the wire, tried before the username/password fields:
- 5.0 enhanced authentication (AUTH packets). A CONNECT carrying an
Authentication Methodruns the exchange of §4.12 — AUTH 0x18 both ways, the CONNACK closes it with the method and the server's final message in its properties. Methods offered: SCRAM-SHA-256, RFC 5802 message syntax, so any SCRAM client library produces the right bytes: CONNECTAuthentication Data=n,,n=<user>,r=<nonce>(=2C/=3Descaping in the user); the server AUTHsr=<nonce+ext>,s=<base64 salt>,i=<iterations>; the client AUTHsc=biws,r=<nonce+ext>,p=<base64 proof>; the CONNACK's data isv=<base64 server signature>— verify it, the server proved itself too. The SCRAM user is the identity (the CONNECT username/password fields are ignored). A failed proof is 0x86; stepping out of the exchange (any packet but AUTH 0x18) is 0x82.GSSAPI(Kerberos), on akerberosbuild withauth.gssapi_enabled: CONNECT data = the initial token, AUTH packets shuttle the rest, the CONNACK carries the final mutual-auth token; the principal maps to an external user (CREATE USER "<principal>" GSSAPI) exactly as on the binary port. Not authorized is 0x87.- anything else, or a method this build/config does not offer: 0x8C (Bad authentication method). Re-authentication (AUTH 0x19 mid-session) is not supported; a session that wants fresh credentials reconnects.
- TLS client-certificate identity (
auth.x509_enabled = true,auth.x509_ca_file = <CA PEM>). Every client-TLS port then requests a client certificate (a client without one still connects and authenticates otherwise). On the MQTT listener a certificate chaining to that CA is the credential: its subject Common Name is the username (the Mosquittouse_identity_as_usernameconvention) — no password; the user must exist (CREATE USER "device-7" …, any kind). A CONNECT username that disagrees with the CN is refused (0x05/0x87); a CN naming no user is 0x04/0x86. Certificate rotation is live like the server's own (the acceptor rebuilds when the PEM files change).
mqtt.allow_anonymous is an extra gate on top of [auth]: even when
server auth is disabled, the MQTT listener refuses credential-less
CONNECTs unless it is true — the IoT listener is usually the most
exposed surface on the box. Per-IP CONNECT rate limiting
(mqtt.connect_rate_limit, default 20/s with 2× burst) blunts reconnect
storms.
Bridging to another broker
[[mqtt.bridge]] mirrors topics between this broker and another one — the
migration or federation tool Mosquitto users know as a bridge connection:
[[mqtt.bridge]]
name = "cloud"
remote = "broker.example.com:8883"
remote_tls = true
remote_tls_ca = "/etc/skaidb/cloud-ca.pem" # empty = the public roots
remote_user = "site42"
remote_password = "…"
local_user = "bridge" # a catalog user holding the topic grants
local_password = "…"
out = ["tele/#", "state/#"] # local → remote as <remote_prefix><topic>
remote_prefix = "site42/"
in = ["site42/cmd/#"] # remote → local as <local_prefix><topic>
local_prefix = ""
qos = 1
keep_alive_secs = 30
A bridge is a thread holding two ordinary MQTT 3.1.1 clients: a loopback
link into this broker (so it is subject to local_user's topic grants,
admission limits and QoS like any device) and a link to the remote. A
message a bridge brings in is a client publish here — forwarded to
cluster peers, captured by sinks, queued for offline sessions. Rules:
out and in must not overlap after remapping (MQTT carries no origin,
so that is the only loop prevention, exactly as in Mosquitto); retain
does not cross a bridge (a 3.1.1 subscriber never sees a publisher's
retain flag on a live delivery); either link failing drops both, and the
bridge reconnects with a capped backoff (1 s to 30 s) and resubscribes —
messages during the outage are not replayed. Counters:
skaidb_mqtt_bridge_messages_total{bridge,direction} and
skaidb_mqtt_bridge_reconnects_total{bridge}. With client_tls =
"required" the loopback link uses the TLS port and trusts the node's own
certificate.
Publishing from SQL
PUBLISH '<topic>' PAYLOAD <expr> [QOS 0|1|2] [RETAIN] sends one message
through this node's broker — subscribers here get it, peers forward it
like any client publish, RETAIN stores it for late subscribers. A
string payload publishes its bytes, a document or array its JSON:
PUBLISH 'tele/kitchen' PAYLOAD {"t": 21.5, "unit": 'C'} QOS 1;
PUBLISH 'state/door' PAYLOAD 'open' RETAIN;
The topic is a literal (concrete name: no wildcards, not $), so the
check is static: the role needs PUBLISH on it under the same topic
grants a client connection gets (GRANT PUBLISH ON TOPIC 'tele/#' TO
role; a role with no topic grants keeps the open default). A procedure
body may PUBLISH, which is how a trigger turns a row change into an
MQTT message (change data capture without a separate stream tail):
CREATE PROCEDURE announce(id TEXT, total INT)
BEGIN
PUBLISH 'orders/new' PAYLOAD {"id": id, "total": total};
END;
CREATE TRIGGER order_out ON orders WHEN (total > 0) CALL announce(NEW.id, NEW.total);
The trigger runs as its definer; a CALL by hand is gated on the body's
topics. Delivery only — topic→table capture rules ([[mqtt.sink]]) run on
client publishes, under the publishing client's role, not on SQL ones.
Needs the broker ([mqtt] enabled = true); otherwise the statement says so.
Topic ACLs
By default any authenticated user may publish/subscribe anywhere (what a default Mosquitto gives Home Assistant). Roles opt into topic ACLs via grants; a role with any topic grant is restricted to its grants, and the superuser is never restricted:
GRANT PUBLISH ON TOPIC 'tele/#' TO sensors;
GRANT SUBSCRIBE ON TOPIC 'cmnd/+/set' TO sensors;
REVOKE PUBLISH ON TOPIC 'tele/#' FROM sensors;
Grant filters use MQTT wildcard semantics. A publish is allowed when any
granted filter matches the topic; a subscribe is allowed when any
granted filter covers the requested filter (a grant of home/# covers a
subscription to home/+/temp). Denied publishes answer 0x87 Not
authorized on 5.0 (3.1.1 has no error channel: the packet is acknowledged
per QoS and silently not routed, counted in
skaidb_mqtt_messages_dropped_total{reason="acl"}); denied subscriptions
get a per-filter SUBACK failure. A will whose topic the role may not
publish is refused at CONNECT. Grant changes apply on the client's next
connect.
Sessions & QoS
Full QoS 0/1/2 in both directions. Persistent sessions (3.1.1
CleanSession=0, 5.0 Session Expiry Interval > 0) keep their
subscriptions, unacknowledged deliveries and offline queue in
_mqtt.* tables:
- QoS 1/2 publishes to a persistent session are written to
_mqtt.queuebefore the publisher's ack — the ack is the durability promise. QoS 0 to a live subscriber never touches storage. - Resumption replays the backlog in order: unacknowledged legs are retransmitted with DUP=1 under their original packet ids, and the QoS 2 PUBREL leg resumes as PUBREL (recorded durably before it is first sent) — exactly-once holds across reconnects and broker restarts.
- Offline queues are bounded (
mqtt.max_queued_per_session); sessions expire aftermqtt.session_expiry_max_secsoffline (5.0 clients may request less, and may revise it at DISCONNECT). - A second CONNECT with the same client id takes the session over and disconnects the first (5.0 reason 0x8E), on whichever node it lives.
Retained messages are cached in memory and persisted write-behind
(~100 ms batches) to _mqtt.retained — a crash may lose the last
≤100 ms of retained churn, matching ecosystem practice. Wills fire on
every abnormal disconnect (never on clean DISCONNECT; 5.0 reason 0x04
keeps the will on a clean close), honor the 5.0 Will Delay Interval,
cancel on reconnect, and survive broker restarts.
MQTT 5.0 specifics
Server capabilities are advertised in CONNACK (Receive Maximum, Topic
Alias Maximum 64, Maximum Packet Size, Assigned Client Identifier, capped
Session Expiry). Message Expiry is honored end-to-end (queued copies
expire in place; deliveries carry the remaining interval; retained
messages expire). Subscription options (No Local, Retain As Published,
Retain Handling), subscription identifiers, inbound topic aliases,
request/response and user-property pass-through, and shared subscriptions
($share/{group}/{filter}, round-robin preferring live members) are all
supported. Egress honors the client's Maximum Packet Size by dropping,
never truncating.
Clustering
Enable [mqtt] on every member; clients may connect to any node.
Publishes fan out to peers over the internode transport and each node
delivers to its own connected clients; sessions resume on any node
(replicated state). QoS 1/2 to a subscriber on another node — or on a
node that has died — rides _mqtt.queue, so acknowledged messages are
never lost to a node failure. Cluster notes:
- Shared-subscription round-robin is cluster-approximate (the origin node picks the member); members that must receive across nodes should use persistent sessions.
$SYStopics are per-node._mqtt.*tables default towitness = false(transient operational state; witnesses skip them). They are ordinary replicated tables otherwise, soBACKUP TO/BACKUP CLUSTER TOinclude them — retained messages, persistent sessions, subscriptions and offline queues restore with the rest of the node.- Cross-node QoS 2 degrades to effectively-once at the internode hop (documented, per the RFC; the queue-row dedup makes duplicates rare).
Topic → table capture (the sink)
Config-driven rules capture matching publishes into tables — many topics to one table, with wildcard captures as column/label values:
[[mqtt.sink]]
filter = "home/+/+/state" # + captures: room, device
table = "iot.sensor_state"
mode = "row" # JSON payload fields → columns
topic_columns = ["room", "device"]
[[mqtt.sink]]
filter = "zigbee2mqtt/+/SENSOR"
table = "iot.metrics"
mode = "timeseries" # numeric JSON leaves → samples
series = ["device"] # capture label names
row mode inserts one row per message: JSON object fields become columns
(non-JSON payloads land in a payload bytes column), plus the captures,
topic, and ts. timeseries mode flattens numeric JSON leaves into
the remote_write fast path (each leaf a series named by its dotted path,
string fields and captures as labels), auto-creating the TS table — MQTT
telemetry becomes PromQL-queryable with zero glue services (see
TIMESERIES.md / GRAFANA.md).
The sink enforces the publishing user's Insert privilege on the
target table and the read-only gate, exactly like remote_write. With
ack_on_sink = true the publisher's ack is gated on the table write (5.0
answers 0x97 on failure — a durable ingest API). Out-of-order-window TS
drops are counted (skaidb_mqtt_sink_dropped_total{reason="ooo"}), not
fatal. Sink rules are runtime-mutable.
$SYS topics
A Mosquitto-compatible subset is published every 10 s per node
(mqtt.sys_topics_enabled, default on): version, uptime,
clients connected/total, messages received/sent, subscription and
retained counts. $SYS values are retained in cache only and are never
matched by a plain # subscription (per spec).
The $ tree is broker-owned: a PUBLISH from a client to any $-prefixed
topic is refused and the connection dropped, whatever its ACL allows and
including a superuser, so nothing can pin attacker-chosen values into the
retained cache and feed them to whatever monitors $SYS. The stats
publisher above writes through an internal path that never passes this
check.
Operational notes
- MQTT connections appear in the
driversconnections registry (endpointmqtt) and the UI. - Metrics:
skaidb_connections_{total,active}{endpoint="mqtt"},skaidb_mqtt_packets_total{type,dir},skaidb_mqtt_connect_total{outcome},skaidb_mqtt_messages_dropped_total{reason},skaidb_mqtt_sink_*— seeMETRICS.md. - Broker-state writes use
mqtt.state_consistency(defaultquorum). - Graceful shutdown DISCONNECTs clients (5.0 reason 0x8B) and flushes dirty broker state; wills are not published on server shutdown.
- Idle parking (epoll). A connection is served by a reader thread
and a writer thread while it is talking; once it has been quiet for
mqtt.idle_park_secs(default 2), its socket moves to the broker's epoll reactor and both threads exit. The reactor resumes it on the next byte (a ping, a publish) on a fresh thread — the same loop, same session — and closes it at its keep-alive deadline if nothing arrives; the writer is respawned by the next message queued for it. Ten thousand idle devices therefore cost ten thousand registered descriptors, not threads; active connections keep the thread-per-connection path with its blocking, simple code.idle_park_secs = 0disables parking; on Windows there is no epoll reactor and every connection keeps its thread pair. Counters:skaidb_mqtt_parked_readers(gauge),skaidb_mqtt_parks_total{side},skaidb_mqtt_wakes_total{side}. - Every limit is enforced from the first byte (max packet size checked before a body is buffered; bounded outboxes, queues, retained store, topic shape, subscriptions per session).
- Deliberate deferrals: MQTT-SN/QUIC, AUTH re-authentication, MQTT 5 across bridges (retain-as-published).
Home Assistant quick start
Point the HA mqtt integration (or zigbee2mqtt's mqtt.server) at
mqtt://<node>:1883 with a database user's credentials. Discovery,
retained state, availability (LWT), and QoS 0/1/2 work out of the box;
after a broker restart HA re-reads its retained discovery topics from
skaidb's replicated store.