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, using the[encryption]certificate. - 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).
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.
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).- 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.
- 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: GSSAPI over 5.0 AUTH, TLS client-certificate identity, MQTT-SN/QUIC, broker bridging, publish-from-SQL.
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.