Skip to main content

featherbit/plugins/
mod.rs

1//! Two-tier plugin system: native Rust plugins ([`native`]) and scripted
2//! plugins ([`script`]). Defines the [`Plugin`] trait — the contract every
3//! graph node implements — and [`create_plugin`], the single factory that
4//! maps node-type strings from YAML config to plugin instances.
5
6pub mod native;
7pub mod ports;
8pub mod resources;
9pub mod script;
10pub mod util;
11
12use async_trait::async_trait;
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use crate::context::{Context, GatewayError};
17use ports::PortSpec;
18use resources::PluginResources;
19
20/// The result of a successful plugin execution.
21#[derive(Debug)]
22pub struct PluginOutput {
23    /// The (possibly mutated) context, passed on to the next node in the graph.
24    pub context: Context,
25    /// Declared output port this result leaves on. `None` = `success`.
26    /// Must name a port of kind `outcome` in the node type's [`PortSpec`].
27    pub port: Option<&'static str>,
28}
29
30impl PluginOutput {
31    /// The normal exit: continue through the `success` port.
32    pub fn success(context: Context) -> Self {
33        Self {
34            context,
35            port: None,
36        }
37    }
38
39    /// Exit through a declared named `outcome` port (e.g. `"denied"`).
40    pub fn on_port(context: Context, port: &'static str) -> Self {
41        Self {
42            context,
43            port: Some(port),
44        }
45    }
46}
47
48/// The result of a plugin execution: either success or an error with the context preserved.
49pub type PluginResult = Result<PluginOutput, PluginExecutionError>;
50
51/// An error that occurs during plugin execution. The context is preserved so the
52/// graph engine can route it through the error port.
53#[derive(Debug)]
54pub struct PluginExecutionError {
55    /// The context as it stood when the error occurred; execution continues
56    /// with it along the node's error edge.
57    pub context: Context,
58    /// The error details, appended to `Context.errors` by the graph engine.
59    pub error: GatewayError,
60}
61
62/// Every plugin (native or scripted) implements this trait.
63///
64/// A plugin is a node in a compiled policy graph. The engine drives each node
65/// through [`execute`](Plugin::execute) and follows the output port the result
66/// names.
67///
68/// A node type's ports are **not** declared on this trait: they live in the
69/// static registry ([`port_spec`] over [`ports`]), the single source of truth
70/// shared by the graph compiler, the admin catalog, and the UI editor. A
71/// plugin therefore cannot drift from its own declaration.
72#[async_trait]
73pub trait Plugin: Send + Sync {
74    /// Unique identifier for the plugin type (e.g., "proxy-rewrite", "upstream").
75    /// This is also the key its [`PortSpec`] is registered under in
76    /// [`port_spec`].
77    fn plugin_type(&self) -> &str;
78
79    /// Executes the plugin logic against the request/response context.
80    ///
81    /// Contract:
82    /// - `ctx` is taken **by value**: the plugin owns the [`Context`] for the
83    ///   duration of the call and must hand it back in either outcome — inside
84    ///   [`PluginOutput`] on success, or inside [`PluginExecutionError`] on
85    ///   failure. The context is never lost.
86    /// - On `Ok`, the engine routes the returned context through the port the
87    ///   [`PluginOutput`] names: [`PluginOutput::success`] takes the node's
88    ///   `success` port, and [`PluginOutput::on_port`] takes a named
89    ///   **outcome** port — the node did its job and chose a deliberate
90    ///   alternate route (`denied`, `redirect`, `limited`, `broken`,
91    ///   `preflight`, `abort`, `routed`, `hit`, `respond`, `true`/`false`), normally with the
92    ///   client-facing response already prepared. The named port must be one
93    ///   this type declares in its `PortSpec`, or the policy would not have
94    ///   compiled; nothing is appended to `ctx.errors`.
95    /// - On `Err`, the [`PluginExecutionError`] carries both the context and a
96    ///   [`GatewayError`], which the engine appends to `ctx.errors` before
97    ///   continuing through the node's `error` port (or the policy catch-all,
98    ///   or a generic 500) instead of aborting the request. `Err` is reserved
99    ///   for *the node could not do its job* — configuration, parse, or
100    ///   infrastructure failure. A plugin must never name `error` from `Ok`.
101    async fn execute(&self, ctx: Context) -> PluginResult;
102
103    /// Whether this **configured instance** reads `context.response.body`.
104    ///
105    /// Defaults to `true`: a plugin that does not opt out forces the policy to
106    /// buffer, so adding a plugin can never silently break a stream. Opting out
107    /// is a deliberate statement about a specific configuration. Consulted at
108    /// policy-compile time by `infer_stream_capability` (`src/graph/engine.rs`),
109    /// which walks an `upstream` node's success path and only marks it
110    /// stream-capable when every node on that path opts out.
111    fn reads_response_body(&self) -> bool {
112        true
113    }
114
115    /// The cache backend this node writes to, if it is a `proxy-cache` half.
116    ///
117    /// Consulted at policy-compile time so an invalidation request can find
118    /// every backend that holds entries for a pair `id` -- the same shape as
119    /// `reads_response_body`: the node describes itself, the compiler
120    /// collects the answers, and there is no process-wide registry that would
121    /// have to survive hot-reloads.
122    fn cache_target(&self) -> Option<crate::traffic::CacheTarget> {
123        None
124    }
125}
126
127/// Every plugin type [`create_plugin`] can build, for save-time validation of
128/// references to plugin types (e.g. a shared config's `type`). Guarded against
129/// drift from the factory's match arms by `test_known_plugin_types_matches_factory`.
130pub const KNOWN_PLUGIN_TYPES: &[&str] = &[
131    "proxy-rewrite",
132    "upstream",
133    "aws-lambda",
134    "azure-functions",
135    "openwhisk",
136    "openfunction",
137    "error-handler",
138    "listener",
139    "client",
140    "cors",
141    "rate-limit",
142    "limit-conn",
143    "api-breaker",
144    "proxy-cache",
145    "limit-count",
146    "proxy-mirror",
147    "ip-restriction",
148    "consumer-restriction",
149    "acl",
150    "attach-consumer-label",
151    "ua-restriction",
152    "referer-restriction",
153    "uri-blocker",
154    "csrf",
155    "request-size-limit",
156    "key-auth",
157    "basic-auth",
158    "jwt-auth",
159    "hmac-auth",
160    "jwe-decrypt",
161    "multi-auth",
162    "forward-auth",
163    "opa",
164    "opentelemetry",
165    "zipkin",
166    "skywalking",
167    "prometheus",
168    "ldap-auth",
169    "wolf-rbac",
170    "cas-auth",
171    "authz-casbin",
172    "authz-keycloak",
173    "authz-casdoor",
174    "openid-connect",
175    "dingtalk-auth",
176    "feishu-auth",
177    "logging",
178    "http-logger",
179    "loki-logger",
180    "splunk-hec-logging",
181    "datadog",
182    "loggly",
183    "tcp-logger",
184    "udp-logger",
185    "syslog",
186    "file-logger",
187    "error-log-logger",
188    "google-cloud-logging",
189    "skywalking-logger",
190    "elasticsearch-logger",
191    "clickhouse-logger",
192    "sls-logger",
193    "tencent-cloud-cls",
194    "lago",
195    "request-id",
196    "real-ip",
197    "redirect",
198    "echo",
199    "fault-injection",
200    "workflow",
201    "condition",
202    "traffic-label",
203    "set-vars",
204    "traffic-split",
205    "mocking",
206    "response-rewrite",
207    "gzip",
208    "brotli",
209    "error-page",
210    "exit-transformer",
211    "data-mask",
212    "request-validation",
213    "body-transformer",
214    "degraphql",
215    "oas-validator",
216    "serverless-pre-function",
217    "serverless-post-function",
218    "script",
219    "store-get",
220    "store-set",
221    "store-delete",
222    "store-incr",
223];
224
225/// Creates a plugin instance from a node type string and its YAML-derived config.
226///
227/// This is the single factory for all node types: the graph compiler calls it
228/// for every node in a policy, so adding a new node type means adding exactly
229/// one match arm here. `resources` hands plugins process-wide services
230/// ([`PluginResources`]); constructors that need them take the handle in
231/// `from_config`. Returns an error string for unknown node types or when
232/// a plugin's `from_config` rejects its configuration (surfaced at config load,
233/// not at request time).
234pub fn create_plugin(
235    node_type: &str,
236    config: &HashMap<String, serde_json::Value>,
237    resources: &Arc<PluginResources>,
238) -> Result<Box<dyn Plugin>, String> {
239    match node_type {
240        "proxy-rewrite" => Ok(Box::new(
241            native::proxy_rewrite::ProxyRewritePlugin::from_config(config)?,
242        )),
243        "upstream" => Ok(Box::new(native::upstream::UpstreamPlugin::from_config(
244            config, resources,
245        )?)),
246        "aws-lambda" => Ok(Box::new(native::aws_lambda::AwsLambdaPlugin::from_config(
247            config, resources,
248        )?)),
249        "azure-functions" => Ok(Box::new(
250            native::azure_functions::AzureFunctionsPlugin::from_config(config, resources)?,
251        )),
252        "openwhisk" => Ok(Box::new(native::openwhisk::OpenWhiskPlugin::from_config(
253            config, resources,
254        )?)),
255        "openfunction" => Ok(Box::new(
256            native::openfunction::OpenFunctionPlugin::from_config(config, resources)?,
257        )),
258        "error-handler" => Ok(Box::new(
259            native::error_handler::ErrorHandlerPlugin::from_config(config)?,
260        )),
261        "listener" => Ok(Box::new(native::listener::ListenerPlugin)),
262        "client" => Ok(Box::new(native::client::ClientPlugin)),
263        "cors" => Ok(Box::new(native::cors::CorsPlugin::from_config(config)?)),
264        "rate-limit" => Ok(Box::new(native::rate_limit::RateLimitPlugin::from_config(
265            config,
266        )?)),
267        "limit-conn" => Ok(Box::new(native::limit_conn::LimitConnPlugin::from_config(
268            config, resources,
269        )?)),
270        "api-breaker" => Ok(Box::new(
271            native::api_breaker::ApiBreakerPlugin::from_config(config, resources)?,
272        )),
273        "proxy-cache" => Ok(Box::new(
274            native::proxy_cache::ProxyCachePlugin::from_config(config, resources)?,
275        )),
276        "limit-count" => Ok(Box::new(
277            native::limit_count::LimitCountPlugin::from_config(config, resources)?,
278        )),
279        "proxy-mirror" => Ok(Box::new(
280            native::proxy_mirror::ProxyMirrorPlugin::from_config(config, resources)?,
281        )),
282        "ip-restriction" => Ok(Box::new(
283            native::ip_restriction::IpRestrictionPlugin::from_config(config)?,
284        )),
285        "consumer-restriction" => Ok(Box::new(
286            native::consumer_restriction::ConsumerRestrictionPlugin::from_config(config)?,
287        )),
288        "acl" => Ok(Box::new(native::acl::AclPlugin::from_config(config)?)),
289        "attach-consumer-label" => Ok(Box::new(
290            native::attach_consumer_label::AttachConsumerLabelPlugin::from_config(config)?,
291        )),
292        "ua-restriction" => Ok(Box::new(
293            native::ua_restriction::UaRestrictionPlugin::from_config(config)?,
294        )),
295        "referer-restriction" => Ok(Box::new(
296            native::referer_restriction::RefererRestrictionPlugin::from_config(config)?,
297        )),
298        "uri-blocker" => Ok(Box::new(
299            native::uri_blocker::UriBlockerPlugin::from_config(config)?,
300        )),
301        "csrf" => Ok(Box::new(native::csrf::CsrfPlugin::from_config(config)?)),
302        "request-size-limit" => Ok(Box::new(
303            native::request_size_limit::RequestSizeLimitPlugin::from_config(config)?,
304        )),
305        "key-auth" => Ok(Box::new(native::key_auth::KeyAuthPlugin::from_config(
306            config, resources,
307        )?)),
308        "basic-auth" => Ok(Box::new(native::basic_auth::BasicAuthPlugin::from_config(
309            config, resources,
310        )?)),
311        "jwt-auth" => Ok(Box::new(native::jwt_auth::JwtAuthPlugin::from_config(
312            config, resources,
313        )?)),
314        "hmac-auth" => Ok(Box::new(native::hmac_auth::HmacAuthPlugin::from_config(
315            config, resources,
316        )?)),
317        "jwe-decrypt" => Ok(Box::new(
318            native::jwe_decrypt::JweDecryptPlugin::from_config(config, resources)?,
319        )),
320        "multi-auth" => Ok(Box::new(native::multi_auth::MultiAuthPlugin::from_config(
321            config, resources,
322        )?)),
323        "forward-auth" => Ok(Box::new(
324            native::forward_auth::ForwardAuthPlugin::from_config(config, resources)?,
325        )),
326        "opa" => Ok(Box::new(native::opa::OpaPlugin::from_config(
327            config, resources,
328        )?)),
329        "opentelemetry" => Ok(Box::new(
330            native::opentelemetry::OpenTelemetryPlugin::from_config(config, resources)?,
331        )),
332        "zipkin" => Ok(Box::new(native::zipkin::ZipkinPlugin::from_config(
333            config, resources,
334        )?)),
335        "skywalking" => Ok(Box::new(native::skywalking::SkywalkingPlugin::from_config(
336            config, resources,
337        )?)),
338        "prometheus" => Ok(Box::new(native::prometheus::PrometheusPlugin::from_config(
339            config, resources,
340        )?)),
341        "ldap-auth" => Ok(Box::new(native::ldap_auth::LdapAuthPlugin::from_config(
342            config, resources,
343        )?)),
344        "wolf-rbac" => Ok(Box::new(native::wolf_rbac::WolfRbacPlugin::from_config(
345            config, resources,
346        )?)),
347        "cas-auth" => Ok(Box::new(native::cas_auth::CasAuthPlugin::from_config(
348            config, resources,
349        )?)),
350        "authz-casbin" => Ok(Box::new(
351            native::authz_casbin::AuthzCasbinPlugin::from_config(config, resources)?,
352        )),
353        "authz-keycloak" => Ok(Box::new(
354            native::authz_keycloak::AuthzKeycloakPlugin::from_config(config, resources)?,
355        )),
356        "authz-casdoor" => Ok(Box::new(
357            native::authz_casdoor::AuthzCasdoorPlugin::from_config(config, resources)?,
358        )),
359        "openid-connect" => Ok(Box::new(
360            native::openid_connect::OpenidConnectPlugin::from_config(config, resources)?,
361        )),
362        "dingtalk-auth" => Ok(Box::new(
363            native::dingtalk_auth::DingtalkAuthPlugin::from_config(config, resources)?,
364        )),
365        "feishu-auth" => Ok(Box::new(
366            native::feishu_auth::FeishuAuthPlugin::from_config(config, resources)?,
367        )),
368        "logging" => Ok(Box::new(native::logging::LoggingPlugin::from_config(
369            config,
370        )?)),
371        "http-logger" => Ok(Box::new(
372            native::http_logger::HttpLoggerPlugin::from_config(config, resources)?,
373        )),
374        "loki-logger" => Ok(Box::new(
375            native::loki_logger::LokiLoggerPlugin::from_config(config, resources)?,
376        )),
377        "splunk-hec-logging" => Ok(Box::new(
378            native::splunk_hec_logging::SplunkHecLoggingPlugin::from_config(config, resources)?,
379        )),
380        "datadog" => Ok(Box::new(native::datadog::DatadogPlugin::from_config(
381            config, resources,
382        )?)),
383        "loggly" => Ok(Box::new(native::loggly::LogglyPlugin::from_config(
384            config, resources,
385        )?)),
386        "tcp-logger" => Ok(Box::new(native::tcp_logger::TcpLoggerPlugin::from_config(
387            config,
388        )?)),
389        "udp-logger" => Ok(Box::new(native::udp_logger::UdpLoggerPlugin::from_config(
390            config,
391        )?)),
392        "syslog" => Ok(Box::new(native::syslog::SyslogPlugin::from_config(config)?)),
393        "file-logger" => Ok(Box::new(
394            native::file_logger::FileLoggerPlugin::from_config(config)?,
395        )),
396        "error-log-logger" => Ok(Box::new(
397            native::error_log_logger::ErrorLogLoggerPlugin::from_config(config)?,
398        )),
399        "google-cloud-logging" => Ok(Box::new(
400            native::google_cloud_logging::GoogleCloudLoggingPlugin::from_config(config, resources)?,
401        )),
402        "skywalking-logger" => Ok(Box::new(
403            native::skywalking_logger::SkywalkingLoggerPlugin::from_config(config, resources)?,
404        )),
405        "elasticsearch-logger" => Ok(Box::new(
406            native::elasticsearch_logger::ElasticsearchLoggerPlugin::from_config(
407                config, resources,
408            )?,
409        )),
410        "clickhouse-logger" => Ok(Box::new(
411            native::clickhouse_logger::ClickhouseLoggerPlugin::from_config(config, resources)?,
412        )),
413        "sls-logger" => Ok(Box::new(native::sls_logger::SlsLoggerPlugin::from_config(
414            config, resources,
415        )?)),
416        "tencent-cloud-cls" => Ok(Box::new(
417            native::tencent_cloud_cls::TencentCloudClsPlugin::from_config(config, resources)?,
418        )),
419        "lago" => Ok(Box::new(native::lago::LagoPlugin::from_config(
420            config, resources,
421        )?)),
422        "request-id" => Ok(Box::new(native::request_id::RequestIdPlugin::from_config(
423            config,
424        )?)),
425        "real-ip" => Ok(Box::new(native::real_ip::RealIpPlugin::from_config(
426            config,
427        )?)),
428        "redirect" => Ok(Box::new(native::redirect::RedirectPlugin::from_config(
429            config,
430        )?)),
431        "echo" => Ok(Box::new(native::echo::EchoPlugin::from_config(config)?)),
432        "fault-injection" => Ok(Box::new(
433            native::fault_injection::FaultInjectionPlugin::from_config(config)?,
434        )),
435        "workflow" => Ok(Box::new(native::workflow::WorkflowPlugin::from_config(
436            config, resources,
437        )?)),
438        "traffic-label" => Ok(Box::new(
439            native::traffic_label::TrafficLabelPlugin::from_config(config)?,
440        )),
441        "set-vars" => Ok(Box::new(native::set_vars::SetVarsPlugin::from_config(
442            config,
443        )?)),
444        "traffic-split" => Ok(Box::new(
445            native::traffic_split::TrafficSplitPlugin::from_config(config, resources)?,
446        )),
447        "mocking" => Ok(Box::new(native::mocking::MockingPlugin::from_config(
448            config,
449        )?)),
450        "response-rewrite" => Ok(Box::new(
451            native::response_rewrite::ResponseRewritePlugin::from_config(config)?,
452        )),
453        "gzip" => Ok(Box::new(native::gzip::GzipPlugin::from_config(config)?)),
454        "brotli" => Ok(Box::new(native::brotli::BrotliPlugin::from_config(config)?)),
455        "error-page" => Ok(Box::new(native::error_page::ErrorPagePlugin::from_config(
456            config,
457        )?)),
458        "exit-transformer" => Ok(Box::new(
459            native::exit_transformer::ExitTransformerPlugin::from_config(config)?,
460        )),
461        "data-mask" => Ok(Box::new(native::data_mask::DataMaskPlugin::from_config(
462            config,
463        )?)),
464        "request-validation" => Ok(Box::new(
465            native::request_validation::RequestValidationPlugin::from_config(config)?,
466        )),
467        "condition" => Ok(Box::new(native::condition::ConditionPlugin::from_config(
468            config,
469        )?)),
470        "body-transformer" => Ok(Box::new(
471            native::body_transformer::BodyTransformerPlugin::from_config(config)?,
472        )),
473        "degraphql" => Ok(Box::new(native::degraphql::DegraphqlPlugin::from_config(
474            config,
475        )?)),
476        "oas-validator" => Ok(Box::new(
477            native::oas_validator::OasValidatorPlugin::from_config(config)?,
478        )),
479        "serverless-pre-function" => Ok(Box::new(
480            native::serverless_pre_function::ServerlessPreFunctionPlugin::from_config(config)?,
481        )),
482        "serverless-post-function" => Ok(Box::new(
483            native::serverless_post_function::ServerlessPostFunctionPlugin::from_config(config)?,
484        )),
485        "script" => Ok(Box::new(script::ScriptPlugin::from_config(config)?)),
486        "store-get" => Ok(Box::new(native::store_get::StoreGetPlugin::from_config(
487            config, resources,
488        )?)),
489        "store-set" => Ok(Box::new(native::store_set::StoreSetPlugin::from_config(
490            config, resources,
491        )?)),
492        "store-delete" => Ok(Box::new(
493            native::store_delete::StoreDeletePlugin::from_config(config, resources)?,
494        )),
495        "store-incr" => Ok(Box::new(native::store_incr::StoreIncrPlugin::from_config(
496            config, resources,
497        )?)),
498        _ => Err(format!("Unknown plugin type: {}", node_type)),
499    }
500}
501
502/// Static port declaration for a node type. `None` for unknown types.
503///
504/// This match is the port registry: sweep tasks add arms here as plugins
505/// gain outcome ports. Keep in sync with `KNOWN_PLUGIN_TYPES`
506/// (enforced by `test_every_known_type_has_a_valid_spec`).
507pub fn port_spec(plugin_type: &str) -> Option<&'static PortSpec> {
508    match plugin_type {
509        "listener" => Some(&ports::LISTENER_SPEC),
510        "client" => Some(&ports::CLIENT_SPEC),
511        "cors" => Some(&ports::CORS_SPEC),
512        "redirect" => Some(&ports::REDIRECT_SPEC),
513        "fault-injection" => Some(&ports::FAULT_INJECTION_SPEC),
514        "script" => Some(&ports::SCRIPT_SPEC),
515        "key-auth" | "basic-auth" | "jwt-auth" | "hmac-auth" | "jwe-decrypt" | "multi-auth"
516        | "ldap-auth" | "forward-auth" | "opa" | "wolf-rbac" => Some(&ports::AUTH_SPEC),
517        "cas-auth" | "openid-connect" | "authz-casdoor" | "dingtalk-auth" | "feishu-auth" => {
518            Some(&ports::INTERACTIVE_AUTH_SPEC)
519        }
520        "authz-casbin" | "authz-keycloak" => Some(&ports::AUTH_SPEC),
521        "acl"
522        | "ip-restriction"
523        | "ua-restriction"
524        | "referer-restriction"
525        | "consumer-restriction"
526        | "uri-blocker"
527        | "csrf"
528        | "request-size-limit"
529        | "request-validation"
530        | "oas-validator" => Some(&ports::DENY_SPEC),
531        "rate-limit" | "limit-conn" | "limit-count" => Some(&ports::LIMIT_SPEC),
532        "api-breaker" => Some(&ports::BREAKER_SPEC),
533        "workflow" => Some(&ports::WORKFLOW_SPEC),
534        "condition" => Some(&ports::CONDITION_SPEC),
535        "traffic-split" => Some(&ports::TRAFFIC_SPLIT_SPEC),
536        "proxy-cache" => Some(&ports::PROXY_CACHE_SPEC),
537        "store-get" => Some(&ports::STORE_GET_SPEC),
538        _ if KNOWN_PLUGIN_TYPES.contains(&plugin_type) => Some(&ports::DEFAULT_SPEC),
539        _ => None,
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    /// KNOWN_PLUGIN_TYPES must track create_plugin's match arms exactly, in
548    /// both directions. Same source-parsing guard the admin catalog uses.
549    #[test]
550    fn test_known_plugin_types_matches_factory() {
551        let factory: std::collections::BTreeSet<String> = include_str!("mod.rs")
552            .lines()
553            .filter_map(|line| {
554                let line = line.trim();
555                let rest = line.strip_prefix('"')?;
556                let (name, tail) = rest.split_once('"')?;
557                tail.trim_start()
558                    .starts_with("=>")
559                    .then(|| name.to_string())
560            })
561            .collect();
562        let listed: std::collections::BTreeSet<String> =
563            KNOWN_PLUGIN_TYPES.iter().map(|s| s.to_string()).collect();
564        assert_eq!(
565            listed, factory,
566            "KNOWN_PLUGIN_TYPES drifted from create_plugin"
567        );
568    }
569}