Replace the fixed 30s sleep-based shutdown drain with actual in-flight RPC tracking using an Arc<AtomicUsize> counter and RAII InFlightGuard. On SIGTERM/SIGINT the server now: 1. Stops accepting new client and federation connections 2. Sends QUIC CONNECTION_CLOSE with reason "server shutting down" 3. Polls the in-flight counter until it reaches 0 (or drain timeout) 4. Logs drain progress as RPCs complete 5. Calls plugin on_shutdown hooks before exit Also adds: - on_shutdown hook to HookVTable (C-ABI plugin API) and ServerHooks trait - server_in_flight_rpcs Prometheus gauge metric - Federation connection tracking via shared in-flight counter
358 lines
12 KiB
Rust
358 lines
12 KiB
Rust
//! Dynamic plugin loader for server-side hook extensions.
|
|
//!
|
|
//! Loads shared libraries (`*.so` / `*.dylib`) from a directory at server
|
|
//! startup. Each library must export:
|
|
//!
|
|
//! ```c
|
|
//! extern "C" int32_t qpc_plugin_init(HookVTable *vtable);
|
|
//! ```
|
|
//!
|
|
//! The server creates a zeroed [`HookVTable`], passes it to `qpc_plugin_init`,
|
|
//! and wraps the resulting vtable in a [`PluginHooks`] that implements
|
|
//! [`ServerHooks`]. Multiple plugins are chained via [`ChainedHooks`].
|
|
//!
|
|
//! # Safety model
|
|
//!
|
|
//! Dynamic loading is inherently unsafe. The plugin binary MUST:
|
|
//! - be compiled against the same `quicprochat-plugin-api` version
|
|
//! - not store the event-struct pointers beyond the callback duration
|
|
//! - be `Send + Sync` (the wrapper is put behind an `Arc`)
|
|
//!
|
|
//! The server operator is responsible for only loading trusted plugin binaries.
|
|
|
|
use std::path::Path;
|
|
|
|
use libloading::{Library, Symbol};
|
|
use quicprochat_plugin_api::{
|
|
CAuthEvent, CChannelEvent, CFetchEvent, CMessageEvent, HookVTable, HOOK_CONTINUE, PLUGIN_OK,
|
|
};
|
|
|
|
use crate::hooks::{AuthEvent, ChannelEvent, FetchEvent, HookAction, MessageEvent, ServerHooks};
|
|
|
|
// ── PluginHooks ───────────────────────────────────────────────────────────────
|
|
|
|
/// A [`ServerHooks`] implementation backed by a dynamically loaded plugin vtable.
|
|
///
|
|
/// Holds the [`Library`] alive alongside the vtable so that the loaded code
|
|
/// is not unmapped while the vtable function pointers are still reachable.
|
|
pub struct PluginHooks {
|
|
/// The vtable filled by `qpc_plugin_init`.
|
|
vtable: HookVTable,
|
|
/// Keeps the shared library mapped. Must be dropped after `vtable`.
|
|
_lib: Library,
|
|
/// Name of the plugin file, for diagnostics.
|
|
name: String,
|
|
}
|
|
|
|
impl PluginHooks {
|
|
/// Load a plugin from `path` and call `qpc_plugin_init`.
|
|
///
|
|
/// Returns `Err` if the library cannot be opened, the symbol is missing,
|
|
/// or `qpc_plugin_init` returns a non-zero error code.
|
|
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
|
let name = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.display().to_string());
|
|
|
|
// Safety: loading arbitrary shared libraries is inherently unsafe.
|
|
// The server operator is responsible for only loading trusted plugins.
|
|
let lib = unsafe { Library::new(path) }
|
|
.map_err(|e| anyhow::anyhow!("plugin '{}': load failed: {}", name, e))?;
|
|
|
|
// Zero-initialise the vtable so unused slots are null.
|
|
let mut vtable = HookVTable {
|
|
user_data: core::ptr::null_mut(),
|
|
on_message_enqueue: None,
|
|
on_batch_enqueue: None,
|
|
on_auth: None,
|
|
on_channel_created: None,
|
|
on_fetch: None,
|
|
on_user_registered: None,
|
|
error_message: None,
|
|
destroy: None,
|
|
on_shutdown: None,
|
|
};
|
|
|
|
// Safety: the symbol must have the exact signature declared in the API crate.
|
|
let init: Symbol<unsafe extern "C" fn(*mut HookVTable) -> i32> =
|
|
unsafe { lib.get(b"qpc_plugin_init\0") }.map_err(|e| {
|
|
anyhow::anyhow!("plugin '{}': missing qpc_plugin_init: {}", name, e)
|
|
})?;
|
|
|
|
let rc = unsafe { init(&mut vtable) };
|
|
if rc != PLUGIN_OK {
|
|
anyhow::bail!("plugin '{}': qpc_plugin_init returned error {}", name, rc);
|
|
}
|
|
|
|
tracing::info!(plugin = %name, "loaded plugin");
|
|
Ok(Self { vtable, _lib: lib, name })
|
|
}
|
|
|
|
/// Human-readable plugin name (filename).
|
|
pub fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
/// Retrieve the rejection reason from the plugin, falling back to a generic string.
|
|
fn rejection_reason(&self) -> String {
|
|
if let Some(f) = self.vtable.error_message {
|
|
let ptr = unsafe { f(self.vtable.user_data) };
|
|
if !ptr.is_null() {
|
|
// Safety: plugin must return a valid null-terminated UTF-8 (or ASCII) string.
|
|
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr as *const core::ffi::c_char) };
|
|
return cstr.to_string_lossy().into_owned();
|
|
}
|
|
}
|
|
"rejected by plugin".to_string()
|
|
}
|
|
}
|
|
|
|
impl Drop for PluginHooks {
|
|
fn drop(&mut self) {
|
|
if let Some(destroy) = self.vtable.destroy {
|
|
// Safety: destroy must be safe to call at any time after init.
|
|
unsafe { destroy(self.vtable.user_data) };
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ServerHooks for PluginHooks {
|
|
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
|
|
let f = match self.vtable.on_message_enqueue {
|
|
Some(f) => f,
|
|
None => return HookAction::Continue,
|
|
};
|
|
|
|
let sender_ptr = event
|
|
.sender_identity
|
|
.as_deref()
|
|
.map(|s| s.as_ptr())
|
|
.unwrap_or(core::ptr::null());
|
|
let sender_len = event.sender_identity.as_deref().map_or(0, |s| s.len());
|
|
|
|
let c_event = CMessageEvent {
|
|
sender_identity: sender_ptr,
|
|
sender_identity_len: sender_len,
|
|
recipient_key: event.recipient_key.as_ptr(),
|
|
recipient_key_len: event.recipient_key.len(),
|
|
channel_id: event.channel_id.as_ptr(),
|
|
channel_id_len: event.channel_id.len(),
|
|
payload_len: event.payload_len,
|
|
seq: event.seq,
|
|
};
|
|
|
|
let rc = unsafe { f(self.vtable.user_data, &c_event) };
|
|
if rc == HOOK_CONTINUE {
|
|
HookAction::Continue
|
|
} else {
|
|
HookAction::Reject(self.rejection_reason())
|
|
}
|
|
}
|
|
|
|
fn on_batch_enqueue(&self, events: &[MessageEvent]) {
|
|
let f = match self.vtable.on_batch_enqueue {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
|
|
let c_events: Vec<CMessageEvent> = events
|
|
.iter()
|
|
.map(|e| {
|
|
let sender_ptr = e
|
|
.sender_identity
|
|
.as_deref()
|
|
.map(|s| s.as_ptr())
|
|
.unwrap_or(core::ptr::null());
|
|
let sender_len = e.sender_identity.as_deref().map_or(0, |s| s.len());
|
|
CMessageEvent {
|
|
sender_identity: sender_ptr,
|
|
sender_identity_len: sender_len,
|
|
recipient_key: e.recipient_key.as_ptr(),
|
|
recipient_key_len: e.recipient_key.len(),
|
|
channel_id: e.channel_id.as_ptr(),
|
|
channel_id_len: e.channel_id.len(),
|
|
payload_len: e.payload_len,
|
|
seq: e.seq,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
unsafe { f(self.vtable.user_data, c_events.as_ptr(), c_events.len()) };
|
|
}
|
|
|
|
fn on_auth(&self, event: &AuthEvent) {
|
|
let f = match self.vtable.on_auth {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
let c_event = CAuthEvent {
|
|
username: event.username.as_ptr(),
|
|
username_len: event.username.len(),
|
|
success: if event.success { 1 } else { 0 },
|
|
failure_reason: event.failure_reason.as_ptr(),
|
|
failure_reason_len: event.failure_reason.len(),
|
|
};
|
|
unsafe { f(self.vtable.user_data, &c_event) };
|
|
}
|
|
|
|
fn on_channel_created(&self, event: &ChannelEvent) {
|
|
let f = match self.vtable.on_channel_created {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
let c_event = CChannelEvent {
|
|
channel_id: event.channel_id.as_ptr(),
|
|
channel_id_len: event.channel_id.len(),
|
|
initiator_key: event.initiator_key.as_ptr(),
|
|
initiator_key_len: event.initiator_key.len(),
|
|
peer_key: event.peer_key.as_ptr(),
|
|
peer_key_len: event.peer_key.len(),
|
|
was_new: if event.was_new { 1 } else { 0 },
|
|
};
|
|
unsafe { f(self.vtable.user_data, &c_event) };
|
|
}
|
|
|
|
fn on_fetch(&self, event: &FetchEvent) {
|
|
let f = match self.vtable.on_fetch {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
let c_event = CFetchEvent {
|
|
recipient_key: event.recipient_key.as_ptr(),
|
|
recipient_key_len: event.recipient_key.len(),
|
|
channel_id: event.channel_id.as_ptr(),
|
|
channel_id_len: event.channel_id.len(),
|
|
message_count: event.message_count,
|
|
};
|
|
unsafe { f(self.vtable.user_data, &c_event) };
|
|
}
|
|
|
|
fn on_user_registered(&self, username: &str, identity_key: &[u8]) {
|
|
let f = match self.vtable.on_user_registered {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
unsafe {
|
|
f(
|
|
self.vtable.user_data,
|
|
username.as_ptr(),
|
|
username.len(),
|
|
identity_key.as_ptr(),
|
|
identity_key.len(),
|
|
)
|
|
};
|
|
}
|
|
|
|
fn on_shutdown(&self) {
|
|
let f = match self.vtable.on_shutdown {
|
|
Some(f) => f,
|
|
None => return,
|
|
};
|
|
unsafe { f(self.vtable.user_data) };
|
|
}
|
|
}
|
|
|
|
// ── ChainedHooks ─────────────────────────────────────────────────────────────
|
|
|
|
/// Composes multiple [`ServerHooks`] implementations into one.
|
|
///
|
|
/// For filtering hooks (`on_message_enqueue`), the first rejection short-circuits
|
|
/// the chain. For fire-and-forget hooks, all plugins are called in order.
|
|
pub struct ChainedHooks {
|
|
hooks: Vec<Box<dyn ServerHooks>>,
|
|
}
|
|
|
|
impl ChainedHooks {
|
|
pub fn new(hooks: Vec<Box<dyn ServerHooks>>) -> Self {
|
|
Self { hooks }
|
|
}
|
|
}
|
|
|
|
impl ServerHooks for ChainedHooks {
|
|
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
|
|
for h in &self.hooks {
|
|
match h.on_message_enqueue(event) {
|
|
HookAction::Continue => {}
|
|
reject => return reject,
|
|
}
|
|
}
|
|
HookAction::Continue
|
|
}
|
|
|
|
fn on_batch_enqueue(&self, events: &[MessageEvent]) {
|
|
for h in &self.hooks {
|
|
h.on_batch_enqueue(events);
|
|
}
|
|
}
|
|
|
|
fn on_auth(&self, event: &AuthEvent) {
|
|
for h in &self.hooks {
|
|
h.on_auth(event);
|
|
}
|
|
}
|
|
|
|
fn on_channel_created(&self, event: &ChannelEvent) {
|
|
for h in &self.hooks {
|
|
h.on_channel_created(event);
|
|
}
|
|
}
|
|
|
|
fn on_fetch(&self, event: &FetchEvent) {
|
|
for h in &self.hooks {
|
|
h.on_fetch(event);
|
|
}
|
|
}
|
|
|
|
fn on_user_registered(&self, username: &str, identity_key: &[u8]) {
|
|
for h in &self.hooks {
|
|
h.on_user_registered(username, identity_key);
|
|
}
|
|
}
|
|
|
|
fn on_shutdown(&self) {
|
|
for h in &self.hooks {
|
|
h.on_shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── load_plugins_from_dir ─────────────────────────────────────────────────────
|
|
|
|
/// Load all `*.so` / `*.dylib` files from `dir` as plugins.
|
|
///
|
|
/// Non-fatal errors (unreadable files, init failures) are logged as warnings
|
|
/// and skipped; the server continues with the plugins that did load.
|
|
/// Returns the full list of successfully loaded plugins.
|
|
pub fn load_plugins_from_dir(dir: &Path) -> Vec<PluginHooks> {
|
|
let mut plugins = Vec::new();
|
|
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
tracing::warn!(dir = %dir.display(), error = %e, "plugin_dir unreadable; no plugins loaded");
|
|
return plugins;
|
|
}
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
if ext != "so" && ext != "dylib" {
|
|
continue;
|
|
}
|
|
|
|
match PluginHooks::load(&path) {
|
|
Ok(p) => {
|
|
tracing::info!(plugin = %p.name(), "plugin loaded successfully");
|
|
plugins.push(p);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(path = %path.display(), error = %e, "failed to load plugin; skipping");
|
|
}
|
|
}
|
|
}
|
|
|
|
plugins
|
|
}
|