Listeners & transports

Serve MQTT over TCP, TLS, WebSocket and Unix sockets, from as many ports as you need, each with its own settings.

Every listener shares the same broker: sessions, subscriptions and retained messages are common to all of them, so a client on the TLS port receives what a client on the WebSocket port publishes.

The main listener

[server] defines the main listener, plain MQTT unless [server.tls] is enabled:

[server]
host = "0.0.0.0"
port = 1883

IPv6 addresses work as they are, such as host = "::". On Linux, bind_interface = "eth0" restricts the listener to one network interface, whatever its address.

Extra listeners

Each [[listeners]] entry adds a listener with its own protocol, TLS, limits and authentication. A common setup keeps plain MQTT on localhost for local services and serves TLS to the outside:

[server]
host = "127.0.0.1"
port = 1883

[[listeners]]
host = "0.0.0.0"
port = 8883
[listeners.tls]
enabled = true
cert_path = "/etc/mercurio/server.crt"
key_path = "/etc/mercurio/server.key"

WebSocket

For browsers and clients behind HTTP proxies. The broker uses the mqtt subprotocol and accepts any path.

# Next to the main listener, sharing its TLS settings
[server]
websocket_port = 8080

# Or as a listener of its own
[[listeners]]
host = "0.0.0.0"
port = 8080
protocol = "websocket"

From a browser, with MQTT.js:

const client = mqtt.connect("ws://broker.example.com:8080/mqtt");
client.on("connect", () => client.subscribe("sensors/#"));
client.on("message", (topic, payload) => console.log(topic, payload.toString()));

Clients must offer the mqtt subprotocol, as MQTT.js does; an upgrade without it is refused with 400 Bad Request. A page on any site can open a WebSocket to the broker through its visitor's browser, which matters for a broker on an internal network that allows anonymous clients. allowed_origins lists the pages that may:

[[listeners]]
port = 8080
protocol = "websocket"
allowed_origins = ["https://dashboard.example.com"]

An upgrade whose Origin header names another site is refused with 403 Forbidden. Clients other than browsers send no Origin and are not affected.

Serving a web page

A WebSocket listener can also serve static files, so a dashboard or test page needs no separate web server:

[[listeners]]
host = "0.0.0.0"
port = 8080
protocol = "websocket"
http_dir = "/var/www/mercurio"

Plain HTTP GET requests are answered from the directory, with index.html for / and directories; WebSocket upgrades on the same port go to MQTT. A directory named without its trailing slash is redirected to the slash form, and paths leaving the directory are refused. Files are streamed; each response may take a minute, apart from the connect timeout, and a listener sends 32 at once, answering further requests with 503 Service Unavailable. [server] http_dir does the same for websocket_port.

Health and metrics endpoints

A WebSocket listener can answer GET /healthz and GET /metrics, with or without http_dir. Both are off by default. Metrics tell anyone who reaches the listener how loaded the broker is, so give them a listener of their own that only monitoring can reach:

[[listeners]]
host = "127.0.0.1"
port = 9883
protocol = "websocket"
health = true
metrics = true

The same limits as for files apply: request size, 32 responses at once and the timeouts. See Health and metrics for what they answer.

Unix domain sockets

For clients on the same machine: no TCP overhead, and access restricted by file permissions.

[[listeners]]
path = "/run/mercurio/mqtt.sock"
mosquitto_pub --unix /run/mercurio/mqtt.sock -t local/test -m hi

A socket file left behind by a broker that did not stop cleanly is replaced at startup. Socket paths are limited to 107 bytes by the operating system.

Behind a load balancer

Behind HAProxy, NGINX or a cloud load balancer, every connection appears to come from the balancer. With proxy_protocol, the listener expects the PROXY protocol header, version 1 or 2, and takes the client's address from it:

[[listeners]]
host = "0.0.0.0"
port = 1884
proxy_protocol = true
proxy_from = ["10.0.0.0/8"]
# haproxy.cfg
backend mqtt
    mode tcp
    server mercurio 10.0.0.5:1884 send-proxy-v2

Connections without a valid header are closed. TLS, when enabled on the listener, starts after the header.

A header can claim any address, so proxy_from is required and names the balancers: connections from other addresses are closed. That matters most with max_connections_per_ip, which counts the address the header gives. When every peer that reaches the port is a proxy, say so with proxy_from = ["0.0.0.0/0", "::/0"]. A Unix socket listener takes no proxy_from: its file permissions decide who may connect. Version 2 headers with a command other than LOCAL or PROXY are refused.

Mount points

A mount point gives a listener's clients their own part of the topic tree, for example one per tenant:

[[listeners]]
host = "0.0.0.0"
port = 1885
mount_point = "tenant1/"

A client on that listener publishing sensors/t1 publishes tenant1/sensors/t1, and subscribing to # subscribes to tenant1/#. The messages it receives carry their topics without the prefix. Shared subscriptions are mounted too: $share/g/jobs becomes $share/g/tenant1/jobs. Clients of other listeners see the full topics.

ACL rules see the full topic, mount point included. A mount point must end with /: tenant1 alone would also let clients reach tenant10/....

Each mount point also has its own client ids: a client sensor-7 on this listener and one on another mount point, or on a listener without one, are two clients with two sessions, which never take each other over. Listeners with the same mount point share their clients' ids and sessions. The admin API and mercurioctl client commands name a client's mount point when its id is on more than one; see Client id ownership.

Per-listener authentication

[listeners.auth] replaces [auth] for a listener's clients, with the same options except dynamic security. Here local services connect freely, while the public listener requires passwords and has its own ACL:

[server]
host = "127.0.0.1"
port = 1883

[[listeners]]
host = "0.0.0.0"
port = 8883
[listeners.tls]
enabled = true
cert_path = "/etc/mercurio/server.crt"
key_path = "/etc/mercurio/server.key"
[listeners.auth]
enabled = true
allow_anonymous = false
password_file = "/etc/mercurio/public.passwd"
acl_file = "/etc/mercurio/public.acl"

A listener's files, its TLS certificate, key, CA and CRLs included, are reloaded on SIGHUP like the main ones.

Per-listener connection limits

[[listeners]]
host = "0.0.0.0"
port = 8883
max_connections = 500

Connections beyond the limit are closed right after being accepted. The broker-wide [server] max_connections still applies, and a flood on one listener can't use up the slots of another.

[[listeners]]
host = "0.0.0.0"
port = 8883
max_connection_rate = 50     # new connections a second, on average
connection_rate_burst = 200  # at once after a quiet spell; max_connection_rate by default

Connections beyond the rate are closed right after being accepted, before any handshake, as those beyond max_connections are. A reconnect storm after an outage then reaches the broker at a pace it can authenticate.

A listener may also replace some [server] limits for its own clients; those it leaves out come from [server]:

[[listeners]]
host = "0.0.0.0"
port = 8884
max_packet_size = 65536         # largest packet, in bytes
max_connect_packet_size = 4096  # largest CONNECT, before authentication
max_keepalive = 120             # longest keep-alive of MQTT 5 clients, in seconds
connect_timeout = 5             # seconds to finish the handshakes and send CONNECT

User names as client ids

[[listeners]]
host = "0.0.0.0"
port = 8883
use_username_as_clientid = true

Each client's session gets the user name the client authenticated as for client id, whatever client id it sent, as with mosquitto's option of the same name. MQTT 5 clients are told the id in the CONNACK's Assigned Client Identifier. A client without a verified user name, anonymous or one that sent a name its credentials didn't prove, is refused with Not Authorized. Each user then has one session, and a client can never take over the client id of another user. A trusted listener verifies no user names, so it can't use this.

Example: a multi-tenant broker

# /etc/mercurio/config.toml
include_dir = "/etc/mercurio/tenants"

[server]
host = "127.0.0.1"

# /etc/mercurio/tenants/acme.toml
[[listeners]]
host = "0.0.0.0"
port = 18831
mount_point = "acme/"
max_connections = 1000
[listeners.auth]
enabled = true
allow_anonymous = false
password_file = "/etc/mercurio/tenants/acme.passwd"

Each tenant gets a file with its own port, namespace, connection limit and users. An internal service on the localhost listener sees every tenant's topics under their prefixes.