mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
feat(homecore): load signed native and Wasmtime plugins
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
//! Secure filesystem discovery for packaged WASM plugins.
|
||||||
|
//!
|
||||||
|
//! A plugin directory has exactly this shape:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <configured-root>/<package>/manifest.json
|
||||||
|
//! <module named by wasm_module>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Only immediate child directories of explicitly configured roots are
|
||||||
|
//! inspected. Symlinks are rejected and the module must be a plain filename
|
||||||
|
//! in the same package directory. This deliberately avoids recursive search,
|
||||||
|
//! implicit current-directory loading, and path traversal.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::{PluginError, PluginManifest};
|
||||||
|
|
||||||
|
/// Conservative input limits applied before allocating file contents.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct DiscoveryLimits {
|
||||||
|
pub max_plugins: usize,
|
||||||
|
pub max_manifest_bytes: u64,
|
||||||
|
pub max_module_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscoveryLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_plugins: 128,
|
||||||
|
max_manifest_bytes: 256 * 1024,
|
||||||
|
max_module_bytes: 16 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated package whose files are safe to read.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiscoveredPlugin {
|
||||||
|
pub manifest: PluginManifest,
|
||||||
|
pub package_dir: PathBuf,
|
||||||
|
pub manifest_path: PathBuf,
|
||||||
|
pub module_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscoveredPlugin {
|
||||||
|
/// Read the module after re-checking its type and size. Re-checking closes
|
||||||
|
/// the common accidental replacement window between discovery and load;
|
||||||
|
/// signature verification remains the authoritative tamper gate.
|
||||||
|
pub fn read_module(&self, limits: DiscoveryLimits) -> Result<Vec<u8>, PluginError> {
|
||||||
|
bounded_regular_file(&self.module_path, limits.max_module_bytes, "WASM module")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover packages in deterministic root/path order.
|
||||||
|
pub fn discover_plugins(
|
||||||
|
roots: &[PathBuf],
|
||||||
|
limits: DiscoveryLimits,
|
||||||
|
) -> Result<Vec<DiscoveredPlugin>, PluginError> {
|
||||||
|
if roots.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if limits.max_plugins == 0 || limits.max_manifest_bytes == 0 || limits.max_module_bytes == 0 {
|
||||||
|
return Err(PluginError::ResourceLimit(
|
||||||
|
"discovery limits must all be greater than zero".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut packages = BTreeSet::new();
|
||||||
|
for configured_root in roots {
|
||||||
|
let metadata = fs::symlink_metadata(configured_root).map_err(|e| {
|
||||||
|
PluginError::Discovery(format!(
|
||||||
|
"cannot inspect configured plugin root {}: {e}",
|
||||||
|
configured_root.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||||
|
return Err(PluginError::Discovery(format!(
|
||||||
|
"configured plugin root must be a real directory, not a symlink: {}",
|
||||||
|
configured_root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let root = fs::canonicalize(configured_root)?;
|
||||||
|
let entries = fs::read_dir(&root)?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let ty = entry.file_type()?;
|
||||||
|
if ty.is_symlink() {
|
||||||
|
return Err(PluginError::Discovery(format!(
|
||||||
|
"symlink found in plugin root: {}",
|
||||||
|
entry.path().display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if ty.is_dir() && entry.path().join("manifest.json").exists() {
|
||||||
|
packages.insert(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if packages.len() > limits.max_plugins {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"discovered {} plugins, maximum is {}",
|
||||||
|
packages.len(),
|
||||||
|
limits.max_plugins
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(packages.len());
|
||||||
|
let mut domains = BTreeSet::new();
|
||||||
|
for package_dir in packages {
|
||||||
|
let package_dir = fs::canonicalize(package_dir)?;
|
||||||
|
let manifest_path = package_dir.join("manifest.json");
|
||||||
|
let manifest_bytes =
|
||||||
|
bounded_regular_file(&manifest_path, limits.max_manifest_bytes, "manifest")?;
|
||||||
|
let manifest_text = std::str::from_utf8(&manifest_bytes).map_err(|e| {
|
||||||
|
PluginError::InvalidManifest(format!(
|
||||||
|
"{} is not UTF-8: {e}",
|
||||||
|
manifest_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let manifest = PluginManifest::parse_json(manifest_text)?;
|
||||||
|
if !domains.insert(manifest.domain.clone()) {
|
||||||
|
return Err(PluginError::Discovery(format!(
|
||||||
|
"duplicate plugin domain `{}`",
|
||||||
|
manifest.domain
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let module_name = manifest.wasm_module.as_deref().ok_or_else(|| {
|
||||||
|
PluginError::InvalidManifest(format!(
|
||||||
|
"WASM package `{}` has no wasm_module",
|
||||||
|
manifest.domain
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let relative = Path::new(module_name);
|
||||||
|
if relative.components().count() != 1
|
||||||
|
|| !matches!(relative.components().next(), Some(Component::Normal(_)))
|
||||||
|
|| relative.extension().and_then(|v| v.to_str()) != Some("wasm")
|
||||||
|
{
|
||||||
|
return Err(PluginError::InvalidManifest(format!(
|
||||||
|
"plugin `{}` wasm_module must be a .wasm filename in its package directory",
|
||||||
|
manifest.domain
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let module_path = package_dir.join(relative);
|
||||||
|
let metadata = fs::symlink_metadata(&module_path).map_err(|e| {
|
||||||
|
PluginError::Discovery(format!("cannot inspect {}: {e}", module_path.display()))
|
||||||
|
})?;
|
||||||
|
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||||
|
return Err(PluginError::Discovery(format!(
|
||||||
|
"plugin module must be a regular non-symlink file: {}",
|
||||||
|
module_path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if metadata.len() > limits.max_module_bytes {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"{} is {} bytes; maximum is {}",
|
||||||
|
module_path.display(),
|
||||||
|
metadata.len(),
|
||||||
|
limits.max_module_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
result.push(DiscoveredPlugin {
|
||||||
|
manifest,
|
||||||
|
package_dir,
|
||||||
|
manifest_path,
|
||||||
|
module_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_regular_file(path: &Path, maximum: u64, kind: &str) -> Result<Vec<u8>, PluginError> {
|
||||||
|
let metadata = fs::symlink_metadata(path)?;
|
||||||
|
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||||
|
return Err(PluginError::Discovery(format!(
|
||||||
|
"{kind} must be a regular non-symlink file: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if metadata.len() > maximum {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"{} is {} bytes; maximum is {}",
|
||||||
|
path.display(),
|
||||||
|
metadata.len(),
|
||||||
|
maximum
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
if bytes.len() as u64 > maximum {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"{} grew beyond the {maximum}-byte limit while being read",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn root() -> PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"homecore-plugin-discovery-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package(root: &Path, directory: &str, domain: &str, module: &str) {
|
||||||
|
let dir = root.join(directory);
|
||||||
|
fs::create_dir(&dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.join("manifest.json"),
|
||||||
|
format!(
|
||||||
|
r#"{{"domain":"{domain}","name":"Test","version":"1","wasm_module":"{module}"}}"#
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if !module.contains('/') && !module.contains('\\') && module.ends_with(".wasm") {
|
||||||
|
fs::write(dir.join(module), b"\0asm\x01\0\0\0").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_is_sorted_and_only_reads_explicit_roots() {
|
||||||
|
let root = root();
|
||||||
|
package(&root, "z-last", "zeta", "z.wasm");
|
||||||
|
package(&root, "a-first", "alpha", "a.wasm");
|
||||||
|
let found = discover_plugins(&[root.clone()], DiscoveryLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
found.iter().map(|p| p.manifest.domain.as_str()).collect::<Vec<_>>(),
|
||||||
|
["alpha", "zeta"]
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn traversal_module_path_is_rejected() {
|
||||||
|
let root = root();
|
||||||
|
package(&root, "bad", "bad", "../bad.wasm");
|
||||||
|
let error = discover_plugins(&[root.clone()], DiscoveryLimits::default()).unwrap_err();
|
||||||
|
assert!(matches!(error, PluginError::InvalidManifest(_)));
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_limits_are_enforced_before_read() {
|
||||||
|
let root = root();
|
||||||
|
package(&root, "large", "large", "large.wasm");
|
||||||
|
let error = discover_plugins(
|
||||||
|
&[root.clone()],
|
||||||
|
DiscoveryLimits {
|
||||||
|
max_module_bytes: 4,
|
||||||
|
..DiscoveryLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, PluginError::ResourceLimit(_)));
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ pub enum PluginError {
|
|||||||
#[error("runtime error: {0}")]
|
#[error("runtime error: {0}")]
|
||||||
RuntimeError(String),
|
RuntimeError(String),
|
||||||
|
|
||||||
|
/// Plugin discovery or filesystem layout is invalid.
|
||||||
|
#[error("plugin discovery failed: {0}")]
|
||||||
|
Discovery(String),
|
||||||
|
|
||||||
|
/// A configured manifest, module, or ABI payload exceeds its limit.
|
||||||
|
#[error("plugin resource limit exceeded: {0}")]
|
||||||
|
ResourceLimit(String),
|
||||||
|
|
||||||
/// The plugin's `setup` hook returned an error.
|
/// The plugin's `setup` hook returned an error.
|
||||||
#[error("plugin setup failed: {0}")]
|
#[error("plugin setup failed: {0}")]
|
||||||
SetupFailed(String),
|
SetupFailed(String),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
|
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod discovery;
|
||||||
pub mod host_abi;
|
pub mod host_abi;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
@@ -53,6 +54,7 @@ pub mod verify;
|
|||||||
pub mod wasmtime_runtime;
|
pub mod wasmtime_runtime;
|
||||||
|
|
||||||
pub use error::PluginError;
|
pub use error::PluginError;
|
||||||
|
pub use discovery::{discover_plugins, DiscoveredPlugin, DiscoveryLimits};
|
||||||
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
|
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
|
||||||
pub use manifest::{IotClass, IntegrationType, PluginManifest};
|
pub use manifest::{IotClass, IntegrationType, PluginManifest};
|
||||||
pub use permissions::PermissionSet;
|
pub use permissions::PermissionSet;
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ use async_trait::async_trait;
|
|||||||
use homecore::HomeCore;
|
use homecore::HomeCore;
|
||||||
|
|
||||||
use crate::error::PluginError;
|
use crate::error::PluginError;
|
||||||
|
use crate::StateChangedEventJson;
|
||||||
|
|
||||||
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
|
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
|
||||||
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
|
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct PluginId(pub String);
|
pub struct PluginId(pub String);
|
||||||
|
|
||||||
impl PluginId {
|
impl PluginId {
|
||||||
@@ -52,6 +53,12 @@ pub trait HomeCorePlugin: Send + Sync + 'static {
|
|||||||
/// register its entities, services, and event subscriptions here.
|
/// register its entities, services, and event subscriptions here.
|
||||||
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
|
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
|
||||||
|
|
||||||
|
/// Receive a committed state change. The default is a no-op so built-in
|
||||||
|
/// plugins only opt into event work they need.
|
||||||
|
async fn state_changed(&self, _event: &StateChangedEventJson) -> Result<(), PluginError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when the plugin is being removed from the registry.
|
/// Called when the plugin is being removed from the registry.
|
||||||
///
|
///
|
||||||
/// The plugin should clean up subscriptions and deregister its entities.
|
/// The plugin should clean up subscriptions and deregister its entities.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
|
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
|
||||||
//! changing registry code.
|
//! changing registry code.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use homecore::HomeCore;
|
use homecore::HomeCore;
|
||||||
@@ -15,6 +15,7 @@ use crate::error::PluginError;
|
|||||||
use crate::manifest::PluginManifest;
|
use crate::manifest::PluginManifest;
|
||||||
use crate::plugin::{HomeCorePlugin, PluginId};
|
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||||
use crate::runtime::{LoadedPlugin, PluginRuntime};
|
use crate::runtime::{LoadedPlugin, PluginRuntime};
|
||||||
|
use crate::StateChangedEventJson;
|
||||||
|
|
||||||
/// Holds all loaded plugins keyed by `PluginId`.
|
/// Holds all loaded plugins keyed by `PluginId`.
|
||||||
///
|
///
|
||||||
@@ -22,7 +23,8 @@ use crate::runtime::{LoadedPlugin, PluginRuntime};
|
|||||||
/// unload) take an exclusive lock only while mutating the map.
|
/// unload) take an exclusive lock only while mutating the map.
|
||||||
pub struct PluginRegistry<R: PluginRuntime> {
|
pub struct PluginRegistry<R: PluginRuntime> {
|
||||||
runtime: R,
|
runtime: R,
|
||||||
plugins: RwLock<HashMap<PluginId, LoadedPlugin>>,
|
plugins: RwLock<BTreeMap<PluginId, LoadedPlugin>>,
|
||||||
|
loading: RwLock<BTreeSet<PluginId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: PluginRuntime> PluginRegistry<R> {
|
impl<R: PluginRuntime> PluginRegistry<R> {
|
||||||
@@ -30,7 +32,8 @@ impl<R: PluginRuntime> PluginRegistry<R> {
|
|||||||
pub fn new(runtime: R) -> Self {
|
pub fn new(runtime: R) -> Self {
|
||||||
Self {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
plugins: RwLock::new(HashMap::new()),
|
plugins: RwLock::new(BTreeMap::new()),
|
||||||
|
loading: RwLock::new(BTreeSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,22 +51,31 @@ impl<R: PluginRuntime> PluginRegistry<R> {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let guard = self.plugins.read().await;
|
let guard = self.plugins.read().await;
|
||||||
if guard.contains_key(&id) {
|
let mut loading = self.loading.write().await;
|
||||||
|
if guard.contains_key(&id) || !loading.insert(id.clone()) {
|
||||||
return Err(PluginError::AlreadyLoaded(id.to_string()));
|
return Err(PluginError::AlreadyLoaded(id.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = self
|
let result = self
|
||||||
.runtime
|
.runtime
|
||||||
.load(id.clone(), manifest, plugin)
|
.load(id.clone(), manifest, plugin)
|
||||||
.await?;
|
.await;
|
||||||
|
let loaded = match result {
|
||||||
loaded
|
Ok(loaded) => loaded,
|
||||||
.setup(hc)
|
Err(error) => {
|
||||||
.await
|
self.loading.write().await.remove(&id);
|
||||||
.map_err(|e| PluginError::SetupFailed(e.to_string()))?;
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = loaded.setup(hc).await {
|
||||||
|
// Best-effort rollback for partially initialized plugins.
|
||||||
|
let _ = loaded.unload().await;
|
||||||
|
self.loading.write().await.remove(&id);
|
||||||
|
return Err(PluginError::SetupFailed(error.to_string()));
|
||||||
|
}
|
||||||
self.plugins.write().await.insert(id.clone(), loaded);
|
self.plugins.write().await.insert(id.clone(), loaded);
|
||||||
|
self.loading.write().await.remove(&id);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +90,11 @@ impl<R: PluginRuntime> PluginRegistry<R> {
|
|||||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
|
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
|
||||||
};
|
};
|
||||||
|
|
||||||
loaded
|
if let Err(error) = loaded.unload().await {
|
||||||
.unload()
|
// Keep the handle reachable so teardown can be retried.
|
||||||
.await
|
self.plugins.write().await.insert(id.clone(), loaded);
|
||||||
.map_err(|e| PluginError::UnloadFailed(e.to_string()))?;
|
return Err(PluginError::UnloadFailed(error.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -99,4 +112,29 @@ impl<R: PluginRuntime> PluginRegistry<R> {
|
|||||||
pub async fn contains(&self, id: &PluginId) -> bool {
|
pub async fn contains(&self, id: &PluginId) -> bool {
|
||||||
self.plugins.read().await.contains_key(id)
|
self.plugins.read().await.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatch a state change in stable plugin-domain order.
|
||||||
|
pub async fn state_changed(&self, event: &StateChangedEventJson) -> Vec<(PluginId, PluginError)> {
|
||||||
|
let guard = self.plugins.read().await;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for (id, plugin) in guard.iter() {
|
||||||
|
if let Err(error) = plugin.state_changed(event).await {
|
||||||
|
errors.push((id.clone(), error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down all plugins in reverse domain order. Failures are collected
|
||||||
|
/// while remaining plugins still receive teardown.
|
||||||
|
pub async fn shutdown(&self) -> Vec<(PluginId, PluginError)> {
|
||||||
|
let ids: Vec<_> = self.plugins.read().await.keys().cloned().rev().collect();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
if let Err(error) = self.unload(&id).await {
|
||||||
|
errors.push((id, error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use homecore::HomeCore;
|
|||||||
use crate::error::PluginError;
|
use crate::error::PluginError;
|
||||||
use crate::manifest::PluginManifest;
|
use crate::manifest::PluginManifest;
|
||||||
use crate::plugin::{HomeCorePlugin, PluginId};
|
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||||
|
use crate::StateChangedEventJson;
|
||||||
|
|
||||||
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
|
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
|
||||||
pub struct LoadedPlugin {
|
pub struct LoadedPlugin {
|
||||||
@@ -42,6 +43,11 @@ impl LoadedPlugin {
|
|||||||
pub async fn unload(&self) -> Result<(), PluginError> {
|
pub async fn unload(&self) -> Result<(), PluginError> {
|
||||||
self.instance.unload().await
|
self.instance.unload().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatch a committed state change to this plugin.
|
||||||
|
pub async fn state_changed(&self, event: &StateChangedEventJson) -> Result<(), PluginError> {
|
||||||
|
self.instance.state_changed(event).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstraction over the WASM (and native) plugin execution environment.
|
/// Abstraction over the WASM (and native) plugin execution environment.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use homecore::HomeCore;
|
use homecore::HomeCore;
|
||||||
use wasmtime::{Engine, Linker, Module, Store};
|
use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
|
||||||
|
|
||||||
use crate::error::PluginError;
|
use crate::error::PluginError;
|
||||||
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
|
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
|
||||||
@@ -34,6 +34,13 @@ use crate::manifest::PluginManifest;
|
|||||||
use crate::permissions::PermissionSet;
|
use crate::permissions::PermissionSet;
|
||||||
use crate::verify::{verify_module, PluginPolicy};
|
use crate::verify::{verify_module, PluginPolicy};
|
||||||
|
|
||||||
|
/// Hard ceiling for every module accepted by the runtime, including callers
|
||||||
|
/// that bypass filesystem discovery.
|
||||||
|
pub const MAX_WASM_MODULE_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
const MAX_SUBSCRIPTIONS: usize = 4096;
|
||||||
|
const MAX_LINEAR_MEMORY_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
const FUEL_PER_CALL: u64 = 10_000_000;
|
||||||
|
|
||||||
// ── Store data ─────────────────────────────────────────────────────────────
|
// ── Store data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per-plugin state stored inside the Wasmtime [`Store`].
|
/// Per-plugin state stored inside the Wasmtime [`Store`].
|
||||||
@@ -51,6 +58,7 @@ pub struct PluginStoreData {
|
|||||||
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
|
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
|
||||||
/// declared set.
|
/// declared set.
|
||||||
pub permissions: PermissionSet,
|
pub permissions: PermissionSet,
|
||||||
|
limits: StoreLimits,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
|
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
|
||||||
@@ -66,7 +74,10 @@ pub struct WasmtimeRuntime {
|
|||||||
impl WasmtimeRuntime {
|
impl WasmtimeRuntime {
|
||||||
/// Create a new runtime with default Cranelift config.
|
/// Create a new runtime with default Cranelift config.
|
||||||
pub fn new() -> Result<Self, PluginError> {
|
pub fn new() -> Result<Self, PluginError> {
|
||||||
let engine = Engine::default();
|
let mut config = Config::new();
|
||||||
|
config.consume_fuel(true);
|
||||||
|
let engine = Engine::new(&config)
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("Wasmtime engine: {e}")))?;
|
||||||
Ok(Self { engine })
|
Ok(Self { engine })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +94,7 @@ impl WasmtimeRuntime {
|
|||||||
wasm_bytes: &[u8],
|
wasm_bytes: &[u8],
|
||||||
hc: HomeCore,
|
hc: HomeCore,
|
||||||
) -> Result<WasmPlugin, PluginError> {
|
) -> Result<WasmPlugin, PluginError> {
|
||||||
|
check_module_size(wasm_bytes)?;
|
||||||
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
|
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +116,7 @@ impl WasmtimeRuntime {
|
|||||||
hc: HomeCore,
|
hc: HomeCore,
|
||||||
policy: &PluginPolicy,
|
policy: &PluginPolicy,
|
||||||
) -> Result<WasmPlugin, PluginError> {
|
) -> Result<WasmPlugin, PluginError> {
|
||||||
|
check_module_size(wasm_bytes)?;
|
||||||
// P4: verify before instantiation.
|
// P4: verify before instantiation.
|
||||||
verify_module(manifest, wasm_bytes, policy)?;
|
verify_module(manifest, wasm_bytes, policy)?;
|
||||||
// P5: scope write authority to the manifest's declared permissions.
|
// P5: scope write authority to the manifest's declared permissions.
|
||||||
@@ -128,8 +141,17 @@ impl WasmtimeRuntime {
|
|||||||
hc,
|
hc,
|
||||||
subscriptions: Vec::new(),
|
subscriptions: Vec::new(),
|
||||||
permissions,
|
permissions,
|
||||||
|
limits: StoreLimitsBuilder::new()
|
||||||
|
.memory_size(MAX_LINEAR_MEMORY_BYTES)
|
||||||
|
.instances(1)
|
||||||
|
.memories(1)
|
||||||
|
.build(),
|
||||||
};
|
};
|
||||||
let mut store = Store::new(&self.engine, store_data);
|
let mut store = Store::new(&self.engine, store_data);
|
||||||
|
store.limiter(|data| &mut data.limits);
|
||||||
|
store
|
||||||
|
.set_fuel(FUEL_PER_CALL)
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("set instantiation fuel: {e}")))?;
|
||||||
|
|
||||||
let instance = linker
|
let instance = linker
|
||||||
.instantiate(&mut store, &module)
|
.instantiate(&mut store, &module)
|
||||||
@@ -179,6 +201,12 @@ fn register_hc_state_get(
|
|||||||
out_ptr: i32,
|
out_ptr: i32,
|
||||||
out_cap: i32|
|
out_cap: i32|
|
||||||
-> i32 {
|
-> i32 {
|
||||||
|
if out_ptr < 0
|
||||||
|
|| out_cap < 0
|
||||||
|
|| out_cap as usize > MAX_ABI_BUFFER_BYTES
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
// Phase 1: read the entity key from guest memory.
|
// Phase 1: read the entity key from guest memory.
|
||||||
let key: String = {
|
let key: String = {
|
||||||
let mem = match caller.get_export("memory") {
|
let mem = match caller.get_export("memory") {
|
||||||
@@ -216,7 +244,9 @@ fn register_hc_state_get(
|
|||||||
Some(wasmtime::Extern::Memory(m)) => m,
|
Some(wasmtime::Extern::Memory(m)) => m,
|
||||||
_ => return -1,
|
_ => return -1,
|
||||||
};
|
};
|
||||||
let end = out_ptr as usize + json_bytes.len();
|
let Some(end) = (out_ptr as usize).checked_add(json_bytes.len()) else {
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) {
|
let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return -1,
|
None => return -1,
|
||||||
@@ -331,7 +361,15 @@ fn register_hc_state_subscribe(
|
|||||||
None => return -1,
|
None => return -1,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
caller.data_mut().subscriptions.push(eid);
|
if homecore::EntityId::parse(&eid).is_err() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if caller.data().subscriptions.len() >= MAX_SUBSCRIPTIONS {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
if !caller.data().subscriptions.contains(&eid) {
|
||||||
|
caller.data_mut().subscriptions.push(eid);
|
||||||
|
}
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -374,6 +412,7 @@ fn register_hc_log(
|
|||||||
///
|
///
|
||||||
/// The `Arc<Mutex<_>>` allows the handle to be `Clone` + `Send` while
|
/// The `Arc<Mutex<_>>` allows the handle to be `Clone` + `Send` while
|
||||||
/// maintaining exclusive access for calls into the WASM module.
|
/// maintaining exclusive access for calls into the WASM module.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct WasmPlugin {
|
pub struct WasmPlugin {
|
||||||
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
|
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
|
||||||
}
|
}
|
||||||
@@ -394,6 +433,9 @@ impl WasmPlugin {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
||||||
let (store, instance) = &mut *guard;
|
let (store, instance) = &mut *guard;
|
||||||
|
store
|
||||||
|
.set_fuel(FUEL_PER_CALL)
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
|
||||||
call_export_str(store, instance, "plugin_setup", config_entry_json)
|
call_export_str(store, instance, "plugin_setup", config_entry_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,20 +451,46 @@ impl WasmPlugin {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
||||||
let (store, instance) = &mut *guard;
|
let (store, instance) = &mut *guard;
|
||||||
|
store
|
||||||
|
.set_fuel(FUEL_PER_CALL)
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
|
||||||
call_export_str(store, instance, "plugin_handle_state_changed", &json)
|
call_export_str(store, instance, "plugin_handle_state_changed", &json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Call an optional `plugin_teardown() -> i32` export. Older modules that
|
||||||
|
/// do not export teardown remain compatible; new modules can release
|
||||||
|
/// guest-owned resources deterministically.
|
||||||
|
pub fn call_teardown(&self) -> Result<i32, PluginError> {
|
||||||
|
let mut guard = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
||||||
|
let (store, instance) = &mut *guard;
|
||||||
|
store
|
||||||
|
.set_fuel(FUEL_PER_CALL)
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
|
||||||
|
let Some(func) = instance.get_func(&mut *store, "plugin_teardown") else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
let func = func.typed::<(), i32>(&*store).map_err(|e| {
|
||||||
|
PluginError::RuntimeError(format!("plugin_teardown has invalid signature: {e}"))
|
||||||
|
})?;
|
||||||
|
func.call(&mut *store, ())
|
||||||
|
.map_err(|e| PluginError::RuntimeError(format!("call plugin_teardown: {e}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Memory helpers ─────────────────────────────────────────────────────────
|
// ── Memory helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read a UTF-8 string from guest linear memory.
|
/// Read a UTF-8 string from guest linear memory.
|
||||||
fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> {
|
fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> {
|
||||||
if len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
|
if ptr < 0 || len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ptr = ptr as usize;
|
let ptr = ptr as usize;
|
||||||
let len = len as usize;
|
let len = len as usize;
|
||||||
let slice = mem.get(ptr..ptr + len)?;
|
let end = ptr.checked_add(len)?;
|
||||||
|
let slice = mem.get(ptr..end)?;
|
||||||
std::str::from_utf8(slice).ok()
|
std::str::from_utf8(slice).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +503,13 @@ fn call_export_str(
|
|||||||
payload: &str,
|
payload: &str,
|
||||||
) -> Result<i32, PluginError> {
|
) -> Result<i32, PluginError> {
|
||||||
let payload_bytes = payload.as_bytes().to_vec(); // owned copy avoids reborrow issues
|
let payload_bytes = payload.as_bytes().to_vec(); // owned copy avoids reborrow issues
|
||||||
|
if payload_bytes.len() > MAX_ABI_BUFFER_BYTES {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"ABI payload is {} bytes; maximum is {}",
|
||||||
|
payload_bytes.len(),
|
||||||
|
MAX_ABI_BUFFER_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
let payload_len = payload_bytes.len() as i32;
|
let payload_len = payload_bytes.len() as i32;
|
||||||
|
|
||||||
// 1. Allocate guest buffer.
|
// 1. Allocate guest buffer.
|
||||||
@@ -444,15 +519,23 @@ fn call_export_str(
|
|||||||
let ptr = alloc
|
let ptr = alloc
|
||||||
.call(&mut *store, payload_len)
|
.call(&mut *store, payload_len)
|
||||||
.map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?;
|
.map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?;
|
||||||
|
if ptr < 0 {
|
||||||
|
return Err(PluginError::RuntimeError(
|
||||||
|
"guest alloc returned a negative pointer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Write payload into guest memory.
|
// 2. Write payload into guest memory.
|
||||||
{
|
{
|
||||||
let mem = instance
|
let mem = instance
|
||||||
.get_memory(&mut *store, "memory")
|
.get_memory(&mut *store, "memory")
|
||||||
.ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?;
|
.ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?;
|
||||||
|
let end = (ptr as usize)
|
||||||
|
.checked_add(payload_bytes.len())
|
||||||
|
.ok_or_else(|| PluginError::RuntimeError("guest allocation overflow".into()))?;
|
||||||
let guest_slice = mem
|
let guest_slice = mem
|
||||||
.data_mut(&mut *store)
|
.data_mut(&mut *store)
|
||||||
.get_mut(ptr as usize..ptr as usize + payload_bytes.len())
|
.get_mut(ptr as usize..end)
|
||||||
.ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?;
|
.ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?;
|
||||||
guest_slice.copy_from_slice(&payload_bytes);
|
guest_slice.copy_from_slice(&payload_bytes);
|
||||||
}
|
}
|
||||||
@@ -476,6 +559,17 @@ fn call_export_str(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_module_size(wasm_bytes: &[u8]) -> Result<(), PluginError> {
|
||||||
|
if wasm_bytes.len() > MAX_WASM_MODULE_BYTES {
|
||||||
|
return Err(PluginError::ResourceLimit(format!(
|
||||||
|
"WASM module is {} bytes; maximum is {}",
|
||||||
|
wasm_bytes.len(),
|
||||||
|
MAX_WASM_MODULE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Unit tests (using inline WAT) ──────────────────────────────────────────
|
// ── Unit tests (using inline WAT) ──────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ path = "src/main.rs"
|
|||||||
# The 8 HOMECORE crates this binary integrates
|
# The 8 HOMECORE crates this binary integrates
|
||||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||||
homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" }
|
homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" }
|
||||||
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0", optional = true }
|
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" }
|
||||||
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
|
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
|
||||||
# Reuse version-gated registry envelope parsing in the isolated restore module.
|
# Reuse version-gated registry envelope parsing in the isolated restore module.
|
||||||
homecore-migrate = { path = "../homecore-migrate", version = "0.1.0-alpha.0" }
|
homecore-migrate = { path = "../homecore-migrate", version = "0.1.0-alpha.0" }
|
||||||
@@ -65,4 +65,4 @@ default = []
|
|||||||
# Pull in ruvector-backed semantic memory.
|
# Pull in ruvector-backed semantic memory.
|
||||||
ruvector = ["homecore-recorder/ruvector"]
|
ruvector = ["homecore-recorder/ruvector"]
|
||||||
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
|
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
|
||||||
wasmtime = ["dep:homecore-plugins", "homecore-plugins/wasmtime"]
|
wasmtime = ["homecore-plugins/wasmtime"]
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Server ownership for plugin discovery, setup, event dispatch, and teardown.
|
||||||
|
//!
|
||||||
|
//! Native Rust plugins are never discovered from disk. They must be compiled
|
||||||
|
//! into this binary and exposed through [`NativePluginRegistration`]. External
|
||||||
|
//! packages are WebAssembly only and require the `wasmtime` feature.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use homecore::HomeCore;
|
||||||
|
use homecore_plugins::{
|
||||||
|
DiscoveryLimits, HomeCorePlugin, InProcessRuntime, PluginError, PluginManifest,
|
||||||
|
PluginRegistry, StateChangedEventJson,
|
||||||
|
};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
use homecore_plugins::{
|
||||||
|
discover_plugins, ConfigEntryJson, PluginId, PluginPolicy, WasmPlugin, WasmtimeRuntime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Factory entry for a trusted plugin linked into the server at compile time.
|
||||||
|
pub struct NativePluginRegistration {
|
||||||
|
pub create:
|
||||||
|
fn() -> Result<(PluginManifest, Arc<dyn HomeCorePlugin>), PluginError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is intentionally empty in the generic server. Appliance builds can
|
||||||
|
/// add first-party registrations here; arbitrary Rust cdylibs are unsupported.
|
||||||
|
const NATIVE_PLUGINS: &[NativePluginRegistration] = &[];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PluginConfig {
|
||||||
|
pub directories: Vec<PathBuf>,
|
||||||
|
pub trusted_publishers: Vec<String>,
|
||||||
|
pub allow_unsigned: bool,
|
||||||
|
pub limits: DiscoveryLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerPlugins {
|
||||||
|
native: Arc<PluginRegistry<InProcessRuntime>>,
|
||||||
|
dispatcher: JoinHandle<()>,
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
wasm: Vec<(PluginId, WasmPlugin)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerPlugins {
|
||||||
|
/// Start the complete plugin subsystem. Any configured package that fails
|
||||||
|
/// discovery, trust verification, compilation, or setup aborts startup.
|
||||||
|
pub async fn start(hc: HomeCore, config: PluginConfig) -> Result<Self> {
|
||||||
|
let native = Arc::new(PluginRegistry::new(InProcessRuntime));
|
||||||
|
for registration in NATIVE_PLUGINS {
|
||||||
|
let (manifest, plugin) = (registration.create)()
|
||||||
|
.context("compiled-in native plugin factory failed")?;
|
||||||
|
native
|
||||||
|
.load(manifest, plugin, hc.clone())
|
||||||
|
.await
|
||||||
|
.context("compiled-in native plugin setup failed")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "wasmtime"))]
|
||||||
|
if !config.directories.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"plugin directories were configured, but homecore-server was built without \
|
||||||
|
--features wasmtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "wasmtime"))]
|
||||||
|
if config.allow_unsigned || !config.trusted_publishers.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"plugin trust options were configured, but homecore-server was built without \
|
||||||
|
--features wasmtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
let wasm = load_wasm_plugins(&hc, &config).await?;
|
||||||
|
|
||||||
|
let mut receiver = hc.states().subscribe();
|
||||||
|
let dispatch_native = native.clone();
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
let dispatch_wasm = wasm.clone();
|
||||||
|
let dispatcher = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let change = match receiver.recv().await {
|
||||||
|
Ok(change) => change,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
warn!(skipped, "plugin state dispatcher lagged; events were dropped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
};
|
||||||
|
let event = StateChangedEventJson::state_changed(
|
||||||
|
change.entity_id.as_str(),
|
||||||
|
change.new_state.as_ref().map(|state| state.state.as_str()),
|
||||||
|
change
|
||||||
|
.new_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| state.attributes.clone())
|
||||||
|
.unwrap_or_else(|| serde_json::json!({})),
|
||||||
|
);
|
||||||
|
for (id, error) in dispatch_native.state_changed(&event).await {
|
||||||
|
error!(plugin = %id, %error, "native plugin state_changed failed");
|
||||||
|
}
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
for (id, plugin) in &dispatch_wasm {
|
||||||
|
if !plugin.subscriptions().iter().any(|entity| entity == change.entity_id.as_str())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let plugin = plugin.clone();
|
||||||
|
let id = id.clone();
|
||||||
|
let event = StateChangedEventJson::state_changed(
|
||||||
|
&event.entity_id,
|
||||||
|
event.new_state.as_deref(),
|
||||||
|
event.attributes.clone(),
|
||||||
|
);
|
||||||
|
match tokio::task::spawn_blocking(move || plugin.call_state_changed(&event)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(0)) => {}
|
||||||
|
Ok(Ok(code)) => error!(plugin = %id, code, "WASM state_changed returned failure"),
|
||||||
|
Ok(Err(error)) => error!(plugin = %id, %error, "WASM state_changed trapped"),
|
||||||
|
Err(error) => error!(plugin = %id, %error, "WASM dispatch task failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(
|
||||||
|
native = NATIVE_PLUGINS.len(),
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
wasm = wasm.len(),
|
||||||
|
"plugin subsystem started"
|
||||||
|
);
|
||||||
|
Ok(Self {
|
||||||
|
native,
|
||||||
|
dispatcher,
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
wasm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop dispatch first, then tear down WASM and native plugins in reverse
|
||||||
|
/// deterministic order. All teardown failures are reported.
|
||||||
|
pub async fn shutdown(self) {
|
||||||
|
self.dispatcher.abort();
|
||||||
|
let _ = self.dispatcher.await;
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
for (id, plugin) in self.wasm.into_iter().rev() {
|
||||||
|
match tokio::task::spawn_blocking(move || plugin.call_teardown()).await {
|
||||||
|
Ok(Ok(0)) => {}
|
||||||
|
Ok(Ok(code)) => error!(plugin = %id, code, "WASM teardown returned failure"),
|
||||||
|
Ok(Err(error)) => error!(plugin = %id, %error, "WASM teardown trapped"),
|
||||||
|
Err(error) => error!(plugin = %id, %error, "WASM teardown task failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id, error) in self.native.shutdown().await {
|
||||||
|
error!(plugin = %id, %error, "native plugin teardown failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
async fn load_wasm_plugins(
|
||||||
|
hc: &HomeCore,
|
||||||
|
config: &PluginConfig,
|
||||||
|
) -> Result<Vec<(PluginId, WasmPlugin)>> {
|
||||||
|
let policy = if config.allow_unsigned {
|
||||||
|
warn!("INSECURE plugin policy enabled: unsigned plugins may execute");
|
||||||
|
PluginPolicy::AllowUnsigned
|
||||||
|
} else {
|
||||||
|
let keys: Vec<_> = config.trusted_publishers.iter().map(String::as_str).collect();
|
||||||
|
PluginPolicy::trusted(&keys).context("invalid trusted plugin publisher key")?
|
||||||
|
};
|
||||||
|
let discovered = discover_plugins(&config.directories, config.limits)
|
||||||
|
.context("plugin discovery failed")?;
|
||||||
|
let runtime = WasmtimeRuntime::new().context("Wasmtime initialization failed")?;
|
||||||
|
let mut loaded: Vec<(PluginId, WasmPlugin)> = Vec::with_capacity(discovered.len());
|
||||||
|
for package in discovered {
|
||||||
|
let id = PluginId::new(&package.manifest.domain);
|
||||||
|
let bytes = package
|
||||||
|
.read_module(config.limits)
|
||||||
|
.with_context(|| format!("failed reading plugin `{id}`"))?;
|
||||||
|
let plugin = runtime
|
||||||
|
.load_plugin(&package.manifest, &bytes, hc.clone(), &policy)
|
||||||
|
.with_context(|| format!("plugin `{id}` rejected before setup"))?;
|
||||||
|
let config_entry = serde_json::to_string(&ConfigEntryJson::bootstrap(id.as_str()))?;
|
||||||
|
let setup_plugin = plugin.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || setup_plugin.call_setup(&config_entry))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("plugin `{id}` setup task failed"))?
|
||||||
|
.with_context(|| format!("plugin `{id}` setup trapped"))?;
|
||||||
|
if result != 0 {
|
||||||
|
let _ = plugin.call_teardown();
|
||||||
|
teardown_wasm(loaded).await;
|
||||||
|
anyhow::bail!("plugin `{id}` setup returned failure code {result}");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
plugin = %id,
|
||||||
|
package = %package.package_dir.display(),
|
||||||
|
"signed WASM plugin loaded"
|
||||||
|
);
|
||||||
|
loaded.push((id, plugin));
|
||||||
|
}
|
||||||
|
Ok(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "wasmtime")]
|
||||||
|
async fn teardown_wasm(plugins: Vec<(PluginId, WasmPlugin)>) {
|
||||||
|
for (_, plugin) in plugins.into_iter().rev() {
|
||||||
|
let _ = tokio::task::spawn_blocking(move || plugin.call_teardown()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user