Skip to main content

haproxy_spoa_hub_plugin_api/
lib.rs

1//! Plugin API for haproxy-spoa-hub.
2//!
3//! # ABI model
4//!
5//! Plugins are loaded from `.so` files at runtime via `dlopen`. The
6//! interface between hub and plugin is a hand-rolled `#[repr(C)]`
7//! vtable defined in [`vtable::PluginVTable`], reached via the
8//! exported `get_plugin_vtable` symbol.
9//!
10//! See [`vtable`] for the load-bearing layout invariants and version
11//! evolution rules. In short: append-only, never reorder, never remove,
12//! version-gate every new field on the host. The matrix test in
13//! `crates/hub/tests/abi_matrix.rs` enforces these invariants by
14//! loading every published plugin version against the current hub.
15//!
16//! # Layout discipline for data types
17//!
18//! `PluginContext`, `SpoeMessage`, `ProcessingResult`, `Diagnostic`,
19//! `ConfigValue`, `SpoeValue`, `TxnVariable`, and `VarScope` are all
20//! `#[derive(StableAbi)]` which gives them `#[repr(C)]` layout.
21//! Existing fields MUST NOT be reordered or removed. Adding a field is
22//! a layout change that requires a coordinated plugin-api version bump
23//! (and is itself a separate concern from vtable evolution — there is
24//! no per-field version-gating on data types).
25//!
26//! `abi_stable` is pinned to `=0.11.3` in `Cargo.toml` so plugins and
27//! hub see byte-identical `RString` / `RVec` / etc. across the FFI
28//! boundary. Bumping that pin is a coordinated rollout, not a routine
29//! patch.
30
31#![allow(non_camel_case_types, non_local_definitions)]
32
33pub mod metrics;
34pub mod types;
35pub mod vtable;
36
37pub use abi_stable;
38pub use abi_stable::std_types::{
39    RBoxError, RHashMap, ROption, RResult, RSlice, RStr, RString, RVec, Tuple2,
40};
41pub use metrics::{MetricKind, MetricLabel, MetricRecorder, RecordMetricFn};
42pub use types::{
43    ConfigValue, Diagnostic, DiagnosticSeverity, PluginContext, ProcessingResult, SpoeMessage,
44    SpoeValue, TxnVariable, VarScope,
45};
46pub use vtable::{
47    GET_PLUGIN_VTABLE_SYMBOL, GetPluginVTableFn, PLUGIN_API_VERSION, PLUGIN_API_VERSION_V1,
48    PLUGIN_API_VERSION_V2, PLUGIN_API_VERSION_V3, PluginVTable,
49};
50
51/// Define a plugin and emit the FFI surface (vtable + entry symbol).
52///
53/// Plugin authors write a regular `impl`-style block; the macro emits
54/// `extern "C"` thunks that bridge into it, the static `PluginVTable`,
55/// and the `get_plugin_vtable` symbol the hub looks up after `dlopen`.
56///
57/// `process` is automatically wrapped in `std::panic::catch_unwind` so
58/// a panic during request handling does not abort the hub process.
59///
60/// # Usage
61///
62/// ```rust,ignore
63/// use haproxy_spoa_hub_plugin_api::{ProcessingResult, SpoeMessage, SpoeValue, TxnVariable, define_plugin};
64///
65/// #[derive(Debug, Default)]
66/// struct MyPlugin;
67///
68/// define_plugin!(MyPlugin, {
69///     fn new() -> Self { MyPlugin }
70///
71///     fn init(&mut self, _: &PluginContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
72///         Ok(())
73///     }
74///
75///     fn name(&self) -> &str { "my-plugin" }
76///     fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
77///
78///     fn process(&self, _: &SpoeMessage)
79///         -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>>
80///     {
81///         Ok(ProcessingResult::single(TxnVariable::session(
82///             "result", SpoeValue::String("ok".into()),
83///         )))
84///     }
85/// });
86/// ```
87///
88/// `config_schema` and `validate` are optional. Add them at the end of
89/// the block in that order if you need them. Plugins that omit them
90/// get the default no-op behavior (no schema, empty diagnostics).
91///
92/// # `validate()` contract: do *semantic* checks only
93///
94/// The hub guarantees that by the time your `validate()` body runs,
95/// the [`PluginContext`] passed in has already been validated against
96/// your `config_schema()` (in BOTH the load path and the
97/// `--validate-socket` path; see `crates/hub/src/plugin_loader.rs`'s
98/// `collect_schema_errors` and how it's called from
99/// `crates/hub/src/validate/orchestrator.rs`). Schema errors short-
100/// circuit before `validate()` is called and surface to the operator
101/// as the same diagnostic shape your override would emit.
102///
103/// **You should therefore never reimplement structural validation
104/// inside `validate()`.** No re-checking that a field is a string,
105/// no walking arrays-vs-tables defensively, no type-shape
106/// assumptions. Use whatever typed accessor your plugin already
107/// builds for `init()` (typically `from_context(ctx)` populating a
108/// `PluginConfig` struct) — the structure is guaranteed valid.
109///
110/// `validate()` is for *semantic* checks the JSON Schema can't
111/// express. Examples:
112/// - Compiling `SecLang` directives via Coraza so a typo'd
113///   `SecBogusDirective` becomes a line-numbered diagnostic
114///   (haproxy-spoa-hub-plugin-coraza).
115/// - Resolving a remote auth-server URL to verify it's reachable
116///   (a hypothetical external-auth deep check).
117/// - Anything else that requires runtime context the schema can't
118///   capture.
119///
120/// Historical context: pre-hub-v0.5.2, the `--validate-socket` path
121/// did NOT schema-check before calling `validate()`, so plugins that
122/// added their own structural walking could (and did) drift away
123/// from `config_schema()` and the runtime parser. The coraza plugin's
124/// `applications`-as-array vs `applications`-as-table mismatch in
125/// v0.4.0–v0.4.1 was that bug. Centralising the check in the hub
126/// makes it impossible for a plugin author to accidentally repeat.
127///
128/// # Lifetime constraints
129///
130/// `name` and `version` MUST return `&'static str`. The macro emits an
131/// FFI thunk whose return type is `RStr<'static>`, so a `&str`
132/// borrowed from `&self` will not compile. In practice this means
133/// returning either a string literal or `env!("CARGO_PKG_VERSION")`.
134/// If you need to compute the name from instance fields, store it in
135/// a `&'static str` (e.g. via `Box::leak(...)` at construction time)
136/// or pre-register the strings as `const`s.
137///
138/// # Panic safety
139///
140/// Every author-supplied body is wrapped in `std::panic::catch_unwind`
141/// inside its FFI thunk. A panic surfaces as:
142/// - `process` / `init` / `create`: `RResult::RErr(PluginPanicError)`.
143/// - `validate`: a single `Diagnostic::error` entry returned to the
144///   host (not an abort — required for `--validate-socket` mode).
145/// - `config_schema`: treated as `RNone`.
146/// - `name` / `version`: returned as the literal `"<plugin-panic>"`.
147/// - `shutdown` / `destroy`: absorbed; memory may leak but the hub
148///   stays up.
149#[macro_export]
150macro_rules! define_plugin {
151    (
152        $plugin_ty:ty, {
153            fn new() -> Self $new_body:block
154
155            fn init(&mut self, $ctx_param:ident : &PluginContext $(,)?)
156                -> Result<(), Box<dyn std::error::Error + Send + Sync>> $init_body:block
157
158            fn name(&self) -> &str $name_body:block
159
160            fn version(&self) -> &str $version_body:block
161
162            fn process(
163                &self,
164                $msg_param:ident : &SpoeMessage $(,)?
165            ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> $process_body:block
166
167            $(fn config_schema(&self) -> Option<&str> $schema_body:block)?
168
169            $(fn validate(&self, $vctx_param:ident : &PluginContext $(,)?) -> Vec<Diagnostic> $validate_body:block)?
170
171            $(fn drain(&self, $drain_timeout_param:ident : u64 $(,)?) -> bool $drain_body:block)?
172
173            $(metrics_static = $metrics_static:path;)?
174        }
175    ) => {
176        // Author-supplied bodies become inherent methods on the
177        // plugin type. The thunks below cast the opaque state pointer
178        // back to `&Self` (or `&mut Self` for init) and call them.
179        impl $plugin_ty {
180            #[allow(dead_code)]
181            fn __new() -> Self $new_body
182
183            #[allow(clippy::unnecessary_wraps, dead_code)]
184            fn __init(
185                &mut self,
186                $ctx_param: &$crate::PluginContext,
187            ) -> ::std::result::Result<(), ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>>
188                $init_body
189
190            #[allow(dead_code)]
191            fn __name(&self) -> &'static str $name_body
192
193            #[allow(dead_code)]
194            fn __version(&self) -> &'static str $version_body
195
196            #[allow(clippy::unnecessary_wraps, dead_code)]
197            fn __process(
198                &self,
199                $msg_param: &$crate::SpoeMessage,
200            ) -> ::std::result::Result<
201                $crate::ProcessingResult,
202                ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
203            > $process_body
204
205            $(
206                #[allow(clippy::unnecessary_wraps, dead_code)]
207                fn __config_schema(&self) -> ::std::option::Option<&'static str> $schema_body
208            )?
209
210            $(
211                #[allow(clippy::unnecessary_wraps, dead_code)]
212                fn __validate(
213                    &self,
214                    $vctx_param: &$crate::PluginContext,
215                ) -> ::std::vec::Vec<$crate::Diagnostic> $validate_body
216            )?
217
218            $(
219                #[allow(clippy::unnecessary_wraps, dead_code)]
220                fn __drain(&self, $drain_timeout_param: u64) -> bool $drain_body
221            )?
222        }
223
224        // FFI thunks. All `unsafe` operations are confined here; the
225        // plugin author's bodies above remain in safe Rust.
226        const _: () = {
227            use ::std::os::raw::c_void;
228            use ::std::panic::{AssertUnwindSafe, catch_unwind};
229
230            // Every thunk that calls into user-supplied code is wrapped
231            // in `catch_unwind`. Without this, a panic in any plugin
232            // method would unwind through `extern "C"` — which Rust
233            // defines as abort — taking the whole hub down. Each thunk
234            // has a sensible fallback for the catch case so the host
235            // observes a structured error rather than UB.
236
237            extern "C" fn create() -> $crate::RResult<*mut c_void, $crate::RBoxError> {
238                match catch_unwind(|| {
239                    let plugin: ::std::boxed::Box<$plugin_ty> =
240                        ::std::boxed::Box::new(<$plugin_ty>::__new());
241                    ::std::boxed::Box::into_raw(plugin).cast::<c_void>()
242                }) {
243                    Ok(raw) => $crate::RResult::ROk(raw),
244                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
245                        $crate::PluginPanicError,
246                    )),
247                }
248            }
249
250            extern "C" fn destroy(state: *mut c_void) {
251                if state.is_null() {
252                    return;
253                }
254                // SAFETY: state was produced by `create` via Box::into_raw
255                // on `Box<$plugin_ty>`. The hub guarantees one destroy per
256                // create. A panic in the plugin's Drop is absorbed; the
257                // alternative (abort) would lose every other live plugin.
258                let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
259                    ::std::mem::drop(::std::boxed::Box::from_raw(state.cast::<$plugin_ty>()));
260                }));
261            }
262
263            extern "C" fn init(
264                state: *mut c_void,
265                ctx: &$crate::PluginContext,
266            ) -> $crate::RResult<(), $crate::RBoxError> {
267                // SAFETY: state was produced by `create` and not yet
268                // destroyed. Hub holds an exclusive reference during init.
269                let plugin = unsafe { &mut *state.cast::<$plugin_ty>() };
270                match catch_unwind(AssertUnwindSafe(|| plugin.__init(ctx))) {
271                    Ok(Ok(())) => $crate::RResult::ROk(()),
272                    Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
273                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
274                        $crate::PluginPanicError,
275                    )),
276                }
277            }
278
279            extern "C" fn process(
280                state: *const c_void,
281                msg: &$crate::SpoeMessage,
282            ) -> $crate::RResult<$crate::ProcessingResult, $crate::RBoxError> {
283                // SAFETY: state is alive between init and destroy. Process
284                // takes &self so concurrent calls share a borrow — the
285                // plugin must use interior mutability for any mutable state.
286                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
287                match catch_unwind(AssertUnwindSafe(|| plugin.__process(msg))) {
288                    Ok(Ok(result)) => $crate::RResult::ROk(result),
289                    Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
290                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
291                        $crate::PluginPanicError,
292                    )),
293                }
294            }
295
296            extern "C" fn name(state: *const c_void) -> $crate::RStr<'static> {
297                // SAFETY: state alive between init and destroy.
298                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
299                match catch_unwind(AssertUnwindSafe(|| plugin.__name())) {
300                    Ok(s) => $crate::RStr::from(s),
301                    Err(_) => $crate::RStr::from("<plugin-panic>"),
302                }
303            }
304
305            extern "C" fn plugin_version(state: *const c_void) -> $crate::RStr<'static> {
306                // SAFETY: state alive between init and destroy.
307                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
308                match catch_unwind(AssertUnwindSafe(|| plugin.__version())) {
309                    Ok(s) => $crate::RStr::from(s),
310                    Err(_) => $crate::RStr::from("<plugin-panic>"),
311                }
312            }
313
314            extern "C" fn shutdown(_state: *const c_void) {
315                // The original SpoePlugin trait's shutdown has an empty
316                // default body; the macro mirrors that. Plugins that need
317                // teardown logic do it in `destroy` (Drop on the boxed
318                // state), which is called once after the last use.
319                // Wrapped for forward-compat: if the macro grows a
320                // user-overridable shutdown body, the wrapper is already
321                // here.
322                let _ = catch_unwind(AssertUnwindSafe(|| {
323                    let _ = _state;
324                }));
325            }
326
327            extern "C" fn config_schema(_state: *const c_void) -> $crate::ROption<$crate::RString> {
328                // catch_unwind around the (potentially user-overridden)
329                // body. On panic, return RNone so the hub skips schema
330                // validation rather than aborting.
331                catch_unwind(AssertUnwindSafe(|| {
332                    $crate::__define_plugin_config_schema_thunk!(_state, $plugin_ty $(, $schema_body)?)
333                }))
334                .unwrap_or($crate::ROption::RNone)
335            }
336
337            extern "C" fn validate(
338                _state: *const c_void,
339                _ctx: &$crate::PluginContext,
340            ) -> $crate::RVec<$crate::Diagnostic> {
341                // catch_unwind around the (potentially user-overridden)
342                // body. On panic, return a single error Diagnostic so
343                // the hub surfaces a structured failure instead of
344                // aborting (critical for --validate-socket mode).
345                catch_unwind(AssertUnwindSafe(|| {
346                    $crate::__define_plugin_validate_thunk!(_state, _ctx, $plugin_ty $(, $validate_body)?)
347                }))
348                .unwrap_or_else(|_| {
349                    let mut diags = $crate::RVec::new();
350                    diags.push($crate::Diagnostic::error(
351                        0,
352                        0,
353                        "plugin's validate() panicked",
354                    ));
355                    diags
356                })
357            }
358
359            extern "C" fn set_metric_recorder(
360                _state: *mut c_void,
361                _record_fn: $crate::RecordMetricFn,
362                _ctx: *const c_void,
363            ) {
364                // The thunk dispatches to the plugin's `metrics_static`
365                // declaration (when present) by calling install() on
366                // the named MetricRecorder. When absent, no-op.
367                // catch_unwind so a panic in install() doesn't unwind
368                // through extern "C".
369                let _ = catch_unwind(AssertUnwindSafe(|| {
370                    $crate::__define_plugin_metrics_thunk!(_state, _record_fn, _ctx $(, $metrics_static)?)
371                }));
372            }
373
374            extern "C" fn drain(_state: *const c_void, _timeout_ms: u64) -> bool {
375                // Dispatch to user-supplied `fn drain` if present;
376                // otherwise the default thunk returns true immediately
377                // (correct for synchronous plugins that complete all
378                // work inside `process()` — coraza, external-auth,
379                // sso-auth, fingerprinting, maxmind, ...).
380                //
381                // catch_unwind so a panic in a user-defined drain
382                // doesn't unwind through extern "C". On panic we
383                // return false — the hub treats that as a forced
384                // shutdown (counter incremented, in-flight work lost)
385                // which is the right outcome for a misbehaving plugin.
386                catch_unwind(AssertUnwindSafe(|| {
387                    $crate::__define_plugin_drain_thunk!(_state, _timeout_ms, $plugin_ty $(, $drain_body)?)
388                }))
389                .unwrap_or(false)
390            }
391
392            #[allow(non_upper_case_globals)]
393            static PLUGIN_VTABLE_INSTANCE: $crate::PluginVTable = $crate::PluginVTable {
394                api_version: $crate::PLUGIN_API_VERSION,
395                create,
396                destroy,
397                init,
398                process,
399                name,
400                plugin_version,
401                shutdown,
402                config_schema,
403                validate,
404                set_metric_recorder,
405                drain,
406            };
407
408            #[unsafe(no_mangle)]
409            pub extern "C" fn get_plugin_vtable() -> *const $crate::PluginVTable {
410                &PLUGIN_VTABLE_INSTANCE
411            }
412        };
413    };
414}
415
416/// Internal helper used by `define_plugin!` to expand the optional
417/// `config_schema` arm. With body → call the impl; without → return None.
418#[doc(hidden)]
419#[macro_export]
420macro_rules! __define_plugin_config_schema_thunk {
421    ($state:ident, $plugin_ty:ty) => {{
422        let _ = $state;
423        $crate::ROption::RNone
424    }};
425    ($state:ident, $plugin_ty:ty, $body:block) => {{
426        // SAFETY: state alive between init and destroy.
427        let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
428        match plugin.__config_schema() {
429            ::std::option::Option::Some(s) => $crate::ROption::RSome($crate::RString::from(s)),
430            ::std::option::Option::None => $crate::ROption::RNone,
431        }
432    }};
433}
434
435/// Internal helper used by `define_plugin!` to expand the optional
436/// `validate` arm. With body → call the impl; without → return empty.
437#[doc(hidden)]
438#[macro_export]
439macro_rules! __define_plugin_validate_thunk {
440    ($state:ident, $ctx:ident, $plugin_ty:ty) => {{
441        let _ = $state;
442        let _ = $ctx;
443        $crate::RVec::new()
444    }};
445    ($state:ident, $ctx:ident, $plugin_ty:ty, $body:block) => {{
446        // SAFETY: state alive between init and destroy.
447        let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
448        $crate::RVec::from(plugin.__validate($ctx))
449    }};
450}
451
452/// Internal helper used by `define_plugin!` to expand the optional
453/// `drain` arm. With body → call the impl; without → return true
454/// immediately (synchronous plugin, nothing in flight to wait for).
455#[doc(hidden)]
456#[macro_export]
457macro_rules! __define_plugin_drain_thunk {
458    ($state:ident, $timeout_ms:ident, $plugin_ty:ty) => {{
459        let _ = $state;
460        let _ = $timeout_ms;
461        true
462    }};
463    ($state:ident, $timeout_ms:ident, $plugin_ty:ty, $body:block) => {{
464        // SAFETY: state alive between init and destroy. Drain is
465        // called between the registry swap and shutdown — both are
466        // operations on still-live state, so &Self is fine.
467        let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
468        plugin.__drain($timeout_ms)
469    }};
470}
471
472/// Internal helper used by `define_plugin!` to expand the optional
473/// `metrics_static` arm. With a path → install the recorder on the
474/// named static `MetricRecorder`; without → ignore (the plugin doesn't
475/// emit metrics).
476///
477/// Why a static and not a `&self` accessor: Rust macro hygiene doesn't
478/// let `$body:block` bodies inside `define_plugin!` reference `self`
479/// (the `self` token in the body resolves through the macro's hygiene
480/// context, not the function's receiver — see the existing pattern
481/// where every other plugin uses a `static OnceLock<State>` for the
482/// same reason). A path arm avoids the limitation cleanly and matches
483/// the convention every other plugin already follows.
484#[doc(hidden)]
485#[macro_export]
486macro_rules! __define_plugin_metrics_thunk {
487    ($state:ident, $record_fn:ident, $ctx:ident) => {{
488        // Plugin didn't declare `metrics_static` — no-op install.
489        let _ = $state;
490        let _ = $record_fn;
491        let _ = $ctx;
492    }};
493    ($state:ident, $record_fn:ident, $ctx:ident, $metrics_static:path) => {{
494        let _ = $state;
495        // Install on the static MetricRecorder the plugin pointed to.
496        // The static is global so it's reachable from every thread
497        // for the plugin's lifetime, matching the recorder's lifetime
498        // contract (valid until destroy returns; static lives forever
499        // → trivially satisfies the bound).
500        $metrics_static.install($record_fn, $ctx);
501    }};
502}
503
504/// Error raised when a plugin's `process` panics. Wrapped in `RBoxError`
505/// by the `define_plugin!` macro's panic-safety net.
506#[derive(Debug)]
507pub struct PluginPanicError;
508
509impl std::fmt::Display for PluginPanicError {
510    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        write!(f, "plugin panicked during message processing")
512    }
513}
514
515impl std::error::Error for PluginPanicError {}