mod convert;
mod pool;
mod runtime;
mod sandbox;
#[cfg(test)]
mod tests;
use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
time::Duration,
};
use anyhow::Context;
use bytes::Bytes;
use mercurio_core::reason::ReasonCode;
use mercurio_server::plugin::{
ready, Access, AuthExchange, AuthResult, BoxFuture, ClientInfo, Credentials, Decision, Event,
MessageAction, Plugin, PluginMessage,
};
use wasmtime::component::Component;
use crate::reload::Reload;
use pool::Pool;
use runtime::bindings::{
self,
mercurio::plugin::types::{self as wit, Hooks},
};
pub use runtime::Runtime;
use sandbox::{Allowed, Kv, State};
const INTERFACE: &str = "mercurio:plugin/guest@";
const VERSION: &str = "0.1";
pub struct PluginSettings {
pub name: String,
pub path: PathBuf,
pub config: Vec<(String, String)>,
pub allow_http: Vec<String>,
pub allow_sockets: Vec<String>,
pub instances: usize,
pub timeout: Duration,
pub memory: usize,
}
pub struct WasmPlugin {
settings: PluginSettings,
loaded: RwLock<Arc<Loaded>>,
kv: Arc<Kv>,
}
struct Loaded {
pool: Pool,
hooks: Hooks,
auth_methods: Vec<String>,
}
impl WasmPlugin {
pub async fn load(runtime: &Runtime, settings: PluginSettings) -> anyhow::Result<Arc<Self>> {
let kv = Arc::new(Kv::default());
let loaded = Loaded::new(runtime, &settings, Arc::clone(&kv)).await?;
Ok(Arc::new(WasmPlugin {
settings,
loaded: RwLock::new(Arc::new(loaded)),
kv,
}))
}
pub async fn reload(&self, runtime: &Runtime) -> anyhow::Result<()> {
let loaded = Loaded::new(runtime, &self.settings, Arc::clone(&self.kv)).await?;
*self.loaded.write().unwrap() = Arc::new(loaded);
Ok(())
}
fn loaded(&self) -> Arc<Loaded> {
Arc::clone(&self.loaded.read().unwrap())
}
fn with(&self, hook: Hooks) -> Option<Arc<Loaded>> {
let loaded = self.loaded();
loaded.hooks.contains(hook).then_some(loaded)
}
}
pub struct Reloader {
plugin: Arc<WasmPlugin>,
runtime: Runtime,
path: String,
}
impl Reloader {
pub fn new(plugin: Arc<WasmPlugin>, runtime: Runtime) -> Reloader {
let path = plugin.settings.path.display().to_string();
Reloader {
plugin,
runtime,
path,
}
}
}
impl Reload for Reloader {
fn reload(&self) -> Result<(), String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.plugin.reload(&self.runtime))
})
.map_err(|e| format!("{e:#}"))
}
fn path(&self) -> &str {
&self.path
}
}
impl Loaded {
async fn new(
runtime: &Runtime,
settings: &PluginSettings,
kv: Arc<Kv>,
) -> anyhow::Result<Self> {
let path = settings.path.clone();
let engine = runtime.engine.clone();
let component = tokio::task::spawn_blocking(move || {
let bytes =
std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
Component::from_binary(&engine, &bytes)
.map_err(anyhow::Error::from)
.with_context(|| format!("compiling {}", path.display()))
})
.await??;
let path = &settings.path;
check_version(runtime, &component, path)?;
let pre = runtime
.linker
.instantiate_pre(&component)
.and_then(bindings::PluginPre::new)
.map_err(anyhow::Error::from)
.with_context(|| format!("linking {}", path.display()))?;
let allowed = Allowed::parse(&settings.allow_http, &settings.allow_sockets)?;
let name: Arc<str> = settings.name.as_str().into();
let memory = settings.memory;
let make = {
let name = Arc::clone(&name);
Box::new(move || State::new(Arc::clone(&name), &allowed, memory, Arc::clone(&kv)))
};
let pool = Pool::new(
name,
pre,
make,
settings.config.clone(),
settings.timeout,
settings.instances,
);
let (instance, registration) = pool.create().await?;
pool.put(instance);
Ok(Loaded {
pool,
hooks: registration.hooks,
auth_methods: registration.auth_methods,
})
}
}
fn check_version(runtime: &Runtime, component: &Component, path: &Path) -> anyhow::Result<()> {
let names: Vec<String> = component
.component_type()
.exports(&runtime.engine)
.map(|(name, _)| name.to_string())
.collect();
let path = path.display();
let Some(version) = names.iter().find_map(|name| name.strip_prefix(INTERFACE)) else {
anyhow::bail!(
"{path}: not a mercurio plugin, it exports {}",
if names.is_empty() {
"nothing".to_string()
} else {
names.join(", ")
}
);
};
anyhow::ensure!(
compatible(version),
"{path}: built for {INTERFACE}{version}, this broker supports {VERSION}"
);
Ok(())
}
fn compatible(version: &str) -> bool {
version
.strip_prefix(VERSION)
.and_then(|patch| patch.strip_prefix('.'))
.is_some_and(|patch| !patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit()))
}
impl Plugin for WasmPlugin {
fn name(&self) -> &str {
&self.settings.name
}
fn authenticate<'a>(
&'a self,
client: &'a ClientInfo,
credentials: &'a Credentials<'a>,
) -> BoxFuture<'a, Decision> {
let Some(loaded) = self.with(Hooks::AUTHENTICATE) else {
return ready(Decision::Defer);
};
let client = convert::client(client);
let (username, password) = (
credentials.username.to_string(),
credentials.password.to_vec(),
);
Box::pin(async move {
loaded
.pool
.call("authenticate", move |i| {
Box::pin(async move {
i.guest
.call_authenticate(&mut i.store, &client, &username, &password)
.await
})
})
.await
.map_or(Decision::Deny, convert::decision)
})
}
fn begin_auth(&self, client: &ClientInfo, method: &str) -> Option<Box<dyn AuthExchange>> {
let loaded = self.loaded();
if !loaded.auth_methods.iter().any(|m| m == method) {
return None;
}
Some(Box::new(WasmAuth {
loaded,
client: convert::client(client),
method: method.to_string(),
state: None,
}))
}
fn authorize<'a>(
&'a self,
client: &'a ClientInfo,
topic: &'a str,
access: Access,
) -> BoxFuture<'a, Decision> {
let Some(loaded) = self.with(Hooks::AUTHORIZE) else {
return ready(Decision::Defer);
};
let Some(access) = convert::access(access) else {
return ready(Decision::Deny);
};
let client = convert::client(client);
let topic = topic.to_string();
Box::pin(async move {
loaded
.pool
.call("authorize", move |i| {
Box::pin(async move {
i.guest
.call_authorize(&mut i.store, &client, &topic, access)
.await
})
})
.await
.map_or(Decision::Deny, convert::decision)
})
}
fn on_message<'a>(
&'a self,
client: &'a ClientInfo,
message: &'a mut PluginMessage,
) -> BoxFuture<'a, MessageAction> {
let Some(loaded) = self.with(Hooks::ON_MESSAGE) else {
return ready(MessageAction::Continue);
};
let client = convert::client(client);
let converted = convert::message(message);
Box::pin(async move {
let action = loaded
.pool
.call("on_message", move |i| {
Box::pin(async move {
i.guest
.call_on_message(&mut i.store, &client, &converted)
.await
})
})
.await;
match action {
Some(wit::MessageAction::Keep) => MessageAction::Continue,
Some(wit::MessageAction::Replace(changed)) => {
*message = convert::plugin_message(changed);
MessageAction::Continue
}
Some(wit::MessageAction::Drop) | None => MessageAction::Drop,
}
})
}
fn on_event<'a>(&'a self, event: &'a Event) -> BoxFuture<'a, ()> {
let Some(loaded) = self.with(Hooks::ON_EVENT) else {
return ready(());
};
let Some(event) = convert::event(event) else {
return ready(());
};
Box::pin(async move {
loaded
.pool
.call("on_event", move |i| {
Box::pin(async move { i.guest.call_on_event(&mut i.store, &event).await })
})
.await;
})
}
fn on_tick(&self) -> BoxFuture<'_, ()> {
let Some(loaded) = self.with(Hooks::ON_TICK) else {
return ready(());
};
Box::pin(async move {
loaded
.pool
.call("on_tick", |i| {
Box::pin(async move { i.guest.call_on_tick(&mut i.store).await })
})
.await;
})
}
fn on_control<'a>(
&'a self,
client: &'a ClientInfo,
path: &'a str,
payload: Bytes,
) -> BoxFuture<'a, Option<Bytes>> {
let Some(loaded) = self.with(Hooks::ON_CONTROL) else {
return ready(None);
};
let client = convert::client(client);
let path = path.to_string();
Box::pin(async move {
loaded
.pool
.call("on_control", move |i| {
Box::pin(async move {
i.guest
.call_on_control(&mut i.store, &client, &path, &payload)
.await
})
})
.await
.flatten()
.map(Bytes::from)
})
}
}
struct WasmAuth {
loaded: Arc<Loaded>,
client: wit::ClientInfo,
method: String,
state: Option<Vec<u8>>,
}
impl AuthExchange for WasmAuth {
fn step<'a>(&'a mut self, data: Option<&'a Bytes>) -> BoxFuture<'a, AuthResult> {
let client = self.client.clone();
let state = self.state.take();
let data = data.map(|data| data.to_vec());
let method = self.method.clone();
Box::pin(async move {
let result = self
.loaded
.pool
.call("enhanced authentication", move |i| {
Box::pin(async move {
i.guest
.call_auth_step(
&mut i.store,
&client,
&method,
state.as_deref(),
data.as_deref(),
)
.await
})
})
.await;
let Some(result) = result else {
return AuthResult::Failed(ReasonCode::NotAuthorized);
};
let (result, state) = convert::auth_result(result);
self.state = state;
result
})
}
}
#[cfg(test)]
mod version_tests {
use super::compatible;
#[test]
fn test_patch_releases_of_the_minor_are_compatible() {
assert!(compatible("0.1.0"));
assert!(compatible("0.1.7"));
assert!(!compatible("0.2.0"));
assert!(!compatible("0.10.0"));
assert!(!compatible("0.1.0-rc1"));
assert!(!compatible("0.1"));
}
}