Skip to main content

pingap_core/
plugin.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{Ctx, HttpResponse};
16use ahash::AHashMap;
17use async_trait::async_trait;
18use pingora::http::ResponseHeader;
19use pingora::proxy::Session;
20use std::borrow::Cow;
21use std::sync::Arc;
22use strum::EnumString;
23
24#[derive(
25    PartialEq, Debug, Default, Clone, Copy, EnumString, strum::Display,
26)]
27#[strum(serialize_all = "snake_case")]
28pub enum PluginStep {
29    EarlyRequest,
30    #[default]
31    Request,
32    ProxyUpstream,
33    UpstreamResponse,
34    Response,
35}
36
37/// A more expressive return type for `handle_request`.
38/// It clearly states the plugin's decision.
39#[derive(Debug)]
40pub enum RequestPluginResult {
41    /// The plugin did not run or took no action.
42    Skipped,
43    /// The plugin ran and modified the request; processing should continue.
44    Continue,
45    /// The plugin has decided to terminate the request and send an immediate response.
46    Respond(HttpResponse),
47}
48
49/// Represents the action a plugin takes on a response.
50#[derive(Debug, PartialEq, Eq)]
51pub enum ResponsePluginResult {
52    /// The plugin did not change the response.
53    Unchanged,
54    /// The plugin modified the response (e.g., headers or body).
55    Modified,
56    // TODO
57    // FullyReplaced(HttpResponse),
58}
59
60// Represents the action a plugin task on a response
61#[derive(Debug, PartialEq, Eq)]
62pub enum ResponseBodyPluginResult {
63    /// The plugin did not modify the response body.
64    Unchanged,
65    /// The plugin partially replaced the response body.
66    PartialReplaced,
67    /// The plugin fully replaced the response body.
68    FullyReplaced,
69}
70
71// Manually implement the PartialEq trait for RequestPluginResult
72impl PartialEq for RequestPluginResult {
73    fn eq(&self, other: &Self) -> bool {
74        match (self, other) {
75            // Two Skipped variants are always equal.
76            (RequestPluginResult::Skipped, RequestPluginResult::Skipped) => {
77                true
78            },
79
80            // Two Continue variants are always equal.
81            (RequestPluginResult::Continue, RequestPluginResult::Continue) => {
82                true
83            },
84
85            // Any other combination is not equal.
86            _ => false,
87        }
88    }
89}
90
91/// Core trait that defines the interface all plugins must implement.
92///
93/// Plugins can handle both requests and responses at different processing steps.
94/// The default implementations do nothing and return Ok.
95#[async_trait]
96pub trait Plugin: Sync + Send {
97    /// Returns a unique key that identifies this specific plugin instance.
98    ///
99    /// # Purpose
100    /// - Can be used for caching plugin results
101    /// - Helps differentiate between multiple instances of the same plugin type
102    /// - Useful for tracking and debugging
103    ///
104    /// # Default
105    /// Returns an empty string by default, which means no specific instance identification.
106    fn config_key(&self) -> Cow<'_, str> {
107        Cow::Borrowed("")
108    }
109
110    /// Processes an HTTP request at a specified lifecycle step.
111    ///
112    /// # Parameters
113    /// * `_step` - Current processing step in the request lifecycle (e.g., pre-routing, post-routing)
114    /// * `_session` - Mutable reference to the HTTP session containing request data
115    /// * `_ctx` - Mutable reference to the request context for storing state
116    ///
117    /// # Returns
118    /// * `Ok(result)` where:
119    ///   * `result` - The result of the plugin's action on the request
120    ///     - `Skipped`: Plugin did not run or took no action
121    ///     - `Continue`: Plugin ran and modified the request; processing should continue
122    ///     - `Respond(response)`: Plugin has decided to terminate the request and send an immediate response
123    ///   * `response` - Optional HTTP response:
124    ///     - `Some(response)`: Terminates request processing and returns this response to client
125    ///     - `None`: Allows request to continue to next plugin or upstream
126    /// * `Err` - Returns error if plugin processing failed
127    #[inline]
128    async fn handle_request(
129        &self,
130        _step: PluginStep,
131        _session: &mut Session,
132        _ctx: &mut Ctx,
133    ) -> pingora::Result<RequestPluginResult> {
134        Ok(RequestPluginResult::Skipped)
135    }
136
137    /// Processes an HTTP response at a specified lifecycle step.
138    ///
139    /// # Parameters
140    /// * `_session` - Mutable reference to the HTTP session
141    /// * `_ctx` - Mutable reference to the request context
142    /// * `_upstream_response` - Mutable reference to the upstream response header
143    ///
144    /// # Returns
145    /// * `Ok(result)` - The result of the plugin's action on the response
146    ///   - `Unchanged`: Plugin did not modify the response
147    ///   - `Modified`: Plugin modified the response in some way
148    /// * `Err` - Returns error if plugin processing failed
149    #[inline]
150    async fn handle_response(
151        &self,
152        _session: &mut Session,
153        _ctx: &mut Ctx,
154        _upstream_response: &mut ResponseHeader,
155    ) -> pingora::Result<ResponsePluginResult> {
156        Ok(ResponsePluginResult::Unchanged)
157    }
158
159    /// Whether `handle_response` is also to see the response that another
160    /// plugin answered the request with.
161    ///
162    /// Such a response is sent from the request step and never reaches the
163    /// response step. Only for a plugin whose `handle_response` does
164    /// nothing but set headers: no body handler runs for that response, and
165    /// its length is fixed.
166    #[inline]
167    fn handles_plugin_response(&self) -> bool {
168        false
169    }
170
171    /// The page this plugin has for an error the proxy answers itself -
172    /// no upstream to be had, a request refused by a limit - in place of
173    /// the one of the server: its content type and its body. `None`
174    /// from a plugin that has none, which is every plugin but
175    /// `error_page`, or none for this status.
176    ///
177    /// Asked of the plugins of the location whichever of them have run:
178    /// the error may be one that stopped the request before this plugin
179    /// had its turn.
180    #[inline]
181    fn error_page(
182        &self,
183        _session: &Session,
184        _status: http::StatusCode,
185        _message: &str,
186    ) -> Option<(http::HeaderValue, bytes::Bytes)> {
187        None
188    }
189
190    /// Processes an HTTP response body at a specified lifecycle step.
191    ///
192    /// # Parameters
193    /// * `_session` - Mutable reference to the HTTP session
194    /// * `_ctx` - Mutable reference to the request context
195    /// * `_body` - Mutable reference to the response body
196    /// * `_end_of_stream` - Boolean flag:
197    ///   - `true`: The end of the response body has been reached
198    ///   - `false`: The response body is still being received
199    ///
200    /// # Returns
201    /// * `Ok(result)` - The result of the plugin's action on the response body
202    ///   - `Unchanged`: Plugin did not modify the response body
203    ///   - `PartialReplaced(new_body)`: Plugin replaced a part of the response body
204    ///   - `FullyReplaced(new_body)`: Plugin replaced the response body with a new one
205    /// * `Err` - Returns error if plugin processing failed
206    #[inline]
207    fn handle_response_body(
208        &self,
209        _session: &mut Session,
210        _ctx: &mut Ctx,
211        _body: &mut Option<bytes::Bytes>,
212        _end_of_stream: bool,
213    ) -> pingora::Result<ResponseBodyPluginResult> {
214        Ok(ResponseBodyPluginResult::Unchanged)
215    }
216
217    /// Processes an upstream response at a specified lifecycle step.
218    ///
219    /// # Parameters
220    /// * `_session` - Mutable reference to the HTTP session
221    /// * `_ctx` - Mutable reference to the request context
222    /// * `_upstream_response` - Mutable reference to the upstream response header
223    ///
224    /// # Returns
225    /// * `Ok(result)` - The result of the plugin's action on the response
226    ///   - `Unchanged`: Plugin did not modify the response
227    ///   - `Modified`: Plugin modified the response in some way
228    /// * `Err` - Returns error if plugin processing failed
229    #[inline]
230    fn handle_upstream_response(
231        &self,
232        _session: &mut Session,
233        _ctx: &mut Ctx,
234        _upstream_response: &mut ResponseHeader,
235    ) -> pingora::Result<ResponsePluginResult> {
236        Ok(ResponsePluginResult::Unchanged)
237    }
238
239    /// Processes an upstream response body at a specified lifecycle step.
240    ///
241    /// # Parameters
242    /// * `_session` - Mutable reference to the HTTP session
243    /// * `_ctx` - Mutable reference to the request context
244    /// * `_body` - Mutable reference to the upstream response body
245    /// * `_end_of_stream` - Boolean flag:
246    ///   - `true`: The end of the upstream response body has been reached
247    ///   - `false`: The upstream response body is still being received
248    ///
249    /// # Returns
250    /// * `Ok(result)` - The result of the plugin's action on the response body
251    ///   - `Unchanged`: Plugin did not modify the response body
252    ///   - `PartialReplaced(new_body)`: Plugin replaced a part of the response body
253    ///   - `FullyReplaced(new_body)`: Plugin replaced the response body with a new one
254    /// * `Err` - Returns error if plugin processing failed
255    #[inline]
256    fn handle_upstream_response_body(
257        &self,
258        _session: &mut Session,
259        _ctx: &mut Ctx,
260        _body: &mut Option<bytes::Bytes>,
261        _end_of_stream: bool,
262    ) -> pingora::Result<ResponseBodyPluginResult> {
263        Ok(ResponseBodyPluginResult::Unchanged)
264    }
265}
266
267/// Takes the place of a plugin that a location names but that is not loaded:
268/// the name is misspelled, or the plugin failed to build. It fails every
269/// request of that location.
270///
271/// Leaving the name out instead served the location without the plugin, and
272/// when that plugin is the authentication or the access list, a broken
273/// config turned into an open door.
274pub struct MissingPlugin {
275    name: Arc<str>,
276}
277
278impl MissingPlugin {
279    pub fn new(name: Arc<str>) -> Self {
280        Self { name }
281    }
282}
283
284#[async_trait]
285impl Plugin for MissingPlugin {
286    async fn handle_request(
287        &self,
288        _step: PluginStep,
289        _session: &mut Session,
290        _ctx: &mut Ctx,
291    ) -> pingora::Result<RequestPluginResult> {
292        Err(crate::new_internal_error(
293            500,
294            format!("plugin {} is not available", self.name),
295        ))
296    }
297}
298
299/// Plugin provider trait
300pub trait PluginProvider: Send + Sync {
301    /// Get a plugin by name
302    ///
303    /// # Arguments
304    /// * `name` - The name of the plugin to get
305    ///
306    /// # Returns
307    /// * `Option<Arc<dyn Plugin>>` - The plugin if found, None otherwise
308    fn get(&self, name: &str) -> Option<Arc<dyn Plugin>>;
309
310    /// A number that changes whenever `get` could start returning different
311    /// plugins - after a plugin reload. Locations cache the plugins they
312    /// resolved through `get` and rebuild the list when this moves, so a
313    /// provider whose plugins can change must implement it.
314    fn version(&self) -> u64 {
315        0
316    }
317}
318
319pub type Plugins = AHashMap<String, Arc<dyn Plugin>>;
320
321/// Lets the plugins that ask for it set their headers on a response a
322/// plugin is about to write itself.
323///
324/// A response returned as [`RequestPluginResult::Respond`] gets this from
325/// the proxy. One that a plugin streams to the client on its own - a file
326/// larger than a chunk - never passes there, so it calls this before it
327/// writes the header: without it a small file came with the CORS headers
328/// and a large one did not.
329pub async fn decorate_plugin_response(
330    session: &mut Session,
331    ctx: &mut Ctx,
332    header: &mut ResponseHeader,
333) -> pingora::Result<()> {
334    let Some(plugins) = ctx.response_plugins.take() else {
335        return Ok(());
336    };
337    let mut result = Ok(());
338    for (_, plugin) in plugins.iter() {
339        if !plugin.handles_plugin_response() {
340            continue;
341        }
342        if let Err(e) = plugin.handle_response(session, ctx, header).await {
343            result = Err(e);
344            break;
345        }
346    }
347    ctx.response_plugins = Some(plugins);
348    result
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::NamedPlugin;
355    use pretty_assertions::assert_eq;
356
357    struct Marker {
358        asks: bool,
359    }
360
361    #[async_trait]
362    impl Plugin for Marker {
363        fn handles_plugin_response(&self) -> bool {
364            self.asks
365        }
366        async fn handle_response(
367            &self,
368            _session: &mut Session,
369            _ctx: &mut Ctx,
370            upstream_response: &mut ResponseHeader,
371        ) -> pingora::Result<ResponsePluginResult> {
372            upstream_response.append_header("x-marked", "1")?;
373            Ok(ResponsePluginResult::Modified)
374        }
375    }
376
377    /// Only the plugins that ask for the responses of other plugins see
378    /// one, and the list stays in the context for the next.
379    #[tokio::test]
380    async fn test_decorate_plugin_response() {
381        let mut session = crate::new_test_session(&[], "/").await;
382        let marked = |header: &ResponseHeader| {
383            header.headers.get_all("x-marked").iter().count()
384        };
385
386        // Nothing to ask.
387        let mut ctx = Ctx::default();
388        let mut header = ResponseHeader::build(200, None).unwrap();
389        decorate_plugin_response(&mut session, &mut ctx, &mut header)
390            .await
391            .unwrap();
392        assert_eq!(0, marked(&header));
393
394        let plugins: Vec<NamedPlugin> = vec![
395            (Arc::from("asks"), Arc::new(Marker { asks: true })),
396            (Arc::from("does-not"), Arc::new(Marker { asks: false })),
397        ];
398        ctx.response_plugins = Some(Arc::from(plugins));
399        decorate_plugin_response(&mut session, &mut ctx, &mut header)
400            .await
401            .unwrap();
402        assert_eq!(1, marked(&header));
403        assert_eq!(true, ctx.response_plugins.is_some());
404    }
405
406    #[test]
407    fn test_plugin_step() {
408        let step = "early_request".parse::<PluginStep>().unwrap();
409        assert_eq!(step, PluginStep::EarlyRequest);
410        assert_eq!(step.to_string(), "early_request");
411
412        let step = "request".parse::<PluginStep>().unwrap();
413        assert_eq!(step, PluginStep::Request);
414        assert_eq!(step.to_string(), "request");
415
416        let step = "proxy_upstream".parse::<PluginStep>().unwrap();
417        assert_eq!(step, PluginStep::ProxyUpstream);
418        assert_eq!(step.to_string(), "proxy_upstream");
419
420        let step = "response".parse::<PluginStep>().unwrap();
421        assert_eq!(step, PluginStep::Response);
422        assert_eq!(step.to_string(), "response");
423    }
424
425    #[test]
426    fn test_request_plugin_result() {
427        let skip1 = RequestPluginResult::Skipped;
428        let skip2 = RequestPluginResult::Skipped;
429        assert_eq!(true, skip1 == skip2);
430
431        let continue1 = RequestPluginResult::Continue;
432        let continue2 = RequestPluginResult::Continue;
433        assert_eq!(true, continue1 == continue2);
434
435        let respond1 = RequestPluginResult::Respond(HttpResponse::no_content());
436        let respond2 = RequestPluginResult::Respond(HttpResponse::no_content());
437        assert_eq!(false, respond1 == respond2);
438    }
439}