mercuriod 0.5.0

Mercurio MQTT broker daemon
mercuriod-0.5.0 is not a library.

mercuriod

The Mercurio MQTT broker daemon. It serves MQTT 3.1, 3.1.1 and 5.0 over TCP, TLS and WebSocket. To try it, the recado client publishes and subscribes from the command line, and mercurioctl administers it while it runs.

Installation

Each release has .deb and .rpm packages for x86_64, aarch64 and armv7 Linux, which install the binary, the configuration in /etc/mercurio/ and the systemd unit:

sudo apt install ./mercuriod_*.deb
sudo dnf install ./mercuriod-*.rpm

Or with cargo:

cargo install mercuriod

Or build from the workspace root:

cargo build --release --package mercuriod

Usage

mercuriod [OPTIONS]
mercuriod passwd -f <FILE> -u <USER> [-d]
mercuriod dynsec-init -f <FILE> [-u <USER>]
mercuriod dynsec-import <MOSQUITTO FILE> <FILE>
Option Description Default
-c, --config <FILE> Path to configuration file /etc/mercurio/config.toml
-l, --listen <ADDR> Override the listen address, such as 0.0.0.0:1883 -
-v, --verbose Log at debug level -
--check Check the configuration and exit -

Without a configuration file at the default path, the broker starts with the defaults below. A missing file at a path given with -c is an error.

mercuriod --check does what a start does short of listening and opening storage: it reads the configuration and its include_dir, loads the password, ACL and dynamic security files, the TLS certificates and keys, and the WebAssembly plugins, then exits with status 0. A problem makes it exit with status 1 and the error a start would print. Run it as the broker's user, so it reads the files as the broker would, before restarting the broker after a change:

sudo -u mercurio mercuriod --check && sudo systemctl restart mercuriod

Configuration

Mercuriod reads a TOML file. Every setting is optional, and a key it does not know stops it from starting, with an error naming the key. This is the full list with the defaults, also available as contrib/config.toml:

# Merge the *.toml files of this directory, in name order: their tables
# extend this file's, their [[listeners]], [[bridges]] and [[plugins]] are
# added, and their other values replace this file's
# include_dir = "/etc/mercurio/conf.d"

[server]
# Host address to bind to
# Use 0.0.0.0 to listen on all interfaces
host = "127.0.0.1"

# Port to listen on (default MQTT port is 1883)
port = 1883

# Only accept connections arriving on this network interface (Linux only).
# IPv6 hosts, such as "::", work as they are
# bind_interface = "eth0"

# Also serve MQTT over WebSocket (with TLS when it is enabled) on this port
# websocket_port = 8080

# Serve the files of this directory to plain HTTP requests on websocket_port,
# such as a browser client's page; [[listeners]] take it too
# http_dir = "/var/www/mercurio"

# Seconds between updates of the broker statistics published under
# $SYS/broker/ (0 disables them)
sys_interval = 10

# Maximum number of concurrent connections
max_connections = 10000

# Largest packet accepted from a client, in bytes
max_packet_size = 1048576

# Largest CONNECT accepted from a client, in bytes. It comes before the
# client is authenticated, so it gets less room than other packets
max_connect_packet_size = 65536

# Messages that may wait for one client (QoS 1 and 2 included) before
# further ones are dropped
max_queued_messages = 10000

# Queued messages beyond which QoS 0 messages are dropped
max_queued_qos0_messages = 1000

# Payload bytes that may wait for one client before further messages are
# dropped, whatever their QoS
# max_queued_bytes = 104857600
# Which message a full queue drops: "drop_newest", the one arriving, or
# "drop_oldest", the oldest waiting of its kind (QoS 0, or QoS 1 and 2) so
# a client that falls behind gets the latest ones
# queue_overflow = "drop_newest"
# How a shared subscription's group picks the member a message goes to:
# "round_robin", "random", "sticky" (the same member for as long as it is
# connected and takes messages) or "hash" (by the publisher's client id, so
# each publisher's messages stay in order). Connected members whose queue
# has room always come first
# shared_subscription_strategy = "round_robin"
# When the messages a member was given and hasn't processed go to another
# member: those still queued for it and those at QoS 1 it hasn't
# acknowledged. "session_end" when the member's session ends; "disconnect"
# also as soon as it loses its connection, to the members connected then.
# QoS 2 messages already sent stay with the member. A QoS 1 message the
# member processed without acknowledging reaches the application twice
# shared_subscription_redelivery = "session_end"


# Send messages at the QoS of the subscription even when they were published
# with a lower one; goes beyond the MQTT specification
# upgrade_outgoing_qos = false

# Send a retained message to new subscribers only while the client that
# published it may still publish to its topic, so access revoked in the ACL
# also stops its retained messages
# check_retain_source = false
# Subscriptions one session may hold; further ones get Quota Exceeded
# max_subscriptions = 10000
# Longest topic filter, in bytes, a SUBSCRIBE may name; longer ones get
# Topic Filter Invalid
# max_topic_filter_length = 1024

# Seconds a new connection has, in all, to send its PROXY header, complete
# the TLS and WebSocket handshakes and send CONNECT
connect_timeout = 10

# Seconds writing one packet to a client may take before its connection is
# dropped as stalled. A client reconnecting with the same client id waits as
# long for the old connection to close, then gets Server Busy
write_timeout = 10

# Highest topic alias MQTT 5 clients may use (0 disables topic aliases)
max_topic_alias = 10

# QoS 2 messages an MQTT 5 client may have awaiting PUBREL at once
receive_maximum = 65535

# QoS 1 and 2 messages sent to one client and not yet acknowledged, at most,
# whatever Receive Maximum the client announces; each holds its message in
# memory until acknowledged
max_inflight = 64

# Longest keep-alive, in seconds, MQTT 5 clients may use; clients asking for
# more, or for none, are told to use this instead
# max_keepalive = 300

# Response topic prefix given to MQTT 5 clients that request Response
# Information; %c is replaced by the client id
# response_information = "responses/%c"

# Replace repeated topics with aliases for MQTT 5 clients that accept them,
# saving bandwidth. Off by default because some client libraries announce
# alias support they don't implement.
# send_topic_aliases = true

# Highest QoS clients may publish and subscribe with; higher subscriptions
# are downgraded, higher publishes cost the connection
# max_qos = 2

# Set to false to refuse retained messages
# retain_available = true

# Accept clients that send an empty client id, assigning them one
# allow_zero_length_clientid = true
# Prefix of the client ids the broker assigns, which are bare UUIDs without
# one
# auto_id_prefix = "auto-"
# Only accept client ids starting with one of these
# clientid_prefixes = ["sensor-", "auto-"]

# Seconds the session of an MQTT 3.x client that connected without clean
# session outlives its connection; forever when unset, as MQTT 3.x has no
# session expiry of its own
# persistent_client_expiration = 604800
# Most seconds any session may outlive its connection, MQTT 5 clients
# included; a week by default, "unlimited" to keep sessions forever
# max_session_expiry = 604800
# Sessions outliving their connection the broker may hold, connected or
# not; a client that would start one more gets Quota Exceeded (MQTT 3.x:
# Server Unavailable). Resuming one is never refused, and sessions ending
# with their connection don't count (unbounded by default)
# max_sessions = 100000
# Topics that may hold a retained message at once, the broker's own $SYS
# topics not counted; further ones get Quota Exceeded (unbounded)
# max_retained_messages = 100000
# Connections one client address may hold at once, counting the address a
# PROXY header gives (unbounded by default, as NAT puts many behind one)
# max_connections_per_ip = 100
# Failed logins from one address (IPv6 by /64) within login_failure_window
# seconds that get it banned for login_failure_ban seconds. The ban is
# listed by `mercurioctl ban list` and lifted like any other; loopback
# addresses and the admin socket are never banned. 0 attempts turns it off
# login_failure_attempts = 10
# PUBLISH packets per second one client may send on average, and at once
# after a quiet spell (a second's worth by default). A client going faster
# isn't read from until its rate allows, so it slows down as TCP pushes
# back; nothing is dropped. In-process clients (bridges) and the admin
# socket are not limited. Unlimited by default
# max_publish_rate = 100
# publish_rate_burst = 500
# The same for the payload bytes one client publishes
# max_publish_bytes_rate = 1048576
# publish_bytes_burst = 4194304
# Seconds a client may stay over either rate, with more waiting each time
# the broker reads from it again, before it is disconnected with Message
# Rate Too High (MQTT 3.x clients are just disconnected). Clients are only
# slowed down by default
# publish_rate_grace = 30
# login_failure_window = 60
# login_failure_ban = 300
# Seconds one call of a plugin hook may take, for built-in authentication
# and access control as for [[plugins]]; a slower call denies, or drops the
# message. [auth.http] timeout_seconds and a plugin's timeout_ms may not
# exceed it
# plugin_timeout = 5
# Seconds between calls of the plugins' on_tick
# plugin_tick_interval = 1

# Send every client to another server instead of serving it. MQTT 5 clients
# are told where to go; MQTT 3.x clients only learn this server is unavailable.
# [server.redirect]
# server_reference = "broker2.example.com:1883"
# permanent = false

[server.tls]
# Serve TLS on `port` (and on `websocket_port`) instead of plain TCP
enabled = false
# Certificate chain and private key, in PEM format
# cert_path = "/etc/mercurio/server.crt"
# key_path = "/etc/mercurio/server.key"
# Require clients to present a certificate signed by this CA (mutual TLS)
# ca_path = "/etc/mercurio/ca.crt"
# With ca_path, let clients without a certificate in to log in by password;
# a certificate that is presented must still be valid
# require_certificate = true
# Reject client certificates revoked in these CRLs (PEM); needs ca_path
# crl_path = "/etc/mercurio/crl.pem"
# Every certificate of a client's chain needs a CRL from its issuer in
# crl_path, or the client is refused. Check only the client's own
# certificate, leaving its intermediate CAs unchecked
# crl_end_entity_only = false
# Accept certificates whose issuer has no CRL in crl_path
# crl_allow_unknown = false
# Use the client certificate's Common Name as the user name, instead of
# the user name and password the client sends; needs ca_path
# identity_as_username = false
# Or the whole certificate subject, as in "CN=alice,O=Example,C=PT"
# subject_as_username = false
# Oldest TLS version accepted: "1.2" or "1.3"
# min_version = "1.2"

[logging]
# Log level: trace, debug, info, warn, error. Levels can also be set per
# module, as in "info,mercurio_server::session=warn"
level = "info"
# Append logs to this file instead of standard output. Rotate it with
# logrotate's copytruncate, as the file stays open
# file = "/var/log/mercurio/mercuriod.log"
# Send logs to the local syslog daemon (/dev/log) instead
# syslog = false
# Log clients connecting and disconnecting; turn off on busy brokers
# connection_messages = true

[storage]
# Where sessions, retained messages, wills and in-flight messages are kept:
# "memory" (lost on restart), "sqlite" (kept in the file at `path`) or
# "postgres" (kept in the database at `url`)
backend = "memory"
# path = "/var/lib/mercurio/mercurio.db"
# url = "postgres://mercurio:password@localhost/mercurio?sslmode=require"
# Connections to another host, or with ca_path, are encrypted unless the URL
# sets sslmode=prefer or disable; the certificate and host name are verified
# CA that signed the PostgreSQL server's certificate; Mozilla's roots otherwise
# ca_path = "/etc/mercurio/postgres-ca.pem"
# One broker serves from a PostgreSQL database at a time. When another is,
# exit (false), or wait and take over once it stops (true)
# standby = false
# Connections each broker keeps to PostgreSQL, plus one that holds the
# database: the server's max_connections must allow pool_size + 1 for each
# broker, standbys included
# pool_size = 16
# Seconds a write waits for a free connection before it is tried again
# pool_timeout = 5

[auth]
# Require clients to authenticate. password_file, dynamic_security and
# user_url authenticate clients, so the broker refuses to start when one is
# set while this is false; acl_file and acl_url work either way
enabled = false

# Allow clients that send no user name (when auth is enabled)
allow_anonymous = false

# User names and Argon2 password hashes, managed with `mercuriod passwd`,
# which also stores SCRAM-SHA-256 keys so clients can log in without
# sending the password; entries set before, or by mosquitto_passwd, get
# them when their password is set again
# password_file = "/etc/mercurio/passwd"

# Restrict the topics each client may publish and subscribe to, using
# mosquitto's acl_file format. Anything not granted is denied. User rules
# only apply to user names verified by authentication; other clients are
# matched as anonymous.
# acl_file = "/etc/mercurio/acl"

# Manage clients, roles and access at runtime instead, with the commands of
# mosquitto's dynamic security plugin; replaces password_file and acl_file.
# Create the file with `mercuriod dynsec-init -f <path>`.
# dynamic_security = "/var/lib/mercurio/dynamic-security.json"

# Or delegate checks to an HTTP service: a 2xx answer allows. acl_url alone,
# without authentication, only checks topic access
# [auth.http]
# user_url = "http://auth.internal/mqtt/user"
# acl_url = "http://auth.internal/mqtt/acl"
# A 2xx answer for a user name makes it a superuser: no acl_url check
# restricts it, and it may administer the broker over $CONTROL. Only with
# authentication enabled
# superuser_url = "http://auth.internal/mqtt/superuser"
# No longer than [server] plugin_timeout
# timeout_seconds = 5
# cache_seconds = 30
# For https:// endpoints: CA certificates their certificates must chain to,
# in place of the bundled roots, and a client certificate with its key for
# endpoints that ask for one. Each file is PEM.
# ca_path = "/etc/mercurio/auth-ca.pem"
# cert_path = "/etc/mercurio/auth-client.pem"
# key_path = "/etc/mercurio/auth-client-key.pem"

[admin]
# Unix socket for administering the broker from this machine, as mercurioctl
# does. Its clients skip authentication and access control, so only root,
# the broker's own user and the members of `group` may connect; others are
# closed at once. Every directory on a custom socket path must be writable
# only by root or the broker's user. "" turns it off. Linux only: elsewhere
# this is ignored.
socket = "/run/mercurio/admin.sock"
# Members are read when the broker starts; add an administrator with
# `usermod -aG mercurio <user>`. "" leaves only root and the broker's user.
group = "mercurio"

# Extra listeners, each with its own protocol ("mqtt" or "websocket") and
# TLS settings, on a TCP `port` (`host` defaults to 127.0.0.1) or a Unix
# socket `path`.
# [[listeners]]
# host = "0.0.0.0"
# port = 8883
# [listeners.tls]
# authentication enabled
# cert_path = "/etc/mercurio/server.crt"
# key_path = "/etc/mercurio/server.key"
#
# [[listeners]]
# port = 8080
# protocol = "websocket"
# Web pages from other origins are refused (browsers send the page's origin;
# other clients send none and are not affected); any origin when unset
# allowed_origins = ["https://app.example.com"]
#
# Health and Prometheus metrics endpoints over HTTP, on WebSocket listeners
# only and off by default: GET /healthz answers 200 while the broker serves,
# GET /metrics the statistics. Metrics tell anyone who reaches the listener
# how loaded the broker is, so give them a listener of their own on a
# loopback address
# [[listeners]]
# host = "127.0.0.1"
# port = 9883
# protocol = "websocket"
# health = true
# metrics = true
#
# Unix domain socket, for clients on the same machine. It belongs to the
# broker's user with mode 0600, so only that user may connect, unless
# `group` and `mode` open it to others (Linux only)
# [[listeners]]
# path = "/run/mercurio/mqtt.sock"
# Group the socket belongs to; the mode is then 0660 unless set
# group = "mqtt"
# Permission bits in octal
# mode = "0660"
#
# Any TCP listener may also set max_connections, on top of the broker-wide
# limit, bind_interface, and max_connection_rate, the new connections a
# second it accepts with connection_rate_burst at once, closing the others
# unanswered. Any listener may replace the [server] limits
# max_packet_size, max_connect_packet_size, max_keepalive and
# connect_timeout for its clients, and with use_username_as_clientid give
# each client the user name it authenticated as for client id, refusing
# clients without one
# [[listeners]]
# host = "0.0.0.0"
# port = 8884
# max_connections = 100
# bind_interface = "eth1"
# max_connection_rate = 50
# connection_rate_burst = 200
# max_packet_size = 65536
# max_connect_packet_size = 4096
# max_keepalive = 120
# connect_timeout = 5
# use_username_as_clientid = true
#
# Behind a load balancer that sends the PROXY protocol (v1 or v2), so logs
# show the clients' own addresses
# [[listeners]]
# host = "0.0.0.0"
# port = 1884
# proxy_protocol = true
# Proxies allowed to send the header, as addresses or CIDR blocks; others
# are closed, as their header could claim any address. Required with
# proxy_protocol; ["0.0.0.0/0", "::/0"] lets any peer send one
# proxy_from = ["10.0.0.0/8"]
#
# A tenant's listener: its clients only see topics under the mount point
# and log in with their own password file. `[listeners.auth]` takes the
# same settings as `[auth]` and replaces it for this listener.
# [[listeners]]
# host = "0.0.0.0"
# port = 1885
# mount_point = "tenant1/"
# Names of the [[plugins]] its clients go through; all of them when unset
# plugins = ["deny"]
# [listeners.auth]
# authentication enabled
# allow_anonymous = false
# password_file = "/etc/mercurio/tenant1.passwd"

# Bridges forward topics to and from another broker
# [[bridges]]
# name = "cloud"
# host = "broker.example.com"
# port = 8883
# client_id = "mercurio-bridge-cloud"
# username = "site1"
# password = "secret"
# Instead of host and port: brokers tried in turn when one is unreachable
# addresses = ["broker1.example.com:8883", "broker2.example.com:8883"]
# "mqttv5" (default) or "mqttv311" for brokers without MQTT 5
# protocol_version = "mqttv5"
# Keep-alive with the remote broker, in seconds
# keepalive = 60
# Start a new session on the remote broker at each connection instead of
# resuming the one kept there
# clean_start = false
# Seconds the remote broker keeps the session, and messages for "in"
# topics, after the link drops
# session_expiry = 86400
# Publish "1"/"0", retained, to $SYS/broker/connection/<client id>/state
# notifications = true
# Another topic for the state
# notification_topic = "bridges/cloud/state"
# With false, publish the state on the remote broker too; it publishes "0"
# through the bridge's will when the link drops. Brokers that refuse
# client publishes to $SYS, Mercurio among them, need a notification_topic
# notifications_local_only = true
# Each bridge needs its own name and client id; with notifications and no
# notification_topic, the client id must be a single topic level, without
# `/`, `+` or `#`
# [bridges.tls]
# TLS with the system's root certificates
# authentication enabled
# A CA, or a client certificate, turns TLS on even without `enabled`
# ca_path = "/etc/mercurio/cloud-ca.crt"
# Client certificate, for brokers that require mutual TLS; both or neither
# cert_path = "/etc/mercurio/site1.crt"
# key_path = "/etc/mercurio/site1.key"
#
# Each topic is forwarded "out" (the default), "in" or "both" ways.
# `pattern` is matched below `local_prefix` here and `remote_prefix` there.
# [[bridges.topics]]
# pattern = "sensors/#"
# direction = "out"
# qos = 1
# local_prefix = ""
# remote_prefix = "site1/"

# WebAssembly plugins, run in this order after the built-in authentication
# and access control. Components built for plugin interface 0.2 or 0.1 load;
# listPlugins shows which. A plugin publishes as the in-process client
# mercurio-plugin-<name>, never to $ topics, 64 KiB a message and 100
# messages a second at most. SIGHUP rereads the file at `path`; other
# changes need a restart. See docs/plugins.html
# [[plugins]]
# path = "/etc/mercurio/plugins/deny_topic.wasm"
# Names the plugin in logs, in $CONTROL/<name>/ and in a listener's
# `plugins`; the file name without .wasm by default
# name = "deny"
# Strings passed to the plugin's init
# config = { prefix = "$internal/" }
# Hosts it may send HTTP requests to, as "host" (port 443) or "host:port"
# allow_http = ["audit.internal:80"]
# Addresses it may open sockets to, as "address:port"
# allow_sockets = ["10.0.0.5:6379"]
# Copies that run calls at once; the number of CPUs by default
# instances = 4
# Longest one call may run, in milliseconds; plugin_timeout by default, and
# no longer than it
# timeout_ms = 5000
# Memory each copy may use, in MiB
# memory_mb = 64

Authentication

With [auth] enabled = true, clients must log in with a user name and password from password_file, unless allow_anonymous lets clients that send no user name in. Credentials that are sent are always checked.

The password file holds one user:hash line per user, with Argon2 hashes. Files written by mosquitto_passwd work too: their $7$ (PBKDF2-SHA512) and $6$ (SHA-512) hashes are recognised. Any other hash field, such as a plain-text password or an empty one, stops the file from loading, with an error naming its line. Manage it with:

# Add a user, or change their password (prompts for it)
mercuriod passwd -f /etc/mercurio/passwd -u alice

# Remove a user
mercuriod passwd -f /etc/mercurio/passwd -u alice -d

passwd replaces the file in one step, keeping its owner, group and mode (at most 0640). A new file gets mode 0600: let the broker read it with chgrp mercurio and chmod 640. It refuses to change a file it cannot read in full, so fix a malformed line by hand first.

Access control

acl_file restricts which topics each client may publish and subscribe to, in the format of mosquitto's acl_file, so existing files can be reused:

# Rules before any `user` line apply to anonymous clients
topic read public/#

user alice
topic readwrite alice/#
topic deny alice/secret

# Patterns apply to every client; %u is the user name, %c the client id
pattern write devices/%c/status

The access is read, write, readwrite (the default) or deny. Anything not granted is denied, and deny overrides any grant. A subscription is allowed only if a rule covers its whole filter: a client granted sensors/+/temperature may subscribe to it, but not to sensors/#. Shared subscriptions are checked by their topic filter: $share/group/sensors/+/temperature needs the same grant.

Dynamic security

dynamic_security replaces password_file and acl_file with clients, roles and access rules that change while the broker runs, as with mosquitto's dynamic security plugin. Create the file with an administrator:

sudo mercuriod dynsec-init -f /var/lib/mercurio/dynamic-security.json -u admin

The file is created with mode 0600. Run as root in a directory owned by another user, as the service's state directory /var/lib/mercurio is, the command gives the file to that directory's owner and group, so the service can read it. systemd creates that directory when the service first starts: start it once before, with systemctl start mercuriod, if it has never run.

Coming from mosquitto, create it from the plugin's file instead:

sudo mercuriod dynsec-import /var/lib/mosquitto/dynamic-security.json \
    /var/lib/mercurio/dynamic-security.json

It carries over clients, groups, roles, ACLs, priorities, the default access and the anonymous group, gives the file to its directory's owner as dynsec-init does, and never overwrites a file. Passwords keep working: mosquitto's PBKDF2-SHA512 hashes are checked as they are, and stored as Argon2 once set again. What it leaves out, such as unsubscribe ACLs, it lists.

The administrator manages it with mercurioctl, which names mosquitto's commands in kebab-case, as mosquitto_ctrl does:

mercurioctl dynsec create-role sensor
mercurioctl dynsec add-role-acl sensor publishClientSend 'sensors/#' allow
mercurioctl dynsec create-client s1 --role sensor   # asks for the password

Underneath, these are JSON commands on $CONTROL/dynamic-security/v1, in the format of mosquitto's plugin.

Supported commands: createClient, modifyClient, deleteClient, setClientPassword, enableClient, disableClient, addClientRole, removeClientRole, getClient, listClients, createRole, modifyRole, deleteRole, addRoleACL, removeRoleACL, getRole, listRoles, setDefaultACLAccess and getDefaultACLAccess, and for groups createGroup, modifyGroup, deleteGroup, addGroupRole, removeGroupRole, addGroupClient, removeGroupClient, getGroup, listGroups, setAnonymousGroup and getAnonymousGroup. A client created with a clientid may connect only with that client id. A user name with no client, such as one from a client certificate, gets no access at all; create a client of that name, without a password, to give it roles. A client has its own roles and those of its groups; clients without a user name get those of the anonymous group. ACLs of type publishClientSend decide publishing, publishClientReceive receiving, and subscribePattern and subscribeLiteral subscribing. As in mosquitto, a check walks the client's own roles, then its groups' roles, group by group, each list highest priority first (-1 when none is given), and within a role its ACLs by their priority; the first ACL that matches decides. Without a matching ACL, only receiving is allowed, unless setDefaultACLAccess changes it. Changes are saved to the file at once. As in mosquitto, a change that can take access away disconnects the connected clients it concerns, with Administrative Action (0x98), so they log in again under their new access.

$CONTROL topics are only open to clients when access control grants them, so a broker without acl_file or dynamic_security refuses them over the network. The admin socket, below, needs no grant.

HTTP authentication

[auth.http] delegates checks to an HTTP service, as mosquitto-go-auth's HTTP backend does. For each login, the broker POSTs {"username", "password", "clientid"} to user_url; clientid is the one the client sent, empty when the broker is to assign one. For each topic check, it POSTs {"username", "clientid", "topic", "acc"} to acl_url, with acc 1 to receive, 2 to publish and 4 to subscribe. A 2xx status allows; anything else, a timeout included, denies. Topic answers are reused for cache_seconds, since every message is checked. With superuser_url, the broker first POSTs {"username"} there; a 2xx answer, cached like topic answers, makes the user a superuser, allowed every topic and $CONTROL without asking acl_url. Anything else, a failure included, means not a superuser. Over https://, ca_path names the CA certificates endpoints' certificates must chain to, in place of the bundled roots, and cert_path and key_path a client certificate for endpoints that ask for one. user_url replaces password_file and acl_url replaces acl_file.

Reloading

Send SIGHUP (systemctl reload mercuriod), or run mercurioctl broker reload, to reread password_file, acl_file, dynamic_security, the TLS cert_path, key_path, ca_path and crl_path of each listener and the .wasm file of each [[plugins]] entry without dropping connections. New ACL rules apply to every later publish and subscribe, including those of connected clients. Password and TLS changes apply to later connections; connected clients keep the certificate they were served. A reloaded plugin takes over once its init has run; calls already running finish on the old code. If a file fails to load, the broker logs the error and keeps the previous version. Other settings, the other [[plugins]] keys among them, still need a restart.

Administration

mercurioctl lists and disconnects clients, bans them, deletes sessions and retained messages, shows subscriptions, changes the log level and reloads files while the broker runs. On the broker's host it connects through the admin socket set in [admin], /run/mercurio/admin.sock by default, which root, the broker's user and members of the mercurio group may use without credentials. Over the network it needs a user that access control grants $CONTROL/broker/v1/#. The administration guide describes every command, its permissions and what changed since 0.4.

TLS

Set enabled = true under [server.tls] with a certificate and key to serve TLS on port, and on websocket_port when it is set. With ca_path, the broker also requires a client certificate signed by that CA. require_certificate = false makes it optional: clients without one log in by user name and password, while a certificate that is presented must still be valid. crl_path rejects revoked client certificates. Each certificate of the client's chain, intermediate CAs included, must be covered by a CRL from its issuer, or the client is refused: give the CRLs of the root and of every intermediate CA. crl_end_entity_only = true checks only the client's own certificate, and crl_allow_unknown = true accepts certificates whose issuer has no CRL in the file. With identity_as_username, the certificate's Common Name becomes the client's user name: it counts as authenticated, and user rules in acl_file apply to it. The user name and password in CONNECT are then ignored, except from clients that present no certificate where it is optional. subject_as_username uses the whole subject instead, written as RFC 2253 does and as mosquitto's use_subject_as_username gives it: CN=alice,O=Example,C=PT. min_version = "1.3" refuses TLS 1.2.

WebSocket

websocket_port serves MQTT over WebSocket, using the mqtt subprotocol, for browsers and clients behind HTTP proxies. Its sessions, retained messages and subscriptions are shared with the TCP listener.

Listeners

Each [[listeners]] entry serves another port, with its own protocol (mqtt or websocket) and its own [listeners.tls] settings. A typical setup keeps plain MQTT on localhost in [server] and adds a TLS listener on 0.0.0.0:8883. All listeners share sessions, subscriptions and retained messages.

mount_point puts a listener's clients in their own part of the topic tree: a client publishing sensors/t1 on a listener mounted at tenant1/ publishes tenant1/sensors/t1, and it is sent topics without the prefix. A mount point must end with /, as tenant1 alone would also reach tenant10/.... [listeners.auth] gives a listener its own authentication and acl_file, which SIGHUP reloads like the main ones. ACL rules see the full topic, mount point included.

A listener with path instead of port serves a Unix domain socket, which local clients reach without TCP and which file permissions can restrict. A socket file left behind by a broker that did not stop cleanly is replaced.

Bridges

A [[bridges]] entry connects to another broker and forwards topics between the two, like mosquitto's bridges. Each [[bridges.topics]] entry forwards pattern out, in or both ways. The prefixes rename topics on the way: with local_prefix = "site/" and remote_prefix = "plant1/", site/sensors/t1 here is plant1/sensors/t1 there.

With MQTT 5, bridges use No Local subscriptions so that forwarded messages never come back. MQTT 3.1.1 has no No Local, so with protocol_version = "mqttv311" the bridge recognises the copies the remote broker sends back and drops them; an identical message published on the remote broker just after one was forwarded is dropped too. With addresses, the bridge moves on to the next broker whenever it can't connect or loses the connection. With notifications, the bridge's state is published, retained, to $SYS/broker/connection/<client id>/state: 1 while connected, 0 otherwise. Two bridges may not share a name or a client id, as each would take over the other's session; with notifications, the client id must be a single topic level. tls.ca_path or a client certificate turns TLS on even without tls.enabled, as --cafile does for recado, and cert_path and key_path go together. The bridge connects to this broker in-process, so authentication and acl_file don't apply to it. A QoS 1 or 2 message is acknowledged to the broker it came from only once the other broker accepted it, so a connection lost on either side never loses it; it may arrive twice instead. Up to 100 messages are in flight in each direction, within the receiving broker's Receive Maximum, and keep their order. If the remote broker is unreachable, the bridge retries after one second, doubling the wait after each failed attempt up to a minute, with some randomness, and messages to forward wait in the bridge's queue on this broker, up to max_queued_messages. Its session there lasts a day, so messages for in topics wait for the bridge across short outages.

Persistence

With backend = "memory", everything is lost when the broker stops. With backend = "sqlite" or backend = "postgres", sessions, subscriptions, retained messages, wills, queued messages and messages in flight are kept in the database, so clients with persistent sessions resume where they left off after a restart. Queued QoS 1 and 2 messages of persistent sessions are kept whether their client is connected or not, each removed as it is sent, in the same write that records it in flight, or dropped; QoS 0 messages are not kept.

Changes are written in the background within milliseconds, so a crash loses only the changes of its last moments; a shutdown writes everything first. Changes the database fails to take are retried; when it keeps refusing them, as opposed to being out of reach, they are given up after a few attempts, and the client's whole state is written again, replacing what the database holds for it, as soon as the database takes it.

The SQLite database runs in WAL mode: while the broker runs, it keeps -wal and -shm files next to the database, and recent changes may be only in the -wal file. To back it up while the broker runs, use sqlite3 mercurio.db ".backup copy.db"; a plain copy of the database file alone can miss those changes. The broker creates the database readable by its owner only, and holds a lock on mercurio.db.lock while it runs, so a second broker started on the same database exits with an error; reading the database with sqlite3, as for a backup, still works. The database records its schema version, and a broker refuses a database written by a newer version of it.

Only one broker serves from a PostgreSQL database at a time. Another one started on it exits, or, with standby = true, waits without listening and takes over as soon as the serving broker's process stops, or about 11 seconds after its host goes down. A broker that can't reach the database for about 7 seconds stops and exits with an error, as a standby may take over; the database refuses writes from a broker that was taken over. PostgreSQL needs version 12 or later, connected to directly, without PgBouncer.

Statistics

Every sys_interval seconds the broker publishes retained statistics under $SYS/broker/, with mosquitto's names: version, uptime, clients/connected, clients/disconnected, clients/total, clients/maximum, clients/expired, messages/received, messages/sent, bytes/received, bytes/sent, retained messages/count, subscriptions/count and publish/messages/dropped, the messages lost before reaching a subscriber. Mercurio adds the dropped messages by cause under publish/messages/dropped/ (queue_full_qos0, queue_full, expired, too_large, not_authorized, no_share_member) and, under storage/, the batches of session changes written again after a storage error (storage/batches/retried), those given up on since the broker started (storage/batches/dropped) and the clients whose stored state is to be written whole again because of them (storage/clients/dirty). Under load/ come the 1, 5 and 15 minute averages per minute of messages, bytes and connections, such as load/messages/received/1min. Clients may subscribe to them, but not publish to $SYS topics. Retained $SYS messages, bridge states included, are kept in memory: new subscribers get the latest values, but they are not stored and don't count toward max_retained_messages.

A WebSocket listener with health = true answers GET /healthz with 200 while the broker serves; with metrics = true, GET /metrics gets the same counters in the Prometheus text format, as mercurio_* metrics. Both are off by default. Metrics show anyone who reaches the listener how loaded the broker is, so serve them on a listener of their own on a loopback address, as in the sample above.

Running

Direct execution

# With the default config location
mercuriod

# With a custom config file
mercuriod -c /path/to/config.toml

# Override the listen address
mercuriod -l 0.0.0.0:1883

# Debug logging
mercuriod -v

Ctrl+C or SIGTERM shuts the broker down gracefully: clients are sent a DISCONNECT saying the server is shutting down, and the broker waits up to five seconds for their connections to close before exiting.

Systemd service

A systemd unit is provided in contrib/mercuriod.service. It runs the broker sandboxed, with /var/lib/mercurio (for the SQLite database) and /var/log/mercurio (for a log file) as its only writable directories. Files the broker creates are readable by its user only, and it may keep 65536 files open, as each client connection takes one.

The unit is Type=notify: the broker sends READY=1 to $NOTIFY_SOCKET once its listeners are bound and it serves, so systemctl start returns, and units ordered after it start, only then. A PostgreSQL standby sends it once it takes over, and until then reports STATUS=Standing by: ... in systemctl status; as that may take any time, the unit sets TimeoutStartSec=infinity. On shutdown the broker sends STOPPING=1.

# Copy service file
sudo cp mercuriod/contrib/mercuriod.service /etc/systemd/system/

# Create config directory and copy config
sudo mkdir -p /etc/mercurio
sudo cp mercuriod/contrib/config.toml /etc/mercurio/

# Copy binary
sudo cp target/release/mercuriod /usr/bin/

# Enable and start service
sudo systemctl daemon-reload
sudo systemctl enable --now mercuriod

# Check status
sudo systemctl status mercuriod

# View logs
journalctl -u mercuriod -f

Features

See the project README for the full list of supported MQTT features.