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 $init_self:ident, $ctx_param:ident : &PluginContext $(,)?)
156 -> Result<(), Box<dyn std::error::Error + Send + Sync>> $init_body:block
157
158 fn name(&$name_self:ident) -> &str $name_body:block
159
160 fn version(&$version_self:ident) -> &str $version_body:block
161
162 fn process(
163 &$process_self:ident,
164 $msg_param:ident : &SpoeMessage $(,)?
165 ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> $process_body:block
166
167 $(fn config_schema(&$schema_self:ident) -> Option<&str> $schema_body:block)?
168
169 $(fn validate(&$validate_self:ident, $vctx_param:ident : &PluginContext $(,)?) -> Vec<Diagnostic> $validate_body:block)?
170
171 $(fn drain(&$drain_self:ident, $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 $init_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(&$name_self) -> &'static str $name_body
192
193 #[allow(dead_code)]
194 fn __version(&$version_self) -> &'static str $version_body
195
196 #[allow(clippy::unnecessary_wraps, dead_code)]
197 fn __process(
198 &$process_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(&$schema_self) -> ::std::option::Option<&'static str> $schema_body
208 )?
209
210 $(
211 #[allow(clippy::unnecessary_wraps, dead_code)]
212 fn __validate(
213 &$validate_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(&$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/// The recorder remains a static because callbacks may run from any plugin
478/// thread and need a process-stable address. Other plugin state should live on
479/// the plugin struct: `define_plugin!` captures each receiver identifier, so
480/// author bodies can use `self` normally.
481#[doc(hidden)]
482#[macro_export]
483macro_rules! __define_plugin_metrics_thunk {
484 ($state:ident, $record_fn:ident, $ctx:ident) => {{
485 // Plugin didn't declare `metrics_static` — no-op install.
486 let _ = $state;
487 let _ = $record_fn;
488 let _ = $ctx;
489 }};
490 ($state:ident, $record_fn:ident, $ctx:ident, $metrics_static:path) => {{
491 let _ = $state;
492 // Install on the static MetricRecorder the plugin pointed to.
493 // The static is global so it's reachable from every thread
494 // for the plugin's lifetime, matching the recorder's lifetime
495 // contract (valid until destroy returns; static lives forever
496 // → trivially satisfies the bound).
497 $metrics_static.install($record_fn, $ctx);
498 }};
499}
500
501/// Error raised when a plugin's `process` panics. Wrapped in `RBoxError`
502/// by the `define_plugin!` macro's panic-safety net.
503#[derive(Debug)]
504pub struct PluginPanicError;
505
506impl std::fmt::Display for PluginPanicError {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 write!(f, "plugin panicked during message processing")
509 }
510}
511
512impl std::error::Error for PluginPanicError {}
513
514#[cfg(test)]
515mod stateful_macro_tests {
516 use super::*;
517
518 #[derive(Debug)]
519 struct StatefulPlugin {
520 value: i32,
521 }
522
523 define_plugin!(StatefulPlugin, {
524 fn new() -> Self {
525 StatefulPlugin { value: 1 }
526 }
527
528 fn init(
529 &mut self,
530 context: &PluginContext,
531 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
532 self.value = context
533 .get_config("value")
534 .and_then(ConfigValue::as_integer)
535 .unwrap_or(1) as i32;
536 Ok(())
537 }
538
539 fn name(&self) -> &str {
540 let _ = self.value;
541 "stateful-test"
542 }
543
544 fn version(&self) -> &str {
545 let _ = self.value;
546 "0.0.0"
547 }
548
549 fn process(
550 &self,
551 _message: &SpoeMessage,
552 ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> {
553 Ok(ProcessingResult::single(TxnVariable::transaction(
554 "value",
555 SpoeValue::Int32(self.value),
556 )))
557 }
558
559 fn config_schema(&self) -> Option<&str> {
560 let _ = self.value;
561 None
562 }
563
564 fn validate(&self, _context: &PluginContext) -> Vec<Diagnostic> {
565 let _ = self.value;
566 Vec::new()
567 }
568
569 fn drain(&self, _timeout_ms: u64) -> bool {
570 self.value > 0
571 }
572 });
573
574 #[test]
575 fn author_bodies_can_read_and_write_instance_state() {
576 let mut config = RHashMap::new();
577 config.insert("value".into(), ConfigValue::Integer(42));
578 let context = PluginContext {
579 name: "stateful-test".into(),
580 config,
581 };
582 let message = SpoeMessage {
583 name: "test".into(),
584 args: RHashMap::new(),
585 stream_id: 1,
586 frame_id: 1,
587 };
588
589 let mut plugin = StatefulPlugin::__new();
590 plugin.__init(&context).expect("init should succeed");
591 let result = plugin.__process(&message).expect("process should succeed");
592
593 assert!(matches!(result.variables[0].value, SpoeValue::Int32(42)));
594 assert!(plugin.__drain(0));
595 }
596}