WebAssembly plugins

A plugin is a WebAssembly component that mercuriod loads from a file and runs in a sandbox. It can authenticate clients, decide topic access, change, drop or publish messages and react to events, without a rebuild of the broker.

What a plugin can do

A plugin implements the hooks it needs. init returns the list of hooks the broker calls; the others are never called.

HookCalled
authenticateFor a client that sends a username and password. Returns allow, deny or defer.
auth-stepFor each step of MQTT 5 enhanced authentication, for the methods the plugin lists in init. Returns continue with a challenge, failed with a reason code and, optionally, a reason string for the client, or success with the user name and, for methods that end with a server message such as SCRAM, the data to send the client in the CONNACK or in the AUTH that ends a re-authentication.
authorizeWhen a client publishes, subscribes or receives a topic (write, subscribe, read). Returns allow, deny or defer. Publishing to $CONTROL/... is not asked here, but in authorize-control.
authorize-controlWhen a client publishes to $CONTROL/..., which administers the broker or a plugin. A request to $CONTROL/broker/v1 is asked about twice: for that topic, then for each of its commands as $CONTROL/broker/v1/<command>. Returns allow, deny or defer; when every plugin defers, the client is refused, so a plugin that allows every topic in authorize does not make every client an administrator.
on-messageFor each published message. Returns keep, drop or a replacement message. A replacement moved to a different topic that starts with $, such as $SYS/... or $CONTROL/..., is dropped and logged.
on-eventWhen a client connects, disconnects, subscribes or unsubscribes, and when a session expires. A session-expired event carries the client id and the mount point of its listener, which together name the session: listeners with different mount points can each have a session of one client id. Plugins built for interface 0.1 get the client id alone.
on-tickOnce a second, for periodic work.
on-controlFor a publish to $CONTROL/<plugin name>/.... It may return a response payload, which only the client that sent the request receives: on its Response Topic, or on the request's topic followed by /response.

The interface is wit/plugin.wit, version 0.2. A plugin also imports the host interface: log, a key-value store and publish.

Hooks get the client as a client-info: its client id; its user name, set only once the broker verified it, except in authenticate and auth-step during CONNECT, where it is the name the client claims; its address; and its connection, none where no connection is known, such as for the publisher of a retained message. The connection gives the listener's address or socket path (local for clients inside the broker), the MQTT version (3, 4 or 5), whether it is TLS, the name taken from the client's certificate on a listener that takes user names from certificates, and the listener's mount point. Topics reach plugins with the mount point in front.

How it runs

Plugins join the same chain as plugins written against the server library, after the built-in password and ACL checks and in the order of the [[plugins]] tables. The rules of the chain (the first allow or deny decides, defer asks the next plugin, every plugin sees a message in turn) are described in Embedding the broker.

The key-value store

kv-get, kv-set, kv-delete and kv-list give copies a place for shared state.

Publishing

publish sends a message with a topic, payload, QoS, retain flag and the MQTT 5 properties on-message sees. The plugin publishes as the client mercurio-plugin-<name>, inside the broker: access control is not asked, and the message is retained and sent on as any client's. The call returns once the message is queued. A message published before the broker serves clients, such as from init, goes out once it does.

A refused message returns an error and nothing is published; the call itself does not fail. The broker logs the first refusal of a run.

The other plugins' on-message see the message as from mercurio-plugin-<name>; the publishing plugin's own on-message does not, so a plugin that republishes what it sees never feeds itself. Two plugins that republish each other's messages still loop, at the pace their rate limits allow, so give such pairs topics that do not overlap. on-event sees the plugin's client connect, on its first message.

Writing a plugin in Rust

Install the target and make a library crate:

rustup target add wasm32-wasip2
cargo new --lib deny-topic

In Cargo.toml, build a cdylib:

[lib]
crate-type = ["cdylib"]

and add the latest release of mercurio-plugin, which holds the bindings and the Plugin trait:

cargo add mercurio-plugin

mercurio-plugin 0.1.x and 0.2.0 speak interface 0.1; the releases after 0.2.0 speak 0.2, which this page describes. Until a release after 0.2.0 is on crates.io, take the crate from the repository instead:

[dependencies]
mercurio-plugin = { git = "https://github.com/frenzox/mercurio" }

This plugin, examples/plugins/deny-topic, refuses publishing under a topic prefix from its configuration and defers every other decision:

use std::sync::OnceLock;

use mercurio_plugin::{
    export_plugin, Access, ClientInfo, Config, Decision, Hooks, Plugin, Registration,
};

/// Set by `init`; settings never change, so each copy keeps its own.
static PREFIX: OnceLock<String> = OnceLock::new();

struct DenyTopic;

impl Plugin for DenyTopic {
    fn init(config: Config) -> Result<Registration, String> {
        let prefix = config.require("prefix")?;
        if prefix.is_empty() {
            return Err("`prefix` is empty, which would deny every publish".into());
        }
        let _ = PREFIX.set(prefix.to_string());
        Ok(Registration {
            hooks: Hooks::AUTHORIZE,
            auth_methods: Vec::new(),
        })
    }

    fn authorize(_client: ClientInfo, topic: String, access: Access) -> Decision {
        let Some(prefix) = PREFIX.get() else {
            return Decision::Defer;
        };
        if access == Access::Write && topic.starts_with(prefix.as_str()) {
            Decision::Deny
        } else {
            Decision::Defer
        }
    }
}

export_plugin!(DenyTopic);

init gets the plugin's config table as a Config: get returns a setting if it is there, and require returns it or an error that names it. If init returns an error, the plugin does not load and the broker does not start. Combine flags to register more hooks, such as Hooks::AUTHORIZE | Hooks::ON_MESSAGE. The examples/plugins directory also has webhook-audit, which sends an HTTP request, and payload-rewrite, which changes messages.

Build it:

cargo build --release --target wasm32-wasip2

The component is target/wasm32-wasip2/release/deny_topic.wasm. Load it in the broker's configuration:

[[plugins]]
path = "/etc/mercurio/plugins/deny_topic.wasm"
name = "deny"
config = { prefix = "$internal/" }

webhook-audit sends plain HTTP, so its host needs the port in allow_http:

[[plugins]]
path = "/etc/mercurio/plugins/webhook_audit.wasm"
config = { url = "http://audit.internal/events" }
allow_http = ["audit.internal:80"]

A plugin built for mercurio:plugin/guest@0.2.x or @0.1.x loads. A component built for another version is refused, with an error that names the version. listPlugins in the admin API shows the version each plugin was built for. See the configuration reference for every key.

The broker matches a component to the interface by the shape of its types, so adding a hook, a field or a case would break plugins built for the old shape. Such changes come with a new minor version. A 0.2.x release changes only documentation, so a plugin built for any 0.2.x loads in a broker built for any other.

Plugins built for 0.1 keep loading and run as before: they get the client without its connection, have no authorize-control hook, so they defer on $CONTROL, send no data with an authentication success and no reason string with a failed, and cannot publish. To use those, rebuild against 0.2. In the Rust SDK, init takes a Config instead of a list of pairs, and AuthResult::Success takes a Success with the user name and the data.

The sandbox

A plugin that traps, runs out of memory or runs out of time fails that call as described above, and its copy is replaced. The broker logs the cause.

Other languages

Any toolchain that builds a WASI preview 2 component against wit/plugin.wit works. Starting points are wit-bindgen for C and C++, and componentize-py and jco componentize for Python and JavaScript. The project has examples only in Rust.

Reloading

Send SIGHUP to swap a plugin's code. The broker rereads each plugin's file, and replaces the running code once the new file has loaded and run init. Calls already running finish on the old code. If the new file fails to load, the old code keeps running and the broker logs why. The store is kept.

Changes to the [[plugins]] tables, such as config, instances or the allowlists, need a restart. A reload can change the interface version a plugin was built for.

Cost

A call into a component costs about 0.5 microseconds (0.55 in a microbenchmark of authorize). In the broker's benchmark, fan-in with QoS 1 had about 0.7% less throughput with an authorize plugin (deny-topic) on every message, and about 1.6% less with an on-message plugin. Fan-out was within noise. These numbers are approximate and depend on what the plugin does.

With the default wasm feature, the stripped mercuriod binary is about 18 MB. Build with --no-default-features to leave out wasmtime.