#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use rumqttc::Transport;
#[cfg(feature = "testing")]
use rumqttc::v5::mqttbytes::matches;
use rumqttc::v5::mqttbytes::v5::{LastWill, Publish};
use rumqttc::v5::{AsyncClient, MqttOptions};
#[cfg(feature = "testing")]
use ruststream::testing::{Coordinator, InProcess, TestableBroker};
use ruststream::{
AckError, AddressedCopies, Broker, ConnectedBroker, DefaultPublish, DescribeServer, ServerSpec,
Subscribe,
};
#[cfg(feature = "testing")]
use ruststream::{OutgoingMessage, RawMessage};
use tokio::sync::{OnceCell, mpsc, oneshot};
#[cfg(feature = "asyncapi")]
use crate::asyncapi::{self, MqttLastWill, MqttServer};
use crate::conn::{Conn, Shared, run};
use crate::error::MqttError;
use crate::filter::{MqttFilter, MqttTopic, Qos};
#[cfg(feature = "testing")]
use crate::in_process::Bus;
use crate::publisher::{MqttPublish, MqttPublisher};
use crate::subscriber::MqttSubscriber;
#[derive(Clone)]
pub(crate) enum Link {
Wire(AsyncClient),
#[cfg(feature = "testing")]
InProcess(Arc<Bus>),
}
const SHUTDOWN_FLUSH: Duration = Duration::from_secs(5);
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Link>() == size_of::<AsyncClient>());
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Core>() == size_of::<(AsyncClient, Arc<Shared>)>());
#[cfg(not(feature = "testing"))]
const _: () =
assert!(size_of::<Option<(Link, Publish)>>() == size_of::<Option<(AsyncClient, Publish)>>());
impl std::fmt::Debug for Link {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Link").finish_non_exhaustive()
}
}
impl Link {
pub(crate) async fn ack(&self, publish: &Publish) -> Result<(), AckError> {
let acknowledged = match self {
Self::Wire(client) => client.ack(publish).await.is_ok(),
#[cfg(feature = "testing")]
Self::InProcess(bus) => bus.is_open(),
};
if acknowledged {
Ok(())
} else {
Err(AckError::Broker(Box::from(
"the mqtt connection task has shut down",
)))
}
}
async fn disconnect(&self, shared: &Shared) {
match self {
Self::Wire(client) => {
let _ = client.disconnect().await;
if tokio::time::timeout(SHUTDOWN_FLUSH, shared.stopped())
.await
.is_err()
{
tracing::warn!(
timeout = ?SHUTDOWN_FLUSH,
"mqtt shutdown stopped waiting for the connection task to flush its queue"
);
}
}
#[cfg(feature = "testing")]
Self::InProcess(bus) => bus.close(),
}
}
}
pub(crate) struct Core {
pub(crate) link: Link,
pub(crate) shared: Arc<Shared>,
}
impl std::fmt::Debug for Core {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Core").finish_non_exhaustive()
}
}
pub(crate) type CoreCell = Arc<OnceCell<Core>>;
#[derive(Debug, Clone)]
#[must_use]
pub struct MqttBroker {
url: String,
client_id: String,
credentials: Option<(String, String)>,
keep_alive: Option<Duration>,
clean_start: Option<bool>,
session_expiry: Option<u32>,
max_packet_size: u32,
receive_maximum: u16,
last_will: Option<(String, Vec<u8>, Qos, bool)>,
tls_ca: Option<Vec<u8>>,
tls_client_auth: Option<(Vec<u8>, Vec<u8>)>,
cell: CoreCell,
}
impl MqttBroker {
pub fn new(url: impl Into<String>, client_id: impl Into<String>) -> Self {
Self {
url: url.into(),
client_id: client_id.into(),
credentials: None,
keep_alive: None,
clean_start: None,
session_expiry: None,
max_packet_size: 1024 * 1024,
receive_maximum: 1000,
last_will: None,
tls_ca: None,
tls_client_auth: None,
cell: Arc::new(OnceCell::new()),
}
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.credentials = Some((username.into(), password.into()));
self
}
pub fn keep_alive(mut self, keep_alive: Duration) -> Self {
self.keep_alive = Some(keep_alive);
self
}
pub fn clean_start(mut self, clean_start: bool) -> Self {
self.clean_start = Some(clean_start);
self
}
pub fn session_expiry(mut self, expiry: Duration) -> Self {
self.session_expiry = Some(u32::try_from(expiry.as_secs()).unwrap_or(u32::MAX));
self
}
pub fn max_packet_size(mut self, bytes: u32) -> Self {
self.max_packet_size = bytes;
self
}
pub fn receive_maximum(mut self, maximum: u16) -> Self {
self.receive_maximum = maximum;
self
}
pub fn last_will(
mut self,
topic: impl Into<String>,
payload: impl Into<Vec<u8>>,
qos: Qos,
retain: bool,
) -> Self {
self.last_will = Some((topic.into(), payload.into(), qos, retain));
self
}
pub fn tls_ca(mut self, ca: impl Into<Vec<u8>>) -> Self {
self.tls_ca = Some(ca.into());
self
}
pub fn tls_client_auth(mut self, cert: impl Into<Vec<u8>>, key: impl Into<Vec<u8>>) -> Self {
self.tls_client_auth = Some((cert.into(), key.into()));
self
}
#[must_use]
pub fn publisher(&self) -> MqttPublisher {
MqttPublisher::new(Arc::clone(&self.cell), Qos::default(), false)
}
fn tls_from_scheme(&self) -> bool {
self.url.starts_with("mqtts://")
}
fn authority(&self) -> String {
ServerSpec::host_from_url(&self.url)
}
fn endpoint(&self) -> Result<(String, u16), MqttError> {
let authority = self.authority();
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (
host.to_owned(),
port.parse::<u16>()
.map_err(|_| MqttError::Invalid(format!("'{port}' is not a valid port")))?,
),
None => (
authority.clone(),
if self.tls_from_scheme() { 8883 } else { 1883 },
),
};
if host.is_empty() {
return Err(MqttError::Invalid("host must be non-empty".into()));
}
Ok((host, port))
}
fn options(&self) -> Result<MqttOptions, MqttError> {
let tls_from_scheme = self.tls_from_scheme();
let (host, port) = self.endpoint()?;
if let Some(keep_alive) = self.keep_alive
&& keep_alive < Duration::from_secs(5)
{
return Err(MqttError::Invalid(
"keep_alive must be at least 5 seconds".into(),
));
}
let mut options = MqttOptions::new(self.client_id.clone(), host, port);
if let Some(keep_alive) = self.keep_alive {
options.set_keep_alive(keep_alive);
}
if let Some(clean_start) = self.clean_start {
options.set_clean_start(clean_start);
}
if let Some((username, password)) = &self.credentials {
options.set_credentials(username.clone(), password.clone());
}
if let Some(expiry) = self.session_expiry {
options.set_session_expiry_interval(Some(expiry));
}
options.set_max_packet_size(Some(self.max_packet_size));
options.set_receive_maximum(Some(self.receive_maximum));
options.set_manual_acks(true);
if let Some((topic, payload, qos, retain)) = &self.last_will {
options.set_last_will(LastWill::new(
topic.clone(),
payload.clone(),
qos.to_client(),
*retain,
None,
));
}
if tls_from_scheme || self.tls_ca.is_some() || self.tls_client_auth.is_some() {
let Some(ca) = self.tls_ca.clone() else {
return Err(MqttError::Invalid(
"a TLS connection needs the certificate authority to trust: call tls_ca(pem)"
.to_owned(),
));
};
options.set_transport(Transport::tls(ca, self.tls_client_auth.clone(), None));
}
Ok(options)
}
}
impl Broker for MqttBroker {
type Error = MqttError;
type Connected = ConnectedMqttBroker;
async fn connect(self) -> Result<Self::Connected, Self::Error> {
let core = self
.cell
.get_or_try_init(async || {
let options = self.options()?;
let (client, eventloop) = AsyncClient::new(options, 64);
let shared = Arc::new(Shared::new());
let (connack_tx, connack_rx) = oneshot::channel();
tokio::spawn(run(Conn {
client: client.clone(),
eventloop,
shared: Arc::clone(&shared),
first_connack: Some(connack_tx),
}));
match tokio::time::timeout(Duration::from_secs(30), connack_rx).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(err))) => return Err(err),
Ok(Err(_)) => {
return Err(MqttError::Connect(Box::from(
"the connection task exited before the first CONNACK",
)));
}
Err(_) => {
shared.closed.store(true, Ordering::Release);
let _ = client.try_disconnect();
let reason = shared.last_error().map_or_else(
|| "timed out waiting for the broker's CONNACK".to_owned(),
|last| {
format!(
"timed out waiting for the broker's CONNACK; \
last connection error: {last}"
)
},
);
return Err(MqttError::Connect(Box::from(reason)));
}
}
Ok::<_, MqttError>(Core {
link: Link::Wire(client),
shared,
})
})
.await?;
Ok(ConnectedMqttBroker {
link: core.link.clone(),
shared: Arc::clone(&core.shared),
cell: self.cell,
})
}
}
#[cfg(feature = "testing")]
impl InProcess for MqttBroker {
async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
self.options()?;
let max_packet_size = self.max_packet_size;
let core = self
.cell
.get_or_try_init(async || {
Ok::<_, MqttError>(Core {
link: Link::InProcess(Bus::new(max_packet_size)),
shared: Arc::new(Shared::new()),
})
})
.await?;
Ok(ConnectedMqttBroker {
link: core.link.clone(),
shared: Arc::clone(&core.shared),
cell: self.cell,
})
}
}
#[cfg(feature = "testing")]
ruststream::register_testable_broker!(MqttBroker);
impl MqttBroker {
#[cfg(feature = "asyncapi")]
fn server_binding(&self) -> ruststream::asyncapi::Bindings {
asyncapi::server(&MqttServer {
client_id: self.client_id.clone(),
clean_session: self.clean_start,
last_will: self
.last_will
.as_ref()
.map(|(topic, _payload, qos, retain)| MqttLastWill {
topic: topic.clone(),
qos: qos.level(),
retain: *retain,
}),
keep_alive: self.keep_alive.map(|interval| interval.as_secs()),
session_expiry_interval: self.session_expiry,
maximum_packet_size: self.max_packet_size,
})
}
}
impl DescribeServer for MqttBroker {
fn describe_server(&self) -> ServerSpec {
let spec = self.endpoint().map_or_else(
|_| ServerSpec::from_url(&self.url, "mqtt"),
|(host, port)| ServerSpec::new(format!("{host}:{port}"), "mqtt"),
);
let spec = spec.protocol_version("5");
#[cfg(feature = "asyncapi")]
let spec = spec.bindings(self.server_binding());
spec
}
}
pub struct ConnectedMqttBroker {
link: Link,
shared: Arc<Shared>,
cell: CoreCell,
}
impl std::fmt::Debug for ConnectedMqttBroker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnectedMqttBroker")
.finish_non_exhaustive()
}
}
impl ConnectedMqttBroker {
#[must_use]
pub fn publisher(&self) -> MqttPublisher {
MqttPublisher::new(Arc::clone(&self.cell), Qos::default(), false)
}
pub(crate) fn publisher_with(&self, policy: MqttPublish) -> MqttPublisher {
policy.into_publisher(Arc::clone(&self.cell))
}
pub async fn subscribe_topic(&self, topic: MqttTopic) -> Result<MqttSubscriber, MqttError> {
topic.validate()?;
self.open(topic.into_filter()).await
}
pub async fn subscribe_filter(&self, filter: MqttFilter) -> Result<MqttSubscriber, MqttError> {
filter.validate()?;
self.open(filter).await
}
async fn open(&self, topic: MqttFilter) -> Result<MqttSubscriber, MqttError> {
self.shared.ensure_open()?;
let wire_filter = topic.wire_filter();
let qos = topic.qos_value().to_client();
let (tx, rx) = mpsc::unbounded_channel();
let id = self
.shared
.open(&self.link, &wire_filter, topic.filter(), qos, tx)
.await?;
Ok(MqttSubscriber::new(
topic.filter().to_owned(),
id,
Arc::clone(&self.shared),
self.link.clone(),
rx,
))
}
}
impl ConnectedBroker for ConnectedMqttBroker {
type Error = MqttError;
type Closed = ();
async fn shutdown(self) -> Result<(), Self::Error> {
self.shared.closed.store(true, Ordering::Release);
let held = self.shared.held();
if held > 0 {
tracing::warn!(
held,
"mqtt shutdown with deliveries no subscription ever matched"
);
}
self.link.disconnect(&self.shared).await;
Ok(())
}
}
impl Subscribe for ConnectedMqttBroker {
type Subscriber = MqttSubscriber;
type Copies = AddressedCopies;
async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
self.subscribe_topic(MqttTopic::new(name)).await
}
}
impl DefaultPublish for ConnectedMqttBroker {
type Policy = MqttPublish;
}
#[cfg(feature = "testing")]
impl TestableBroker for ConnectedMqttBroker {
fn install_coordinator(&self, coordinator: Coordinator) {
if let Link::InProcess(bus) = &self.link {
bus.install(coordinator);
}
}
fn inject(&self, message: OutgoingMessage<'_>) {
let bus = self.bus("inject");
if let Err(err) = bus.inject(&self.shared, &message) {
panic!(
"the injected message to {:?} is not one a server takes: {err}",
message.name()
);
}
}
fn published(&self, name: &str) -> Vec<RawMessage> {
self.bus("published").published(name)
}
fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
let mut routed = Vec::new();
let mut answered: Vec<&str> = Vec::new();
for filter in subscriptions {
if !matches(destination, filter) || answered.contains(filter) {
continue;
}
answered.push(filter);
let positions = subscriptions
.iter()
.enumerate()
.filter(|(_, name)| *name == filter)
.map(|(position, _)| position);
let takers = self.shared.next_takers(filter);
if takers.is_empty() {
routed.extend(positions);
} else {
let positions: Vec<usize> = positions.collect();
routed.extend(takers.iter().filter_map(|rank| positions.get(*rank)));
}
}
routed.sort_unstable();
routed
}
}
#[cfg(feature = "testing")]
impl ConnectedMqttBroker {
fn bus(&self, what: &str) -> &Arc<Bus> {
match &self.link {
Link::InProcess(bus) => bus,
Link::Wire(_) => panic!(
"TestableBroker::{what} reached a broker connected with `connect`; the harness \
drives the connection `connect_in_process` produces"
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn broker(url: &str) -> MqttBroker {
MqttBroker::new(url, "describe")
}
#[test]
fn the_endpoint_is_the_host_and_port_whatever_else_the_url_carries() {
for (url, expected) in [
("mqtt://broker.example.com", ("broker.example.com", 1883)),
("mqtts://broker.example.com", ("broker.example.com", 8883)),
(
"mqtt://broker.example.com:1884",
("broker.example.com", 1884),
),
(
"mqtt://alice:s3cret@broker.example.com:1884",
("broker.example.com", 1884),
),
(
"mqtts://alice:s3cret@broker.example.com",
("broker.example.com", 8883),
),
(
"mqtt://alice:p@ss@broker.example.com:1884",
("broker.example.com", 1884),
),
(
"mqtt://broker.example.com:1884/ns",
("broker.example.com", 1884),
),
("mqtt://broker.example.com/ns", ("broker.example.com", 1883)),
(
"mqtt://broker.example.com/a@b",
("broker.example.com", 1883),
),
] {
let broker = broker(url);
let (host, port) = broker.endpoint().expect("the url parses");
assert_eq!((host.as_str(), port), expected, "parsing {url}");
}
}
#[tokio::test]
async fn tls_without_a_certificate_authority_is_refused_before_any_io() {
let scheme = MqttBroker::new("mqtts://127.0.0.1:8883", "tls-scheme");
let client_auth =
broker("mqtt://127.0.0.1:1").tls_client_auth(b"cert".to_vec(), b"key".to_vec());
for (broker, asked) in [
(scheme, "the URL scheme"),
(client_auth, "a client certificate"),
] {
let error = broker
.connect()
.await
.expect_err("nothing can verify the server");
assert!(
matches!(&error, MqttError::Invalid(reason) if reason.contains("tls_ca")),
"{asked} asks for TLS, and the refusal names what is missing: {error}"
);
}
}
#[tokio::test]
async fn a_keep_alive_below_the_protocol_floor_is_refused_before_any_io() {
let error = broker("mqtt://127.0.0.1:1")
.keep_alive(Duration::from_secs(1))
.connect()
.await
.expect_err("the protocol floor is five seconds");
assert!(
matches!(&error, MqttError::Invalid(reason) if reason.contains("5 seconds")),
"the refusal names the floor: {error}"
);
}
#[test]
fn a_url_with_credentials_describes_a_server_without_them() {
let spec = broker("mqtt://alice:s3cret@broker.example.com:1884").describe_server();
let host = spec.host.expect("a networked broker states its host");
assert_eq!(host, "broker.example.com:1884");
assert!(
!host.contains('@'),
"the userinfo separator is gone: {host}"
);
assert!(!host.contains("alice"), "the user name is gone: {host}");
assert!(!host.contains("s3cret"), "the password is gone: {host}");
}
}