Docs / Custom Plugins

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

1

Create the plugin file

bash
touch custom_plugins/my_plugin.rs
2

Implement the Plugin trait

See the full example below. At minimum you need name(), priority(), and create_plugin().

3

Build — your plugin is compiled in

bash
cargo build --release
4

Reference it in your gateway config

json
{
  "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.

RangeBandPurposeBuilt-in Examples
0–99ObservabilityTracing, correlationotel_tracing (25), correlation_id (50)
100–999PreflightCORS, IP filtering, termination, bot detectioncors (100), request_termination (125), ip_restriction (150), bot_detection (200), grpc_method_router (275)
950–1499AuthenticationIdentity verificationmtls_auth (950), jwks_auth (1000), jwt_auth (1100), key_auth (1200), basic_auth (1300), hmac_auth (1400)
2000–2099AuthorizationAccess control, throttlingaccess_control (2000), tcp_connection_throttle (2050)
2800–2999Request ValidationSize limits, rate limits, body validationrequest_size_limiting (2800), graphql (2850), rate_limiting (2900), ai_prompt_shield (2925), body_validator (2950), ai_request_guard (2975)
3000–3099Request TransformModify request before backendrequest_transformer (3000), serverless_function (3025), grpc_deadline (3050)
3400–3599Response ValidationResponse size limits, cachingresponse_size_limiting (3490), response_caching (3500)
4000–4299Response TransformModify response, metricsresponse_transformer (4000), ai_token_metrics (4100), ai_rate_limiter (4200)
5000Custom DefaultDefault for custom plugins
9000–9999LoggingObservability, metricsstdout_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

HookPhaseCan 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 header parameter: In 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

HookPhaseCan 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().

MethodDefaultDescription
name()requiredUnique plugin identifier — must match the file name (without .rs) and config plugin_name
priority()5000Execution order (lower = earlier). See priority bands above.
supported_protocols()HTTP_ONLY_PROTOCOLSWhich proxy protocols this plugin supports (see protocol constants below)
is_auth_plugin()falseSet to true if your plugin participates in the authentication phase
modifies_request_headers()falseSet to true if your plugin modifies outgoing request headers in before_proxy()
modifies_request_body()falseSet to true if your plugin transforms the request body via transform_request_body()
requires_request_body_before_before_proxy()falseSet to true if your plugin needs the raw request body available during before_proxy()
requires_request_body_buffering()derivedReturns true if modifies_request_body() or requires_request_body_before_before_proxy(). Override for custom logic.
should_buffer_request_body(&ctx)delegatesPer-request decision on whether to buffer. Defaults to requires_request_body_buffering(). Override for conditional buffering.
requires_response_body_buffering()falseSet to true if your plugin needs the entire response body buffered (disables streaming)
applies_after_proxy_on_reject()falseSet to true if your plugin's after_proxy() should also run on gateway-generated rejection responses
requires_ws_frame_hooks()falseSet 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()NoneNumber 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:

ConstantProtocolsUse Case
ALL_PROTOCOLSHttp, Grpc, WebSocket, Tcp, UdpProtocol-agnostic plugins (logging, metrics, tracing)
HTTP_FAMILY_PROTOCOLSHttp, Grpc, WebSocketPlugins for all HTTP-based protocols
HTTP_FAMILY_AND_TCP_PROTOCOLSHttp, Grpc, WebSocket, TcpHTTP family plus raw TCP streams
HTTP_GRPC_PROTOCOLSHttp, GrpcPlugins for HTTP and gRPC only
HTTP_ONLY_PROTOCOLSHttpHTTP-only plugins (default)
GRPC_ONLY_PROTOCOLSGrpcgRPC-specific plugins
WS_ONLY_PROTOCOLSWebSocketWebSocket frame-level plugins
TCP_ONLY_PROTOCOLSTcpTCP 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.

rust
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.

rust — custom_plugins/example_plugin.rs
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.

ℹ️
Prefix your table names with your plugin name (e.g. audit_log_) to avoid collisions with core tables or other plugins.
rust — plugin_migrations() example
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

bash
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.

bash
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.

Architecture Overview → Browse Built-in Plugins