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, 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 $(metrics_static = $metrics_static:path;)?
172 }
173 ) => {
174 // Author-supplied bodies become inherent methods on the
175 // plugin type. The thunks below cast the opaque state pointer
176 // back to `&Self` (or `&mut Self` for init) and call them.
177 impl $plugin_ty {
178 #[allow(dead_code)]
179 fn __new() -> Self $new_body
180
181 #[allow(clippy::unnecessary_wraps, dead_code)]
182 fn __init(
183 &mut self,
184 $ctx_param: &$crate::PluginContext,
185 ) -> ::std::result::Result<(), ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>>
186 $init_body
187
188 #[allow(dead_code)]
189 fn __name(&self) -> &'static str $name_body
190
191 #[allow(dead_code)]
192 fn __version(&self) -> &'static str $version_body
193
194 #[allow(clippy::unnecessary_wraps, dead_code)]
195 fn __process(
196 &self,
197 $msg_param: &$crate::SpoeMessage,
198 ) -> ::std::result::Result<
199 $crate::ProcessingResult,
200 ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
201 > $process_body
202
203 $(
204 #[allow(clippy::unnecessary_wraps, dead_code)]
205 fn __config_schema(&self) -> ::std::option::Option<&'static str> $schema_body
206 )?
207
208 $(
209 #[allow(clippy::unnecessary_wraps, dead_code)]
210 fn __validate(
211 &self,
212 $vctx_param: &$crate::PluginContext,
213 ) -> ::std::vec::Vec<$crate::Diagnostic> $validate_body
214 )?
215 }
216
217 // FFI thunks. All `unsafe` operations are confined here; the
218 // plugin author's bodies above remain in safe Rust.
219 const _: () = {
220 use ::std::os::raw::c_void;
221 use ::std::panic::{AssertUnwindSafe, catch_unwind};
222
223 // Every thunk that calls into user-supplied code is wrapped
224 // in `catch_unwind`. Without this, a panic in any plugin
225 // method would unwind through `extern "C"` — which Rust
226 // defines as abort — taking the whole hub down. Each thunk
227 // has a sensible fallback for the catch case so the host
228 // observes a structured error rather than UB.
229
230 extern "C" fn create() -> $crate::RResult<*mut c_void, $crate::RBoxError> {
231 match catch_unwind(|| {
232 let plugin: ::std::boxed::Box<$plugin_ty> =
233 ::std::boxed::Box::new(<$plugin_ty>::__new());
234 ::std::boxed::Box::into_raw(plugin).cast::<c_void>()
235 }) {
236 Ok(raw) => $crate::RResult::ROk(raw),
237 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
238 $crate::PluginPanicError,
239 )),
240 }
241 }
242
243 extern "C" fn destroy(state: *mut c_void) {
244 if state.is_null() {
245 return;
246 }
247 // SAFETY: state was produced by `create` via Box::into_raw
248 // on `Box<$plugin_ty>`. The hub guarantees one destroy per
249 // create. A panic in the plugin's Drop is absorbed; the
250 // alternative (abort) would lose every other live plugin.
251 let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
252 ::std::mem::drop(::std::boxed::Box::from_raw(state.cast::<$plugin_ty>()));
253 }));
254 }
255
256 extern "C" fn init(
257 state: *mut c_void,
258 ctx: &$crate::PluginContext,
259 ) -> $crate::RResult<(), $crate::RBoxError> {
260 // SAFETY: state was produced by `create` and not yet
261 // destroyed. Hub holds an exclusive reference during init.
262 let plugin = unsafe { &mut *state.cast::<$plugin_ty>() };
263 match catch_unwind(AssertUnwindSafe(|| plugin.__init(ctx))) {
264 Ok(Ok(())) => $crate::RResult::ROk(()),
265 Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
266 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
267 $crate::PluginPanicError,
268 )),
269 }
270 }
271
272 extern "C" fn process(
273 state: *const c_void,
274 msg: &$crate::SpoeMessage,
275 ) -> $crate::RResult<$crate::ProcessingResult, $crate::RBoxError> {
276 // SAFETY: state is alive between init and destroy. Process
277 // takes &self so concurrent calls share a borrow — the
278 // plugin must use interior mutability for any mutable state.
279 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
280 match catch_unwind(AssertUnwindSafe(|| plugin.__process(msg))) {
281 Ok(Ok(result)) => $crate::RResult::ROk(result),
282 Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
283 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
284 $crate::PluginPanicError,
285 )),
286 }
287 }
288
289 extern "C" fn name(state: *const c_void) -> $crate::RStr<'static> {
290 // SAFETY: state alive between init and destroy.
291 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
292 match catch_unwind(AssertUnwindSafe(|| plugin.__name())) {
293 Ok(s) => $crate::RStr::from(s),
294 Err(_) => $crate::RStr::from("<plugin-panic>"),
295 }
296 }
297
298 extern "C" fn plugin_version(state: *const c_void) -> $crate::RStr<'static> {
299 // SAFETY: state alive between init and destroy.
300 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
301 match catch_unwind(AssertUnwindSafe(|| plugin.__version())) {
302 Ok(s) => $crate::RStr::from(s),
303 Err(_) => $crate::RStr::from("<plugin-panic>"),
304 }
305 }
306
307 extern "C" fn shutdown(_state: *const c_void) {
308 // The original SpoePlugin trait's shutdown has an empty
309 // default body; the macro mirrors that. Plugins that need
310 // teardown logic do it in `destroy` (Drop on the boxed
311 // state), which is called once after the last use.
312 // Wrapped for forward-compat: if the macro grows a
313 // user-overridable shutdown body, the wrapper is already
314 // here.
315 let _ = catch_unwind(AssertUnwindSafe(|| {
316 let _ = _state;
317 }));
318 }
319
320 extern "C" fn config_schema(_state: *const c_void) -> $crate::ROption<$crate::RString> {
321 // catch_unwind around the (potentially user-overridden)
322 // body. On panic, return RNone so the hub skips schema
323 // validation rather than aborting.
324 catch_unwind(AssertUnwindSafe(|| {
325 $crate::__define_plugin_config_schema_thunk!(_state, $plugin_ty $(, $schema_body)?)
326 }))
327 .unwrap_or($crate::ROption::RNone)
328 }
329
330 extern "C" fn validate(
331 _state: *const c_void,
332 _ctx: &$crate::PluginContext,
333 ) -> $crate::RVec<$crate::Diagnostic> {
334 // catch_unwind around the (potentially user-overridden)
335 // body. On panic, return a single error Diagnostic so
336 // the hub surfaces a structured failure instead of
337 // aborting (critical for --validate-socket mode).
338 catch_unwind(AssertUnwindSafe(|| {
339 $crate::__define_plugin_validate_thunk!(_state, _ctx, $plugin_ty $(, $validate_body)?)
340 }))
341 .unwrap_or_else(|_| {
342 let mut diags = $crate::RVec::new();
343 diags.push($crate::Diagnostic::error(
344 0,
345 0,
346 "plugin's validate() panicked",
347 ));
348 diags
349 })
350 }
351
352 extern "C" fn set_metric_recorder(
353 _state: *mut c_void,
354 _record_fn: $crate::RecordMetricFn,
355 _ctx: *const c_void,
356 ) {
357 // The thunk dispatches to the plugin's `metrics_static`
358 // declaration (when present) by calling install() on
359 // the named MetricRecorder. When absent, no-op.
360 // catch_unwind so a panic in install() doesn't unwind
361 // through extern "C".
362 let _ = catch_unwind(AssertUnwindSafe(|| {
363 $crate::__define_plugin_metrics_thunk!(_state, _record_fn, _ctx $(, $metrics_static)?)
364 }));
365 }
366
367 #[allow(non_upper_case_globals)]
368 static PLUGIN_VTABLE_INSTANCE: $crate::PluginVTable = $crate::PluginVTable {
369 api_version: $crate::PLUGIN_API_VERSION,
370 create,
371 destroy,
372 init,
373 process,
374 name,
375 plugin_version,
376 shutdown,
377 config_schema,
378 validate,
379 set_metric_recorder,
380 };
381
382 #[unsafe(no_mangle)]
383 pub extern "C" fn get_plugin_vtable() -> *const $crate::PluginVTable {
384 &PLUGIN_VTABLE_INSTANCE
385 }
386 };
387 };
388}
389
390/// Internal helper used by `define_plugin!` to expand the optional
391/// `config_schema` arm. With body → call the impl; without → return None.
392#[doc(hidden)]
393#[macro_export]
394macro_rules! __define_plugin_config_schema_thunk {
395 ($state:ident, $plugin_ty:ty) => {{
396 let _ = $state;
397 $crate::ROption::RNone
398 }};
399 ($state:ident, $plugin_ty:ty, $body:block) => {{
400 // SAFETY: state alive between init and destroy.
401 let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
402 match plugin.__config_schema() {
403 ::std::option::Option::Some(s) => $crate::ROption::RSome($crate::RString::from(s)),
404 ::std::option::Option::None => $crate::ROption::RNone,
405 }
406 }};
407}
408
409/// Internal helper used by `define_plugin!` to expand the optional
410/// `validate` arm. With body → call the impl; without → return empty.
411#[doc(hidden)]
412#[macro_export]
413macro_rules! __define_plugin_validate_thunk {
414 ($state:ident, $ctx:ident, $plugin_ty:ty) => {{
415 let _ = $state;
416 let _ = $ctx;
417 $crate::RVec::new()
418 }};
419 ($state:ident, $ctx:ident, $plugin_ty:ty, $body:block) => {{
420 // SAFETY: state alive between init and destroy.
421 let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
422 $crate::RVec::from(plugin.__validate($ctx))
423 }};
424}
425
426/// Internal helper used by `define_plugin!` to expand the optional
427/// `metrics_static` arm. With a path → install the recorder on the
428/// named static `MetricRecorder`; without → ignore (the plugin doesn't
429/// emit metrics).
430///
431/// Why a static and not a `&self` accessor: Rust macro hygiene doesn't
432/// let `$body:block` bodies inside `define_plugin!` reference `self`
433/// (the `self` token in the body resolves through the macro's hygiene
434/// context, not the function's receiver — see the existing pattern
435/// where every other plugin uses a `static OnceLock<State>` for the
436/// same reason). A path arm avoids the limitation cleanly and matches
437/// the convention every other plugin already follows.
438#[doc(hidden)]
439#[macro_export]
440macro_rules! __define_plugin_metrics_thunk {
441 ($state:ident, $record_fn:ident, $ctx:ident) => {{
442 // Plugin didn't declare `metrics_static` — no-op install.
443 let _ = $state;
444 let _ = $record_fn;
445 let _ = $ctx;
446 }};
447 ($state:ident, $record_fn:ident, $ctx:ident, $metrics_static:path) => {{
448 let _ = $state;
449 // Install on the static MetricRecorder the plugin pointed to.
450 // The static is global so it's reachable from every thread
451 // for the plugin's lifetime, matching the recorder's lifetime
452 // contract (valid until destroy returns; static lives forever
453 // → trivially satisfies the bound).
454 $metrics_static.install($record_fn, $ctx);
455 }};
456}
457
458/// Error raised when a plugin's `process` panics. Wrapped in `RBoxError`
459/// by the `define_plugin!` macro's panic-safety net.
460#[derive(Debug)]
461pub struct PluginPanicError;
462
463impl std::fmt::Display for PluginPanicError {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 write!(f, "plugin panicked during message processing")
466 }
467}
468
469impl std::error::Error for PluginPanicError {}