Custom Plugin Development
Extend Ferrum Edge with custom plugins written in Rust. Drop a .rs file in custom_plugins/ and it is auto-discovered, compiled into the binary, and available by name at runtime.
Overview
Custom plugins are plain Rust files compiled directly into the gateway binary — no external SDK, no FFI, no dynamic loading.
They implement the Plugin trait from crate::plugins and are registered automatically by a build script
that scans the custom_plugins/ directory.
The file name (without .rs) becomes the plugin name used in gateway configuration.
For example, my_rate_limiter.rs registers as plugin name "my_rate_limiter".
Quick Start
Create the plugin file
touch custom_plugins/my_plugin.rs
Implement the Plugin trait
See the full example below. Implement name(), declare response-body production, and export create_plugin(). Override priority() when needed; its default is 5000.
Build — your plugin is compiled in
cargo build --release
Reference it in your gateway config
{
"plugin_name": "my_plugin",
"scope": "global",
"config": { "header_value": "ferrum-custom" }
}
Priority Bands
Priority is a u16. Lower values run earlier. Bands are spaced with gaps so future plugins can
slot in without renumbering — pick a value in the band that matches where your plugin belongs.
| Range | Band | Purpose | Built-in Examples |
|---|---|---|---|
0–949 | Early | Matched-request tracing, IDs, preflight, and short-circuiting before auth | otel_tracing (25), correlation_id (50), cors (100), request_termination (125), ip_restriction (150), bot_detection (200), grpc_method_router (275), spiffe_identity (940) |
950–1999 | AuthN | Authentication / identity verification | mtls_auth (950), jwks_auth (1000), oauth2_introspection (1050), jwt_auth (1100), key_auth (1200), basic_auth (1300), hmac_auth (1400), soap_ws_security (1500) |
2000–2999 | Admission | Authorization, validation, and request admission control | access_control (2000), opa (2080), request_size_limiting (2800), rate_limiting (2900), ai_prompt_shield (2925), waf (2930), body_validator (2950), ai_request_guard (2975) |
3000–3999 | Transform | Request shaping and response buffering decisions | request_transformer (3000), serverless_function (3025), response_mock (3030), grpc_deadline (3050), request_mirror (3075), response_size_limiting (3490), response_caching (3500) |
4000–4999 | Response | Response transformation, compression, security headers, and AI accounting | response_transformer (4000), compression (4050), ai_federation (4060), security_headers (4080), ai_token_metrics (4100), ai_rate_limiter (4200) |
5000 | Custom | Default for unrecognized / custom plugins | — |
9000–9999 | Logging | Observability and frame logging | stdout_logging (9000), statsd_logging (9075), http_logging (9100), kafka_logging (9150), transaction_debugger (9200), prometheus_metrics (9300), api_chargeback (9350) |
The default priority is 5000 (the Custom band), which runs after all transforms but before
logging — a safe default when your plugin has no strong ordering requirements. Add a named constant to the
priority module in src/plugins/mod.rs for discoverability. Operators can also
override any instance's priority per plugin-config via priority_override (0–10000).
Plugin Trait Hooks — HTTP/gRPC/WebSocket
| Hook | Phase | Can Reject? | Typical Use |
|---|---|---|---|
on_request_received(&mut ctx) |
After route and allowed-method admission | Yes | IP filtering, request validation, early termination |
authenticate(&mut ctx, &consumer_index) |
Authentication | Yes | Verify identity (JWT, API key, custom tokens) |
authorize(&mut ctx) |
Authorization | Yes | Check permissions, enforce rate limits |
before_proxy(&mut ctx, &mut headers) |
Pre-backend | Yes | Transform request headers, add tracing IDs |
transform_request_body(&body, content_type) |
Pre-backend (buffered) | No | Rewrite request body before sending to backend |
on_final_request_body(&headers, &body) |
Pre-backend (post-transform) | Yes | Validate the final request body after all transforms |
after_proxy(&mut ctx, status, &mut headers) |
Post-backend | Yes | Transform response headers, reject responses |
on_response_body(&mut ctx, status, &headers, &body) |
Post-backend (buffered) | Yes | Inspect buffered response body, extract metrics |
transform_response_body(&body, content_type) |
Post-backend (buffered) | No | Rewrite response body before sending to client |
on_final_response_body(&mut ctx, status, &headers, &body) |
Post-backend (post-transform) | Yes | Validate the final response body after all transforms |
log(&summary) |
Logging | No (fire-and-forget) | Send transaction data to external systems |
on_ws_frame(proxy_id, connection_id, direction, &message) |
WebSocket frame | Close* | Inspect/transform per-frame WebSocket traffic |
All hooks have default no-op implementations — only override the ones your plugin needs.
*on_ws_frame cannot return PluginResult::Reject. Instead, return Some(Message::Close(...)) to close the connection in both directions.
before_proxy, always read request headers from the headers parameter, not from ctx.headers. The gateway moves headers out of ctx.headers via std::mem::take() during this call, so ctx.headers is empty. Headers are moved back after the call completes.
Plugin Trait Hooks — TCP/UDP Streams
| Hook | Phase | Can Reject? | Typical Use |
|---|---|---|---|
on_stream_connect(&mut stream_ctx) |
Connection established | Yes | Auth, authz, throttling, rate limiting for stream proxies |
on_stream_disconnect(&stream_summary) |
Connection closed | No (fire-and-forget) | Logging, metrics for stream proxies |
For TCP+TLS proxies, on_stream_connect runs after the frontend TLS handshake, so client certificate data is available in StreamConnectionContext.
Capability Methods
These methods tell the gateway about your plugin's requirements. All have default implementations except name(). Custom plugins must explicitly declare response-body production before handling buffered responses.
| Method | Default | Description |
|---|---|---|
name() | required | Unique plugin identifier — must match the file name (without .rs) and config plugin_name |
priority() | 5000 | Execution order (lower = earlier). See priority bands above. |
supported_protocols() | HTTP_ONLY_PROTOCOLS | Which proxy protocols this plugin supports (see protocol constants below) |
is_auth_plugin() | false | Set to true if your plugin participates in the authentication phase |
modifies_request_headers() | false | Set to true if your plugin modifies outgoing request headers in before_proxy() |
modifies_request_body() | false | Set to true if your plugin transforms the request body via transform_request_body() |
requires_request_body_before_before_proxy() | false | Set to true if your plugin needs the raw request body available during before_proxy() |
requires_request_body_buffering() | derived | Returns true if modifies_request_body() or requires_request_body_before_before_proxy(). Override for custom logic. |
should_buffer_request_body(&ctx) | delegates | Per-request decision on whether to buffer. Defaults to requires_request_body_buffering(). Override for conditional buffering. |
requires_response_body_buffering() | false | Set to true if your plugin needs the entire response body buffered (disables streaming) |
applies_after_proxy_on_reject() | false | Set to true if your plugin's after_proxy() should also run on gateway-generated rejection responses |
requires_ws_frame_hooks() | false | Set to true if your plugin implements on_ws_frame(). Pre-computed per proxy for zero overhead when unused. |
warmup_hostnames() | [] | Hostnames your plugin connects to — pre-resolved via DNS at startup |
tracked_keys_count() | None | Number of tracked rate-limit keys (for admin API diagnostics) |
Protocol Constants
Use these constants in supported_protocols() to declare which proxy protocols your plugin supports:
| Constant | Protocols | Use Case |
|---|---|---|
ALL_PROTOCOLS | Http, Grpc, WebSocket, Tcp, Udp | Protocol-agnostic plugins (logging, metrics, tracing) |
HTTP_FAMILY_PROTOCOLS | Http, Grpc, WebSocket | Plugins for all HTTP-based protocols |
HTTP_FAMILY_AND_TCP_PROTOCOLS | Http, Grpc, WebSocket, Tcp | HTTP family plus raw TCP streams |
HTTP_GRPC_PROTOCOLS | Http, Grpc | Plugins for HTTP and gRPC only |
HTTP_ONLY_PROTOCOLS | Http | HTTP-only plugins (default) |
GRPC_ONLY_PROTOCOLS | Grpc | gRPC-specific plugins |
WS_ONLY_PROTOCOLS | WebSocket | WebSocket frame-level plugins |
TCP_ONLY_PROTOCOLS | Tcp | TCP stream-only plugins |
Factory Function
Every plugin file must export a create_plugin function. This is the only required entry point — the build script discovers it automatically.
Config is passed in as a raw &serde_json::Value; returning Err rejects the config at admission time.
pub fn create_plugin(
config: &Value,
_http_client: PluginHttpClient,
) -> Result<Option<Arc<dyn Plugin>>, String> {
Ok(Some(Arc::new(MyPlugin::new(config)?)))
}
Full Example: Header Injection Plugin
Adds a configurable X-Custom-Gateway header to every proxied request and echoes it back in the response.
The repository’s complete example lives under custom_plugins/examples/; copy it to custom_plugins/ or select it with FERRUM_CUSTOM_PLUGINS=example_plugin. The abbreviated lifecycle table above is complemented by the full hook contract, including final-header and stream-termination hooks.
use async_trait::async_trait;
use http::HeaderValue;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use crate::plugins::{Plugin, PluginHttpClient, PluginResult, RequestContext, ResponseBodyProduction, TransactionSummary};
pub struct ExamplePlugin {
header_value: String,
}
impl ExamplePlugin {
pub fn new(config: &Value) -> Result<Self, String> {
let object = config.as_object().ok_or("config must be an object")?;
if object.keys().any(|key| key != "header_value") {
return Err("unknown configuration field".into());
}
let value = match object.get("header_value") {
None => "ferrum-custom",
Some(Value::String(value)) => value,
Some(_) => return Err("header_value must be a string".into()),
};
if value.len() > 8192 {
return Err("header_value exceeds 8192 bytes".into());
}
HeaderValue::from_str(value).map_err(|_| "invalid header value")?;
Ok(Self { header_value: value.to_string() })
}
}
#[async_trait]
impl Plugin for ExamplePlugin {
fn name(&self) -> &str {
"example_plugin"
}
fn priority(&self) -> u16 {
// Default band — runs after transforms, before logging.
super::super::plugins::priority::DEFAULT
}
fn response_body_production(&self) -> ResponseBodyProduction {
ResponseBodyProduction::Never
}
fn modifies_request_headers(&self) -> bool {
true
}
async fn on_request_received(&self, _ctx: &mut RequestContext) -> PluginResult {
PluginResult::Continue
}
async fn before_proxy(
&self,
_ctx: &mut RequestContext,
headers: &mut HashMap<String, String>,
) -> PluginResult {
headers.insert("x-custom-gateway".to_string(), self.header_value.clone());
PluginResult::Continue
}
async fn after_proxy(
&self,
_ctx: &mut RequestContext,
_response_status: u16,
response_headers: &mut HashMap<String, String>,
) -> PluginResult {
response_headers.insert("x-custom-gateway".to_string(), self.header_value.clone());
PluginResult::Continue
}
async fn log(&self, _summary: &TransactionSummary) {
// Fire-and-forget: send to a logging endpoint, write to a file, etc.
}
}
/// Factory function — called by the build-script-generated registry.
pub fn create_plugin(
config: &Value,
_http_client: PluginHttpClient,
) -> Result<Option<Arc<dyn Plugin>>, String> {
Ok(Some(Arc::new(ExamplePlugin::new(config)?)))
}
Database Migrations
Plugins that need their own tables can declare migrations via plugin_migrations().
These are auto-discovered by the build script and run alongside core gateway migrations when
FERRUM_MODE=migrate FERRUM_MIGRATE_ACTION=up is executed.
audit_log_) to avoid collisions with core tables or other plugins.
use crate::config::migrations::CustomPluginMigration;
pub fn plugin_migrations() -> Vec<CustomPluginMigration> {
vec![CustomPluginMigration {
version: 1,
name: "create_audit_log",
checksum: "v1_create_audit_log_example",
sql: "CREATE TABLE audit_log (id VARCHAR(255) PRIMARY KEY, message TEXT NOT NULL)",
sql_postgres: None,
sql_mysql: None,
}]
}
Running migrations
FERRUM_MODE=migrate FERRUM_MIGRATE_ACTION=up cargo run -- run
Build-time Filtering
Set FERRUM_CUSTOM_PLUGINS at build time to include only specific plugins.
If unset, all .rs files in custom_plugins/ are compiled in.
FERRUM_CUSTOM_PLUGINS=my_plugin,audit_plugin cargo build --release
Best Practices
Validate config in new()
Return Err(String) from your constructor for missing required fields or invalid values. This rejects bad configs at admission time, before the gateway starts serving traffic.
Use serde_json::Value
Config arrives as a raw &serde_json::Value. Use .as_str(), .as_bool(), .as_u64() etc., or deserialize into a typed struct with serde_json::from_value.
Declare modifies_request_headers
Return true only when your before_proxy() actually writes to the header map. This lets the gateway skip cloning the header map for plugins that don't need to modify it.
Avoid panics
Never unwrap() or panic!() in production plugin code. Use Result types and propagate errors via PluginResult — a panic in a plugin will crash the gateway worker.