Skip to main content

haproxy_spoa_hub_plugin_api/
vtable.rs

1//! Hand-rolled `#[repr(C)]` plugin vtable.
2//!
3//! Design rationale: see ADR-001 / docs/abi-evolution.md (TBD). In short:
4//! `abi_stable`'s prefix-type model rejects loads where the host's vtable
5//! has more fields than the plugin's, blocking the host-grows-faster
6//! direction we need. The hand-rolled C-style vtable with a leading
7//! `api_version: u32` field, version-gated host access, and an
8//! append-only evolution discipline gives us bidirectional ABI compat
9//! without a third-party load-time check that fights us.
10//!
11//! # Layout invariants (load-bearing)
12//!
13//! 1. `api_version` MUST be the first field.
14//! 2. Existing fields MUST NOT be reordered or removed in any future
15//!    crate version. New fields MUST be appended after the existing ones.
16//! 3. `PLUGIN_API_VERSION` increments by 1 each time a new field is
17//!    appended and the corresponding accessor lands.
18//! 4. Hosts MUST read `api_version` before accessing any field beyond
19//!    the v1 baseline. Reading newer fields on a plugin that reports
20//!    a lower `api_version` is undefined behavior — the plugin's
21//!    allocation does not include those bytes.
22//!
23//! See `crates/hub/tests/abi_matrix.rs` for the regression test that
24//! enforces invariants 1–3 by loading every published plugin version
25//! against the current hub binary.
26
27use std::os::raw::c_void;
28
29use abi_stable::std_types::{RBoxError, ROption, RResult, RStr, RString, RVec};
30
31use crate::metrics::RecordMetricFn;
32use crate::types::{Diagnostic, PluginContext, ProcessingResult, SpoeMessage};
33
34/// API version corresponding to the v1 baseline (initial release of the
35/// hand-rolled vtable). Every field declared in `PluginVTable` is part
36/// of v1 and is present on every plugin built against this crate.
37pub const PLUGIN_API_VERSION_V1: u32 = 1;
38
39/// API version v2: adds `set_metric_recorder` so plugins can emit
40/// Prometheus metrics through the hub's existing recorder. Plugins
41/// built against v2 keep loading on v1 hubs (the hub ignores the field
42/// because it doesn't know it exists); plugins built against v1 keep
43/// loading on v2 hubs (the hub gates access on `api_version >= 2` and
44/// skips the install for v1 plugins).
45pub const PLUGIN_API_VERSION_V2: u32 = 2;
46
47/// API version v3: adds `drain` so the hub can wait for a plugin's
48/// fire-and-forget background work (mirror's `runtime.spawn`'d HTTP
49/// dispatches, otel's batched exporter queue, etc.) to quiesce before
50/// `shutdown` is invoked on a reload. Plugins that complete all work
51/// inside `process()` (coraza, external-auth, sso-auth, ...) get the
52/// `define_plugin!` macro's default thunk which returns immediately —
53/// no in-flight state, nothing to wait for. Plugins with background
54/// work override `drain` to block until their state is quiescent or
55/// the supplied deadline elapses.
56///
57/// Compat: v3 plugins keep loading on v2/v1 hubs (those hubs ignore
58/// the new field); v2/v1 plugins keep loading on v3 hubs (the hub
59/// gates access on `api_version >= 3` and skips the drain wait for
60/// older plugins — equivalent to today's behaviour, which is the
61/// right default since those plugins are all synchronous-only).
62pub const PLUGIN_API_VERSION_V3: u32 = 3;
63
64/// Latest API version this crate's `PluginVTable` exposes. Plugin
65/// authors set this as the `api_version` field. When future versions
66/// of this crate append a field, this constant bumps by 1.
67pub const PLUGIN_API_VERSION: u32 = PLUGIN_API_VERSION_V3;
68
69/// Symbol name the hub looks up via `dlsym` after `dlopen`.
70pub const GET_PLUGIN_VTABLE_SYMBOL: &[u8] = b"get_plugin_vtable\0";
71
72/// Type of the exported entry-point symbol.
73pub type GetPluginVTableFn = extern "C" fn() -> *const PluginVTable;
74
75/// The function table a plugin shared library exports.
76///
77/// `#[repr(C)]` is mandatory: it pins field offsets and padding rules
78/// so plugins built against an older version of this crate keep
79/// working when the hub is built against a newer one (and vice versa).
80///
81/// # Safety contract for hosts
82///
83/// Fields below the v1 baseline marker are version-gated. Hosts MUST
84/// NOT access them via `&PluginVTable` directly — the borrow would
85/// implicitly cover the whole struct, which on an older plugin is
86/// past the end of its allocation. Use the `read_*_field` accessors
87/// or read individual fields through the raw pointer (e.g.
88/// `unsafe { (*ptr).api_version }`) which read only the bytes for that
89/// field.
90#[repr(C)]
91pub struct PluginVTable {
92    // ============================================================
93    // v1 baseline — always present on every plugin built against
94    // this crate. All fields above the marker comment are mandatory
95    // and never change order.
96    // ============================================================
97    /// Plugin's reported API version. The hub uses this to gate
98    /// access to fields beyond the v1 baseline.
99    pub api_version: u32,
100
101    /// Factory: allocate and return a new plugin state pointer.
102    /// `RErr` aborts plugin loading.
103    pub create: extern "C" fn() -> RResult<*mut c_void, RBoxError>,
104
105    /// Destructor: free the plugin state pointer. Called once at hub
106    /// shutdown or when a plugin fails post-create. MUST tolerate
107    /// being called with a state pointer that `init()` has not yet
108    /// observed.
109    pub destroy: extern "C" fn(state: *mut c_void),
110
111    /// Initialize the plugin. Called once after `create` and any
112    /// schema/validate pre-checks. `RErr` aborts plugin loading.
113    pub init: extern "C" fn(state: *mut c_void, ctx: &PluginContext) -> RResult<(), RBoxError>,
114
115    /// Handle one SPOE message. Called per request. The implementation
116    /// is wrapped in `catch_unwind` by the `define_plugin!` macro so a
117    /// panic does not abort the hub process.
118    pub process: extern "C" fn(
119        state: *const c_void,
120        msg: &SpoeMessage,
121    ) -> RResult<ProcessingResult, RBoxError>,
122
123    /// Plugin's display name. Borrowed for the lifetime of the plugin
124    /// instance (until `destroy` is called). Plugins typically return
125    /// a `&'static str` literal.
126    pub name: extern "C" fn(state: *const c_void) -> RStr<'static>,
127
128    /// Plugin's `SemVer` string. Same lifetime contract as `name`.
129    pub plugin_version: extern "C" fn(state: *const c_void) -> RStr<'static>,
130
131    /// Shutdown hook. Called once before `destroy`.
132    pub shutdown: extern "C" fn(state: *const c_void),
133
134    /// Optional JSON Schema string for config validation. `RNone`
135    /// skips schema validation. The default `define_plugin!` macro
136    /// emits a thunk that returns `RNone` when the plugin author has
137    /// not declared one.
138    pub config_schema: extern "C" fn(state: *const c_void) -> ROption<RString>,
139
140    /// Deep config validation. Returns an empty `RVec` when the
141    /// plugin's config is valid. Errors block loading; warnings are
142    /// surfaced but do not block.
143    pub validate: extern "C" fn(state: *const c_void, ctx: &PluginContext) -> RVec<Diagnostic>,
144    // ============================================================
145    // End of v1 baseline. Future additions appear below this line.
146    // Each new field MUST be guarded on `api_version` in the host
147    // and bumps `PLUGIN_API_VERSION` by 1.
148    // ============================================================
149    //
150    // v2 additions — present iff api_version >= PLUGIN_API_VERSION_V2.
151    /// Install the hub's metric recorder on the plugin. Called by the
152    /// hub once between `create` and `init`, so the plugin can emit
153    /// metrics from `init` onward. The plugin stores the `record_fn`
154    /// and `ctx` (typically inside a [`MetricRecorder`] field of its
155    /// state) and invokes `record_fn(ctx, ...)` for every metric
156    /// event. The recorder remains valid until `destroy` returns; the
157    /// hub guarantees lifetime, the plugin guarantees not to call it
158    /// after `destroy`.
159    ///
160    /// Plugins that don't want metrics still need to provide a thunk
161    /// (the `define_plugin!` macro emits a no-op default) — the field
162    /// is unconditionally read on v2 hubs.
163    ///
164    /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V2`.
165    /// Reading this field on a v1 plugin's vtable is UB — the v1
166    /// allocation does not include these bytes.
167    ///
168    /// [`MetricRecorder`]: crate::MetricRecorder
169    pub set_metric_recorder:
170        extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void),
171
172    // v3 additions — present iff api_version >= PLUGIN_API_VERSION_V3.
173    /// Block until the plugin's in-flight background work has completed
174    /// or `timeout_ms` milliseconds have elapsed. Called by the hub on
175    /// a config reload between the registry swap and the call to
176    /// `shutdown`, so plugins with fire-and-forget tasks (mirror's
177    /// `runtime.spawn`'d HTTP dispatches; otel's batched exporter
178    /// queue) can finish ACK'd requests before their runtime is torn
179    /// down.
180    ///
181    /// Returns `true` if the plugin reports it has nothing more in
182    /// flight (clean drain). Returns `false` if the timeout elapsed
183    /// while in-flight work was still pending (forced shutdown — the
184    /// hub will still proceed with `shutdown`, the open requests are
185    /// lost). Plugins MUST treat the timeout as advisory — going over
186    /// is acceptable in narrow margins, but the hub's overall reload
187    /// SLO depends on drain respecting the deadline.
188    ///
189    /// `process()` calls landing on this plugin instance AFTER drain
190    /// is invoked are still serviced; the hub's swap happens before
191    /// drain, so post-swap traffic goes to the NEW registry. Drain
192    /// concerns only work already in flight against this (now-old)
193    /// instance.
194    ///
195    /// Plugins with no background state (synchronous handlers that
196    /// complete inside `process()`) get the `define_plugin!` macro's
197    /// default thunk which returns `true` immediately — nothing to
198    /// wait for. Plugins that spawn background tasks SHOULD override
199    /// `drain` to wait on their in-flight counter.
200    ///
201    /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V3`.
202    /// Reading this field on a v1 or v2 plugin's vtable is UB — those
203    /// allocations do not include these bytes. The hub treats older
204    /// plugins as "drained immediately" because those plugins have no
205    /// in-flight background state in practice (coraza, external-auth,
206    /// fingerprinting, maxmind, otel pre-v0.4, sso-auth all complete
207    /// their work inside `process()`).
208    pub drain: extern "C" fn(state: *const c_void, timeout_ms: u64) -> bool,
209}
210
211impl PluginVTable {
212    /// Read just the `api_version` field of a plugin's vtable.
213    ///
214    /// # Safety
215    ///
216    /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
217    /// allocation that is at least 4 bytes long (every plugin has at
218    /// least the `api_version` field).
219    #[must_use]
220    pub unsafe fn read_api_version(vtable_ptr: *const PluginVTable) -> u32 {
221        // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long.
222        // Reads only the first field; does not borrow the full struct.
223        unsafe { (*vtable_ptr).api_version }
224    }
225
226    /// Read the v2 `set_metric_recorder` field, returning `None` for
227    /// plugins built against v1 (whose allocation doesn't include it).
228    ///
229    /// # Safety
230    ///
231    /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
232    /// allocation produced by a published plugin (i.e. consistent with
233    /// the `api_version` field's claim about which fields are present).
234    /// On v1 plugins this function reads only `api_version` and returns
235    /// `None` without touching past the v1 baseline.
236    #[must_use]
237    pub unsafe fn read_set_metric_recorder(
238        vtable_ptr: *const PluginVTable,
239    ) -> Option<extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void)>
240    {
241        // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
242        // and that api_version accurately describes the layout.
243        let api_version = unsafe { Self::read_api_version(vtable_ptr) };
244        if api_version < PLUGIN_API_VERSION_V2 {
245            return None;
246        }
247        // SAFETY: api_version >= V2 implies the allocation includes the
248        // set_metric_recorder field. Reading the field directly (not
249        // via `&PluginVTable`) avoids borrowing the whole struct.
250        Some(unsafe { (*vtable_ptr).set_metric_recorder })
251    }
252
253    /// Read the v3 `drain` field, returning `None` for plugins built
254    /// against v1 or v2 (whose allocation doesn't include it). The
255    /// hub treats `None` as "drained immediately" — see the field's
256    /// doc comment for the rationale (older plugins are synchronous).
257    ///
258    /// # Safety
259    ///
260    /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
261    /// allocation produced by a published plugin (i.e. consistent with
262    /// the `api_version` field's claim about which fields are present).
263    /// On v1/v2 plugins this function reads only `api_version` and
264    /// returns `None` without touching past the relevant baseline.
265    #[must_use]
266    pub unsafe fn read_drain(
267        vtable_ptr: *const PluginVTable,
268    ) -> Option<extern "C" fn(state: *const c_void, timeout_ms: u64) -> bool> {
269        // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
270        // and that api_version accurately describes the layout.
271        let api_version = unsafe { Self::read_api_version(vtable_ptr) };
272        if api_version < PLUGIN_API_VERSION_V3 {
273            return None;
274        }
275        // SAFETY: api_version >= V3 implies the allocation includes the
276        // drain field. Reading the field directly (not via
277        // `&PluginVTable`) avoids borrowing the whole struct.
278        Some(unsafe { (*vtable_ptr).drain })
279    }
280}