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. At minimum you need name(), priority(), and create_plugin().
Build — your plugin is compiled in
cargo build --release
Reference it in your gateway config
{
"plugin_name": "my_plugin",
"config": { "header_value": "ferrum-custom" }
}
Priority Bands
Priority is a u16. Lower values run earlier. Use the bands below to control where your plugin fits in the execution order.
| Range | Band | Purpose | Built-in Examples |
|---|---|---|---|
0–99 | Observability | Tracing, correlation | otel_tracing (25), correlation_id (50) |
100–999 | Preflight | CORS, IP filtering, termination, bot detection | cors (100), request_termination (125), ip_restriction (150), bot_detection (200), grpc_method_router (275) |
950–1499 | Authentication | Identity verification | mtls_auth (950), jwks_auth (1000), jwt_auth (1100), key_auth (1200), basic_auth (1300), hmac_auth (1400) |
2000–2099 | Authorization | Access control, throttling | access_control (2000), tcp_connection_throttle (2050) |
2800–2999 | Request Validation | Size limits, rate limits, body validation | request_size_limiting (2800), graphql (2850), rate_limiting (2900), ai_prompt_shield (2925), body_validator (2950), ai_request_guard (2975) |
3000–3099 | Request Transform | Modify request before backend | request_transformer (3000), serverless_function (3025), grpc_deadline (3050) |
3400–3599 | Response Validation | Response size limits, caching | response_size_limiting (3490), response_caching (3500) |
4000–4299 | Response Transform | Modify response, metrics | response_transformer (4000), ai_token_metrics (4100), ai_rate_limiter (4200) |
5000 | Custom Default | Default for custom plugins | — |
9000–9999 | Logging | Observability, metrics | stdout_logging (9000), statsd_logging (9075), http_logging (9100), prometheus (9300) |
Use the constant super::super::plugins::priority::DEFAULT (= 5000) when you don't need a specific position.
Plugin Trait Hooks — HTTP/gRPC/WebSocket
| Hook | Phase | Can Reject? | Typical Use |
|---|---|---|---|
on_request_received(&mut ctx) |
Pre-routing | 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() and priority().
| 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.
This is based on the example_plugin.rs included in the repo.
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use crate::plugins::{Plugin, PluginHttpClient, PluginResult, RequestContext, TransactionSummary};
pub struct ExamplePlugin {
header_value: String,
}
impl ExamplePlugin {
pub fn new(config: &Value) -> Result<Self, String> {
Ok(Self {
// Read config from the plugin's JSON config block:
// { "plugin_name": "example_plugin", "config": { "header_value": "my-gateway" } }
header_value: config["header_value"]
.as_str()
.unwrap_or("ferrum-custom")
.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 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_f8a3e1",
// Default SQL (SQLite-compatible)
sql: r#"
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
client_ip TEXT NOT NULL,
http_method TEXT NOT NULL,
request_path TEXT NOT NULL,
response_status INTEGER NOT NULL,
latency_ms REAL NOT NULL,
consumer_username TEXT,
proxy_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp)
"#,
// PostgreSQL override: use TIMESTAMPTZ and JSONB
sql_postgres: Some(r#"
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
...
request_headers JSONB
);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp)
"#),
// MySQL override: use DATETIME(3) and JSON
sql_mysql: Some(r#"
CREATE TABLE IF NOT EXISTS audit_log (
id VARCHAR(255) PRIMARY KEY,
timestamp DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
...
request_headers JSON
)
"#),
},
]
}
Running migrations
FERRUM_MODE=migrate FERRUM_MIGRATE_ACTION=up cargo 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.