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::logging::{LogLevel, LogSinkFn};
32use crate::metrics::RecordMetricFn;
33use crate::types::{Diagnostic, PluginContext, ProcessingResult, SpoeMessage};
34
35/// API version corresponding to the v1 baseline (initial release of the
36/// hand-rolled vtable). Every field declared in `PluginVTable` is part
37/// of v1 and is present on every plugin built against this crate.
38pub const PLUGIN_API_VERSION_V1: u32 = 1;
39
40/// API version v2: adds `set_metric_recorder` so plugins can emit
41/// Prometheus metrics through the hub's existing recorder. Plugins
42/// built against v2 keep loading on v1 hubs (the hub ignores the field
43/// because it doesn't know it exists); plugins built against v1 keep
44/// loading on v2 hubs (the hub gates access on `api_version >= 2` and
45/// skips the install for v1 plugins).
46pub const PLUGIN_API_VERSION_V2: u32 = 2;
47
48/// API version v3: adds `drain` so the hub can wait for a plugin's
49/// fire-and-forget background work (mirror's `runtime.spawn`'d HTTP
50/// dispatches, otel's batched exporter queue, etc.) to quiesce before
51/// `shutdown` is invoked on a reload. Plugins that complete all work
52/// inside `process()` (coraza, external-auth, sso-auth, ...) get the
53/// `define_plugin!` macro's default thunk which returns immediately —
54/// no in-flight state, nothing to wait for. Plugins with background
55/// work override `drain` to block until their state is quiescent or
56/// the supplied deadline elapses.
57///
58/// Compat: v3 plugins keep loading on v2/v1 hubs (those hubs ignore
59/// the new field); v2/v1 plugins keep loading on v3 hubs (the hub
60/// gates access on `api_version >= 3` and skips the drain wait for
61/// older plugins — equivalent to today's behaviour, which is the
62/// right default since those plugins are all synchronous-only).
63pub const PLUGIN_API_VERSION_V3: u32 = 3;
64
65/// API version v4: adds `set_log_sink` so a plugin's `log` records
66/// reach the hub's log. A plugin `.so` has its own copy of the `log`
67/// crate, so without the sink its records go nowhere. `define_plugin!`
68/// installs the sink unconditionally — plugins built against v4 forward
69/// their logs with no source change. v3-and-older plugins keep loading
70/// on v4 hubs (the hub gates on `api_version >= 4` and skips the
71/// install); v4 plugins keep loading on older hubs (which ignore the
72/// field, so their logs stay silent there).
73pub const PLUGIN_API_VERSION_V4: u32 = 4;
74
75/// Latest API version this crate's `PluginVTable` exposes. Plugin
76/// authors set this as the `api_version` field. When future versions
77/// of this crate append a field, this constant bumps by 1.
78pub const PLUGIN_API_VERSION: u32 = PLUGIN_API_VERSION_V4;
79
80/// Symbol name the hub looks up via `dlsym` after `dlopen`.
81pub const GET_PLUGIN_VTABLE_SYMBOL: &[u8] = b"get_plugin_vtable\0";
82
83/// Type of the exported entry-point symbol.
84pub type GetPluginVTableFn = extern "C" fn() -> *const PluginVTable;
85
86/// The function table a plugin shared library exports.
87///
88/// `#[repr(C)]` is mandatory: it pins field offsets and padding rules
89/// so plugins built against an older version of this crate keep
90/// working when the hub is built against a newer one (and vice versa).
91///
92/// # Safety contract for hosts
93///
94/// Fields below the v1 baseline marker are version-gated. Hosts MUST
95/// NOT access them via `&PluginVTable` directly — the borrow would
96/// implicitly cover the whole struct, which on an older plugin is
97/// past the end of its allocation. Use the `read_*_field` accessors
98/// or read individual fields through the raw pointer (e.g.
99/// `unsafe { (*ptr).api_version }`) which read only the bytes for that
100/// field.
101#[repr(C)]
102pub struct PluginVTable {
103 // ============================================================
104 // v1 baseline — always present on every plugin built against
105 // this crate. All fields above the marker comment are mandatory
106 // and never change order.
107 // ============================================================
108 /// Plugin's reported API version. The hub uses this to gate
109 /// access to fields beyond the v1 baseline.
110 pub api_version: u32,
111
112 /// Factory: allocate and return a new plugin state pointer.
113 /// `RErr` aborts plugin loading.
114 pub create: extern "C" fn() -> RResult<*mut c_void, RBoxError>,
115
116 /// Destructor: free the plugin state pointer. Called once at hub
117 /// shutdown or when a plugin fails post-create. MUST tolerate
118 /// being called with a state pointer that `init()` has not yet
119 /// observed.
120 pub destroy: extern "C" fn(state: *mut c_void),
121
122 /// Initialize the plugin. Called once after `create` and any
123 /// schema/validate pre-checks. `RErr` aborts plugin loading.
124 pub init: extern "C" fn(state: *mut c_void, ctx: &PluginContext) -> RResult<(), RBoxError>,
125
126 /// Handle one SPOE message. Called per request. The implementation
127 /// is wrapped in `catch_unwind` by the `define_plugin!` macro so a
128 /// panic does not abort the hub process.
129 pub process: extern "C" fn(
130 state: *const c_void,
131 msg: &SpoeMessage,
132 ) -> RResult<ProcessingResult, RBoxError>,
133
134 /// Plugin's display name. Borrowed for the lifetime of the plugin
135 /// instance (until `destroy` is called). Plugins typically return
136 /// a `&'static str` literal.
137 pub name: extern "C" fn(state: *const c_void) -> RStr<'static>,
138
139 /// Plugin's `SemVer` string. Same lifetime contract as `name`.
140 pub plugin_version: extern "C" fn(state: *const c_void) -> RStr<'static>,
141
142 /// Shutdown hook. Called once before `destroy`.
143 pub shutdown: extern "C" fn(state: *const c_void),
144
145 /// Optional JSON Schema string for config validation. `RNone`
146 /// skips schema validation. The default `define_plugin!` macro
147 /// emits a thunk that returns `RNone` when the plugin author has
148 /// not declared one.
149 pub config_schema: extern "C" fn(state: *const c_void) -> ROption<RString>,
150
151 /// Deep config validation. Returns an empty `RVec` when the
152 /// plugin's config is valid. Errors block loading; warnings are
153 /// surfaced but do not block.
154 pub validate: extern "C" fn(state: *const c_void, ctx: &PluginContext) -> RVec<Diagnostic>,
155 // ============================================================
156 // End of v1 baseline. Future additions appear below this line.
157 // Each new field MUST be guarded on `api_version` in the host
158 // and bumps `PLUGIN_API_VERSION` by 1.
159 // ============================================================
160 //
161 // v2 additions — present iff api_version >= PLUGIN_API_VERSION_V2.
162 /// Install the hub's metric recorder on the plugin. Called by the
163 /// hub once between `create` and `init`, so the plugin can emit
164 /// metrics from `init` onward. The plugin stores the `record_fn`
165 /// and `ctx` (typically inside a [`MetricRecorder`] field of its
166 /// state) and invokes `record_fn(ctx, ...)` for every metric
167 /// event. The recorder remains valid until `destroy` returns; the
168 /// hub guarantees lifetime, the plugin guarantees not to call it
169 /// after `destroy`.
170 ///
171 /// Plugins that don't want metrics still need to provide a thunk
172 /// (the `define_plugin!` macro emits a no-op default) — the field
173 /// is unconditionally read on v2 hubs.
174 ///
175 /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V2`.
176 /// Reading this field on a v1 plugin's vtable is UB — the v1
177 /// allocation does not include these bytes.
178 ///
179 /// [`MetricRecorder`]: crate::MetricRecorder
180 pub set_metric_recorder:
181 extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void),
182
183 // v3 additions — present iff api_version >= PLUGIN_API_VERSION_V3.
184 /// Block until the plugin's in-flight background work has completed
185 /// or `timeout_ms` milliseconds have elapsed. Called by the hub on
186 /// a config reload between the registry swap and the call to
187 /// `shutdown`, so plugins with fire-and-forget tasks (mirror's
188 /// `runtime.spawn`'d HTTP dispatches; otel's batched exporter
189 /// queue) can finish ACK'd requests before their runtime is torn
190 /// down.
191 ///
192 /// Returns `true` if the plugin reports it has nothing more in
193 /// flight (clean drain). Returns `false` if the timeout elapsed
194 /// while in-flight work was still pending (forced shutdown — the
195 /// hub will still proceed with `shutdown`, the open requests are
196 /// lost). Plugins MUST treat the timeout as advisory — going over
197 /// is acceptable in narrow margins, but the hub's overall reload
198 /// SLO depends on drain respecting the deadline.
199 ///
200 /// `process()` calls landing on this plugin instance AFTER drain
201 /// is invoked are still serviced; the hub's swap happens before
202 /// drain, so post-swap traffic goes to the NEW registry. Drain
203 /// concerns only work already in flight against this (now-old)
204 /// instance.
205 ///
206 /// Plugins with no background state (synchronous handlers that
207 /// complete inside `process()`) get the `define_plugin!` macro's
208 /// default thunk which returns `true` immediately — nothing to
209 /// wait for. Plugins that spawn background tasks SHOULD override
210 /// `drain` to wait on their in-flight counter.
211 ///
212 /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V3`.
213 /// Reading this field on a v1 or v2 plugin's vtable is UB — those
214 /// allocations do not include these bytes. The hub treats older
215 /// plugins as "drained immediately" because those plugins have no
216 /// in-flight background state in practice (coraza, external-auth,
217 /// fingerprinting, maxmind, otel pre-v0.4, sso-auth all complete
218 /// their work inside `process()`).
219 pub drain: extern "C" fn(state: *const c_void, timeout_ms: u64) -> bool,
220
221 // v4 additions — present iff api_version >= PLUGIN_API_VERSION_V4.
222 /// Install the hub's log sink on the plugin. Called by the hub once
223 /// between `create` and `init`; the `define_plugin!` macro's thunk
224 /// makes [`HubLogSink`] the plugin's global `log` logger, forwarding
225 /// every record at or below `max_level` to `sink_fn(ctx, …)`. `ctx`
226 /// identifies the `[[plugins]]` entry. The sink stays valid until
227 /// `destroy` returns.
228 ///
229 /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V4`.
230 ///
231 /// [`HubLogSink`]: crate::HubLogSink
232 pub set_log_sink: extern "C" fn(
233 state: *mut c_void,
234 sink_fn: LogSinkFn,
235 ctx: *const c_void,
236 max_level: LogLevel,
237 ),
238}
239
240impl PluginVTable {
241 /// Read just the `api_version` field of a plugin's vtable.
242 ///
243 /// # Safety
244 ///
245 /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
246 /// allocation that is at least 4 bytes long (every plugin has at
247 /// least the `api_version` field).
248 #[must_use]
249 pub unsafe fn read_api_version(vtable_ptr: *const PluginVTable) -> u32 {
250 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long.
251 // Reads only the first field; does not borrow the full struct.
252 unsafe { (*vtable_ptr).api_version }
253 }
254
255 /// Read the v2 `set_metric_recorder` field, returning `None` for
256 /// plugins built against v1 (whose allocation doesn't include it).
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 plugins this function reads only `api_version` and returns
264 /// `None` without touching past the v1 baseline.
265 #[must_use]
266 pub unsafe fn read_set_metric_recorder(
267 vtable_ptr: *const PluginVTable,
268 ) -> Option<extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void)>
269 {
270 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
271 // and that api_version accurately describes the layout.
272 let api_version = unsafe { Self::read_api_version(vtable_ptr) };
273 if api_version < PLUGIN_API_VERSION_V2 {
274 return None;
275 }
276 // SAFETY: api_version >= V2 implies the allocation includes the
277 // set_metric_recorder field. Reading the field directly (not
278 // via `&PluginVTable`) avoids borrowing the whole struct.
279 Some(unsafe { (*vtable_ptr).set_metric_recorder })
280 }
281
282 /// Read the v3 `drain` field, returning `None` for plugins built
283 /// against v1 or v2 (whose allocation doesn't include it). The
284 /// hub treats `None` as "drained immediately" — see the field's
285 /// doc comment for the rationale (older plugins are synchronous).
286 ///
287 /// # Safety
288 ///
289 /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
290 /// allocation produced by a published plugin (i.e. consistent with
291 /// the `api_version` field's claim about which fields are present).
292 /// On v1/v2 plugins this function reads only `api_version` and
293 /// returns `None` without touching past the relevant baseline.
294 #[must_use]
295 pub unsafe fn read_drain(
296 vtable_ptr: *const PluginVTable,
297 ) -> Option<extern "C" fn(state: *const c_void, timeout_ms: u64) -> bool> {
298 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
299 // and that api_version accurately describes the layout.
300 let api_version = unsafe { Self::read_api_version(vtable_ptr) };
301 if api_version < PLUGIN_API_VERSION_V3 {
302 return None;
303 }
304 // SAFETY: api_version >= V3 implies the allocation includes the
305 // drain field. Reading the field directly (not via
306 // `&PluginVTable`) avoids borrowing the whole struct.
307 Some(unsafe { (*vtable_ptr).drain })
308 }
309
310 /// Read the v4 `set_log_sink` field, returning `None` for plugins
311 /// built against v1–v3 (whose allocation doesn't include it); the
312 /// hub then leaves the plugin's logging as it is.
313 ///
314 /// # Safety
315 ///
316 /// Same contract as [`Self::read_drain`]: `vtable_ptr` must point at
317 /// a vtable whose `api_version` accurately describes its layout.
318 #[must_use]
319 pub unsafe fn read_set_log_sink(
320 vtable_ptr: *const PluginVTable,
321 ) -> Option<
322 extern "C" fn(
323 state: *mut c_void,
324 sink_fn: LogSinkFn,
325 ctx: *const c_void,
326 max_level: LogLevel,
327 ),
328 > {
329 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
330 // and that api_version accurately describes the layout.
331 let api_version = unsafe { Self::read_api_version(vtable_ptr) };
332 if api_version < PLUGIN_API_VERSION_V4 {
333 return None;
334 }
335 // SAFETY: api_version >= V4 implies the allocation includes the
336 // set_log_sink field.
337 Some(unsafe { (*vtable_ptr).set_log_sink })
338 }
339}