Embedding the broker

mercurio-server is the broker as a library: run it inside your own program, with your own authentication, access control and storage.

Adding it

[dependencies]
mercurio-server = "0.5"
mercurio-storage = { version = "0.3", features = ["sqlite"] }
tokio = { version = "1", features = ["full"] }

Running a broker

use mercurio_server::server::{ListenerConfig, Server, Transport};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("0.0.0.0:1883").await?;
    Server::builder()
        .listener(ListenerConfig::new(listener, Transport::Mqtt))
        .run(tokio::signal::ctrl_c())
        .await?;
    Ok(())
}

The broker runs until the future given to run completes, then shuts down gracefully. Server::builder() starts a ServerBuilder with no listener, its state in memory and the default settings:

MethodSets
listener(ListenerConfig), listeners(...)The sockets to serve, any number, none of them special: TCP or Unix, MQTT or WebSocket, each with its own TLS, mount point, auth, plugins and limits
local_listener(LocalListener)In-process connections made before the broker is built, see below
storage(Arc<St>)Where sessions, retained messages, wills and bans are kept, see below
config(ServerConfig)Every broker-wide setting at once
with_auth, with_plugins, with_limits, with_policy, ...One broker-wide setting, as listed under Configuration

build() checks the configuration and restores what storage holds, and returns a Server that serves nobody yet; run(shutdown) on the builder builds and serves at once. A broker needs no network listener: with none, it serves the in-process clients of Server::local_connector.

A configuration that can't be used fails build() before anyone is served, with ServerError::Config. Its ConfigError names the listener, by its position among those given and its address, and its kind says which setting is wrong, such as ConfigErrorKind::MountPointWildcard for a mount point with a wildcard. Certificates and keys that can't be read are a TlsError, from Tls::new.

When run returns, every task the broker started has ended: connections still open after a few seconds, session expiry timers, wills waiting for their delay and the $SYS publisher are stopped, and what they were waiting for is taken up from storage by the next broker. The broker no longer holds its storage, so the same SQLite file can be opened again in the same process. An Admin handle kept from Server::admin still holds it until dropped.

Configuration

ServerConfig holds the broker-wide settings, which listeners use unless they have their own:

FieldHolds
authAuthConfig: whether authentication is required and anonymous clients are allowed
pluginsVec<Arc<dyn Plugin>>: the plugin chain, see below
plugin_timeoutLongest a plugin hook may take, 5 seconds by default
plugin_tick_intervalHow often Plugin::on_tick runs, 1 second by default
limitsLimits: connection, packet, queue and QoS limits
policyConnectPolicy: client id rules, redirection, response information
admin_extensionServes the $CONTROL/broker/v1 commands the broker doesn't know, see below
sys_intervalHow often $SYS statistics are published
use mercurio_server::{
    server::{Limits, ListenerConfig, Server, ServerConfig, Transport},
    tls::{Tls, TlsConfig},
};
use tokio::net::TcpListener;

let tls = Tls::new(TlsConfig::new("server.crt", "server.key"))?;
let plain = ListenerConfig::new(TcpListener::bind("0.0.0.0:1883").await?, Transport::Mqtt);
let secure = ListenerConfig::new(TcpListener::bind("0.0.0.0:8883").await?, Transport::Mqtt)
    .with_tls(tls.clone());

let config = ServerConfig::default()
    .with_limits(Limits::default().with_max_connections(10_000));
let server = Server::builder()
    .config(config)
    .listener(plain)
    .listener(secure)
    .build()
    .await?;

TLS

Tls::new reads the certificate, key, CA and CRL files a TlsConfig names, and fails if they can't be used. Tls::reload reads them again, for example after a certificate is renewed: connections from then on get the new files, and those already up keep theirs. A reload that fails returns the error and keeps the previous files. Clones of a Tls share it, so one reload reaches every listener given a clone.

For settings TlsConfig has no field for, such as ALPN, session resumption, a certificate resolver or a client verifier of your own, build a rustls::ServerConfig and serve it with Tls::from_rustls(config, username_from_certificate). Reloading is then up to you: Tls::reload returns an error, and Tls::set_server_config swaps in a new configuration for later connections. With a CertificateName, clients that present a certificate are named by it; whether they must present one is up to your client verifier.

Configuration types are built from their default, or from a constructor such as ListenerConfig::new, then changed with their with_* methods, one per field, or field by field: new fields may appear in minor releases, so they can't be written out as struct literals.

Plugins

One Plugin trait covers authentication, MQTT 5 enhanced authentication, topic access control, message hooks, lifecycle events, periodic work and $CONTROL commands. A plugin implements the hooks it needs; every other one does nothing. The broker runs its plugins as a chain, in the order configured:

Each hook call has a time limit, ServerConfig::plugin_timeout. A hook that runs out of time or panics denies, for authentication and access control, and drops, for messages. A listener can have its own chain in ListenerConfig::plugins; ListenerConfig::auth only sets require_auth and allow_anonymous for it.

use std::sync::Arc;

use mercurio_server::{
    plugin::{ready, Access, BoxFuture, ClientInfo, Credentials, Decision, Plugin},
    server::{AuthConfig, ServerConfig},
};

/// Lets each user use the topics under their own name.
struct OwnTopics;

impl Plugin for OwnTopics {
    fn name(&self) -> &str {
        "own-topics"
    }

    fn authenticate<'a>(
        &'a self,
        _: &'a ClientInfo,
        credentials: &'a Credentials<'a>,
    ) -> BoxFuture<'a, Decision> {
        // A real plugin would check a database here, and could await it
        let known = matches!(
            (credentials.username, credentials.password),
            ("alice", b"alice-secret") | ("bob", b"bob-secret")
        );
        ready(if known { Decision::Allow } else { Decision::Deny })
    }

    fn authorize<'a>(
        &'a self,
        client: &'a ClientInfo,
        topic: &'a str,
        _: Access,
    ) -> BoxFuture<'a, Decision> {
        let own = client
            .username
            .as_deref()
            .is_some_and(|user| topic.starts_with(&format!("{user}/")));
        ready(if own { Decision::Allow } else { Decision::Deny })
    }
}

let config = ServerConfig::default()
    .with_auth(AuthConfig::default().with_require_auth(true))
    .with_plugins(vec![Arc::new(OwnTopics)]);

Enhanced authentication

A plugin takes part in MQTT 5 enhanced authentication by returning an AuthExchange from Plugin::begin_auth for the methods it knows. begin_auth gets the client: during CONNECT its user name is the one the client claims, if any; on re-authentication it is the verified user. The broker calls step once per message from the client; each step returns AuthResult::Continue(challenge), Success { username, data } or Failed { reason, reason_string }, where reason_string optionally tells the client why in the CONNACK, or in the DISCONNECT that ends a failed re-authentication, unless the client sent Request Problem Information 0 or the text exceeds its Maximum Packet Size; it must not reveal, for example, whether a user exists. AuthResult::failed(reason) fails without one. data, such as SCRAM's final server message, goes to the client as Authentication Data in the CONNACK, or in the AUTH that ends a re-authentication. auth::PlainAuth is a complete example, with passwords held in plaintext for tests and demos: add it to ServerConfig::plugins to accept the PLAIN method.

auth::ScramAuth implements SCRAM-SHA-256 over credentials you store. Keep each user's StoredCredentials, made with StoredCredentials::generate(password, iterations) when the password is set, and look them up through ScramCredentialStore; the plain password is never kept. Return None for a user who doesn't exist or may not log in as that client: the broker then answers with a salt derived from the user name and fails the exchange at the proof, so the client can't tell. Give decoy_iterations the count your credentials use, mercurio_scram::DEFAULT_ITERATIONS unless you chose another.

use std::{collections::HashMap, sync::Arc};
use mercurio_server::auth::{ScramAuth, StoredCredentials};

let mut users = HashMap::new();
users.insert(
    "alice".to_string(),
    StoredCredentials::generate("secret", mercurio_scram::DEFAULT_ITERATIONS)?,
);
config.plugins.push(Arc::new(ScramAuth::new(Arc::new(users))));

A HashMap<String, StoredCredentials> is a store, for tests and demos; implement ScramCredentialStore on your own type for anything else. The mercurio-scram crate holds the messages and key derivation the broker and the client share.

Storage

use std::sync::Arc;

use mercurio_server::server::{ListenerConfig, Server, Transport};
use mercurio_storage::sqlite::SqliteStore;

let storage = Arc::new(SqliteStore::new("/var/lib/myapp/mqtt.db")?);
Server::builder()
    .storage(storage)
    .listener(ListenerConfig::new(listener, Transport::Mqtt))
    .run(shutdown)
    .await?;

Any type implementing the storage traits of mercurio-storage (sessions, retained messages, wills, in-flight messages and queues) can replace SQLite; postgres::PostgresStore::connect_exclusive(url, &options, wait), behind the postgres feature, keeps it in PostgreSQL; postgres::ConnectOptions sets the CA certificate and the size of its connection pool. It returns a DatabaseLock along with the store: only its holder may write to the database, and its lost() completes when another broker may have taken over, the moment to stop serving.

In-process clients

A LocalConnector opens connections to the broker in memory, without TCP. Combined with mercurio-client's connector option, it lets your program publish and subscribe on its own broker. These connections are trusted: authentication and access control don't apply to them.

use mercurio_client::{ConnectOptions, MqttClient, QoS};
use mercurio_server::server::Server;

let server = Server::builder().build().await?;
let connector = server.local_connector();
tokio::spawn(server.run(std::future::pending::<()>()));

let options = ConnectOptions::new("localhost", 0)
    .client_id("internal")
    .connector(move || std::future::ready(connector.connect()));
let client = MqttClient::connect(options).await?;
client.publish("app/started", "1", QoS::AtLeastOnce).await?;

Code that must hold a connector before the broker is built, as the daemon's plugins and bridges do, creates the pair with LocalConnector::new() and gives the LocalListener to ServerBuilder::local_listener. The daemon's bridges and dynamic security work this way.

Administering it

Server separates setting a broker up from serving it. In between it hands out an Admin, which runs the commands of the admin API as async methods, before and while the broker serves. ServerBuilder::run is a shortcut for ServerBuilder::build followed by Server::run.

use mercurio_server::{
    admin::ClientQuery,
    server::{ListenerConfig, Server, Transport},
};

let server = Server::builder()
    .listener(ListenerConfig::new(listener, Transport::Mqtt))
    .build()
    .await?;
let admin = server.admin();
tokio::spawn(server.run(tokio::signal::ctrl_c()));

let mut query = ClientQuery::default();
query.connected = Some(true);
for client in admin.clients(&query).await.items {
    println!("{} {:?}", client.client_id, client.address);
}

Logs

The broker logs through tracing. Install any subscriber, such as tracing_subscriber::fmt().init(). Connection and disconnection messages use the target mercurio::connections, the constant mercurio_server::CONNECTIONS, so they can be filtered alone.