# mercuriod
The [Mercurio](https://github.com/frenzox/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`](https://crates.io/crates/recado) client publishes and subscribes
from the command line, and
[`mercurioctl`](https://crates.io/crates/mercurioctl) administers it while
it runs.
## Installation
Each [release](https://github.com/frenzox/mercurio/releases) 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:
```bash
sudo apt install ./mercuriod_*.deb
sudo dnf install ./mercuriod-*.rpm
```
Or with cargo:
```bash
cargo install mercuriod
```
Or build from the workspace root:
```bash
cargo build --release --package mercuriod
```
## Usage
```bash
mercuriod [OPTIONS]
mercuriod passwd -f <FILE> -u <USER> [-d]
mercuriod dynsec-init -f <FILE> [-u <USER>]
mercuriod dynsec-import <MOSQUITTO FILE> <FILE>
```
| `-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:
```bash
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`](contrib/config.toml):
```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:
```bash
# 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:
```text
# 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:
```bash
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:
```bash
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:
```bash
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](https://frenzox.github.io/mercurio/admin.html)
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
```bash
# 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`](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`.
```bash
# 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](../README.md#broker-mercuriod) for the full list
of supported MQTT features.