Skip to main content

pingap_plugin/
lib.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 humantime::parse_duration;
16use pingap_config::PluginConf;
17use pingap_core::PluginStep;
18use snafu::Snafu;
19use std::cell::RefCell;
20use std::fmt::Write;
21use std::str::FromStr;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::time::Duration;
24
25#[derive(Debug, Snafu)]
26pub enum Error {
27    #[snafu(display("Plugin {category} invalid, message: {message}"))]
28    Invalid { category: String, message: String },
29    #[snafu(display("Plugin {category} not found"))]
30    NotFound { category: String },
31    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
32    Base64Decode {
33        category: String,
34        source: base64::DecodeError,
35    },
36    #[snafu(display("Plugin {category}, exceed limit {value}/{max}"))]
37    Exceed {
38        category: String,
39        max: f64,
40        value: f64,
41    },
42    #[snafu(display("Plugin {category}, regex error {source}"))]
43    Regex {
44        category: String,
45        source: Box<fancy_regex::Error>,
46    },
47    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
48    ParseDuration {
49        category: String,
50        source: humantime::DurationError,
51    },
52}
53
54thread_local! {
55    /// The config values of the plugin being built that were not of the
56    /// type their key takes. `None` when no plugin is being built.
57    static WRONG_TYPES: RefCell<Option<Vec<String>>> =
58        const { RefCell::new(None) };
59}
60
61/// Notes that the value of `key` is not `expected`.
62///
63/// The getters below answer a value of the wrong type with the default:
64/// `max = "100"` was a limit of 0, `encodings = ["gzip"]` no encoding at
65/// all, a number in a list of keys a key that is not there. Nothing said
66/// so. They still answer the default, and [`build_plugin`] turns the note
67/// into the error of the plugin that is being built.
68fn note_wrong_type(key: &str, expected: &str, value: &toml::Value) {
69    WRONG_TYPES.with_borrow_mut(|notes| {
70        if let Some(notes) = notes {
71            // The value is what tells a typo apart, but not when it is a
72            // credential: this note ends up in the log and in the
73            // notification of a failed reload.
74            let shown = if pingap_config::is_secret_key(key) {
75                String::new()
76            } else {
77                format!(" {value}")
78            };
79            notes.push(format!(
80                "{key} must be {expected}, got {}{shown}",
81                value.type_str()
82            ));
83        }
84    });
85}
86
87/// The error [`build_plugin`] is going to give for the values of the wrong
88/// type noted so far, for a constructor that has something to hold back
89/// until its configuration is known to be good.
90pub(crate) fn wrong_types_error(category: &str) -> Option<Error> {
91    WRONG_TYPES.with_borrow(|notes| {
92        let notes = notes.as_ref().filter(|notes| !notes.is_empty())?;
93        Some(Error::Invalid {
94            category: category.to_string(),
95            message: notes.join(", "),
96        })
97    })
98}
99
100/// Builds one plugin with `build`, and fails when a config value it read
101/// was not of the type its key takes.
102///
103/// Building a plugin is one synchronous call, which is what lets the notes
104/// be kept per thread instead of being handed through every getter.
105pub(crate) fn build_plugin<T>(
106    category: &str,
107    build: impl FnOnce() -> Result<T, Error>,
108) -> Result<T, Error> {
109    /// Puts back what was there, on a panic too.
110    struct Restore(Option<Vec<String>>);
111    impl Drop for Restore {
112        fn drop(&mut self) {
113            WRONG_TYPES.set(self.0.take());
114        }
115    }
116    let restore = Restore(WRONG_TYPES.replace(Some(vec![])));
117    let result = build();
118    let notes = WRONG_TYPES.take().unwrap_or_default();
119    drop(restore);
120    // The plugin's own complaint comes first: it is the more specific.
121    let plugin = result?;
122    if notes.is_empty() {
123        return Ok(plugin);
124    }
125    Err(Error::Invalid {
126        category: category.to_string(),
127        message: notes.join(", "),
128    })
129}
130
131/// Helper functions for accessing plugin configuration values
132pub fn get_str_conf(value: &PluginConf, key: &str) -> String {
133    match value.get(key) {
134        Some(toml::Value::String(item)) => item.clone(),
135        Some(other) => {
136            note_wrong_type(key, "a string", other);
137            String::new()
138        },
139        None => String::new(),
140    }
141}
142
143/// Helper functions for accessing plugin configuration values
144pub fn get_duration_conf(value: &PluginConf, key: &str) -> Option<Duration> {
145    let item = value.get(key)?;
146    let duration = item.as_str().and_then(|s| parse_duration(s).ok());
147    if duration.is_none() {
148        note_wrong_type(key, "a duration such as \"10s\"", item);
149    }
150    duration
151}
152
153/// The name one plugin instance keeps its response body handler under.
154///
155/// The handlers of a request are kept by name, and the name used to be the
156/// plugin's category. Two plugins of one category on a location then shared
157/// a single slot: the second replaced the handler of the first, and both ran
158/// the one that was left, so its rules were applied twice and the other's
159/// not at all.
160pub fn new_body_handler_id(prefix: &str) -> String {
161    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
162    format!("{prefix}{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
163}
164
165/// A list of strings. One string on its own is a list of one: that is how
166/// a single value reads in KDL (`ip_list "1.2.3.4"`), and it used to count
167/// as no list at all, which left an allow or deny list empty without a
168/// word.
169pub fn get_str_slice_conf(value: &PluginConf, key: &str) -> Vec<String> {
170    match value.get(key) {
171        Some(toml::Value::Array(arr)) => arr
172            .iter()
173            .filter_map(|item| {
174                let text = item.as_str();
175                if text.is_none() {
176                    note_wrong_type(key, "a list of strings", item);
177                }
178                text
179            })
180            .map(String::from) // same as .map(|s| s.to_string())
181            .collect(),
182        // An empty string is an empty list, not a list of one empty item.
183        Some(toml::Value::String(item)) => {
184            if item.is_empty() {
185                vec![]
186            } else {
187                vec![item.clone()]
188            }
189        },
190        Some(other) => {
191            note_wrong_type(key, "a list of strings", other);
192            vec![]
193        },
194        None => vec![],
195    }
196}
197
198pub(crate) use pingap_core::decode_query_value;
199
200pub(crate) fn get_bool_conf(value: &PluginConf, key: &str) -> bool {
201    match value.get(key) {
202        Some(toml::Value::Boolean(item)) => *item,
203        Some(other) => {
204            note_wrong_type(key, "true or false", other);
205            false
206        },
207        None => false,
208    }
209}
210
211pub fn get_int_conf(value: &PluginConf, key: &str) -> i64 {
212    get_int_conf_or_default(value, key, 0)
213}
214
215pub fn get_int_conf_or_default(
216    value: &PluginConf,
217    key: &str,
218    default_value: i64,
219) -> i64 {
220    match value.get(key) {
221        Some(toml::Value::Integer(item)) => *item,
222        Some(other) => {
223            note_wrong_type(key, "an integer", other);
224            default_value
225        },
226        None => default_value,
227    }
228}
229
230pub fn get_step_conf(
231    value: &PluginConf,
232    default_value: PluginStep,
233) -> PluginStep {
234    value
235        .get("step")
236        .and_then(|v| v.as_str())
237        .and_then(|s| PluginStep::from_str(s).ok())
238        .unwrap_or(default_value)
239}
240
241/// Resolves `step`, rejecting a value the plugin does not implement.
242///
243/// `get_step_conf` falls back to the default for an unknown or unsupported
244/// value, which turns a misconfigured `step` into a plugin that quietly never
245/// runs. Plugins that implement only some of the steps should use this so the
246/// mistake surfaces at `pingap -t` instead.
247pub fn get_step_conf_in(
248    value: &PluginConf,
249    category: &str,
250    default_value: PluginStep,
251    allowed: &[PluginStep],
252) -> Result<PluginStep, Error> {
253    let Some(step) = value.get("step").and_then(|v| v.as_str()) else {
254        return Ok(default_value);
255    };
256    let invalid = || Error::Invalid {
257        category: category.to_string(),
258        message: format!(
259            "Invalid step({step}), expect one of: {}",
260            allowed
261                .iter()
262                .map(|item| item.to_string())
263                .collect::<Vec<_>>()
264                .join(", ")
265        ),
266    };
267    let step = PluginStep::from_str(step).map_err(|_| invalid())?;
268    if !allowed.contains(&step) {
269        return Err(invalid());
270    }
271    Ok(step)
272}
273
274/// Whether a restriction list is a whitelist or a blacklist.
275///
276/// Parsed rather than compared literally: `type` used to be tested against the
277/// string `deny`, so every other spelling — including `Deny` — silently selected
278/// allow mode and inverted the policy.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub(crate) enum RestrictionCategory {
281    #[default]
282    Allow,
283    Deny,
284}
285
286impl RestrictionCategory {
287    /// Given whether the request matched the configured list, returns whether
288    /// it is allowed through.
289    #[inline]
290    pub(crate) fn allows(&self, found: bool) -> bool {
291        match self {
292            Self::Allow => found,
293            Self::Deny => !found,
294        }
295    }
296}
297
298/// Parses the `type` of a restriction plugin. Absent means `allow`, which is
299/// the documented default; anything that is not `allow` or `deny` is rejected.
300pub(crate) fn get_restriction_category_conf(
301    value: &PluginConf,
302    category: &str,
303) -> Result<RestrictionCategory, Error> {
304    match get_str_conf(value, "type").to_lowercase().as_str() {
305        "" | "allow" => Ok(RestrictionCategory::Allow),
306        "deny" => Ok(RestrictionCategory::Deny),
307        other => Err(Error::Invalid {
308            category: category.to_string(),
309            message: format!("Invalid type({other}), expect allow or deny"),
310        }),
311    }
312}
313
314/// Whether the response is a part of a body and not the body: a `206`, or
315/// anything that says which bytes it is.
316///
317/// A plugin that rewrites the body - compresses it, substitutes in it,
318/// encodes it anew - has nothing to do with a part. What it would make of
319/// one is no part of anything: the hundred bytes asked for come back as
320/// the gzip of those hundred bytes, under a `Content-Range` that still
321/// counts in the bytes of the original.
322pub fn is_partial_content(resp: &pingora::http::ResponseHeader) -> bool {
323    resp.status == http::StatusCode::PARTIAL_CONTENT
324        || resp.headers.contains_key(http::header::CONTENT_RANGE)
325}
326
327/// Takes back what a response can no longer say of itself once its body
328/// is going to be another: that ranges of it can be asked for, and that
329/// its `ETag` names these bytes.
330///
331/// The upstream's `Accept-Ranges` and strong `ETag` are about the body it
332/// sent. Left on the rewritten one, a client resumed a download with a
333/// range of the original, or took two different bodies for the same bytes.
334pub fn body_will_change(resp: &mut pingora::http::ResponseHeader) {
335    resp.remove_header(&http::header::ACCEPT_RANGES);
336    weaken_etag(resp);
337}
338
339/// Makes a strong `ETag` a weak one, `true` when it changed the header.
340///
341/// The validator is weakened rather than removed, as nginx and pingora's
342/// own compression do it: it still tells whether the resource changed.
343/// One that is not a quoted string is no validator, and is removed.
344pub fn weaken_etag(resp: &mut pingora::http::ResponseHeader) -> bool {
345    let Some(etag) = resp.headers.get(http::header::ETAG) else {
346        return false;
347    };
348    let value = etag.as_bytes();
349    if value.starts_with(b"W/") {
350        return false;
351    }
352    let weak = value
353        .starts_with(b"\"")
354        .then(|| [b"W/", value].concat())
355        .and_then(|weak| http::HeaderValue::from_bytes(&weak).ok());
356    match weak {
357        Some(weak) => {
358            let _ = resp.insert_header(http::header::ETAG, weak);
359        },
360        None => {
361            resp.remove_header(&http::header::ETAG);
362        },
363    }
364    true
365}
366
367/// Returns true if `accept_encoding` lists `coding` as an acceptable encoding.
368///
369/// Matches on comma/`;`-delimited token boundaries (so `x-gzip` does not match
370/// `gzip`) and treats an explicit `q=0` as "not acceptable". Shared by the
371/// `accept_encoding` and `compression` plugins so the two cannot disagree about
372/// what the client accepts.
373pub fn accepts_encoding(accept_encoding: &str, coding: &str) -> bool {
374    accept_encoding.split(',').any(|part| {
375        let mut segments = part.split(';');
376        let name = segments.next().unwrap_or_default().trim();
377        if !name.eq_ignore_ascii_case(coding) {
378            return false;
379        }
380        // Acceptable unless the token is explicitly weighted q=0.
381        !segments.any(|seg| {
382            let seg = seg.trim();
383            seg.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("q="))
384                && seg[2..].trim().parse::<f32>().is_ok_and(|q| q <= 0.0)
385        })
386    })
387}
388
389/// Generates a unique hash key for a plugin configuration to detect changes.
390///
391/// # Arguments
392/// * `conf` - The plugin configuration to hash
393///
394/// # Returns
395/// A string containing the CRC32 hash of the sorted configuration key-value pairs
396pub fn get_hash_key(conf: &PluginConf) -> String {
397    let mut items: Vec<_> = conf.iter().collect();
398    // sort by key
399    items.sort_unstable_by_key(|(k, _)| *k);
400
401    // pre-allocate capacity to reduce subsequent memory reallocation.
402    let mut buf = String::with_capacity(256);
403    for (i, (key, value)) in items.iter().enumerate() {
404        if i > 0 {
405            buf.push('\n');
406        }
407        // use write! macro to write the formatted string directly into the buffer, avoid format! to produce temporary String.
408        // because writing to String will not fail, so it can be safely.
409        let _ = write!(&mut buf, "{key}:{value}");
410    }
411
412    let hash = crc32fast::hash(buf.as_bytes());
413    format!("{hash:X}")
414}
415
416/// Registers a plugin with the global plugin factory inside a pre-main
417/// constructor. Collapses the identical `#[ctor(unsafe)] fn init()` block
418/// that every plugin module would otherwise repeat.
419macro_rules! register_plugin {
420    ($category:literal, $ty:ty) => {
421        #[::ctor::ctor(unsafe)]
422        fn init() {
423            $crate::get_plugin_factory().register($category, |params| {
424                Ok(::std::sync::Arc::new(<$ty>::new(params)?))
425            });
426        }
427    };
428}
429
430mod accept_encoding;
431mod bandwidth_limit;
432mod basic_auth;
433mod cache;
434mod combined_auth;
435mod compression;
436mod cors;
437mod csrf;
438mod directory;
439mod error_page;
440mod forward_auth;
441#[cfg(feature = "geo")]
442mod geo_restriction;
443mod hmac_auth;
444mod ip_restriction;
445mod jwt;
446mod key_auth;
447mod limit;
448mod maintenance;
449mod mirror;
450mod mock;
451mod oidc;
452mod ping;
453mod redirect;
454mod referer_restriction;
455mod request_headers;
456mod request_id;
457mod response_headers;
458mod sub_filter;
459mod traffic_splitting;
460mod ua_restriction;
461mod uri_block;
462
463mod plugin;
464
465pub use plugin::get_plugin_factory;
466
467#[cfg(test)]
468mod tests {
469    use super::{accepts_encoding, get_plugin_factory, get_str_slice_conf};
470    use pingap_config::PluginConf;
471    use pretty_assertions::assert_eq;
472
473    /// Regression: a value of the wrong type was read as the default, and
474    /// the plugin ran with a limit of 0, an empty list or a flag left off.
475    #[test]
476    fn test_body_will_change() {
477        use pingora::http::ResponseHeader;
478        let changed = |etag: Option<&str>| {
479            let mut resp = ResponseHeader::build(200, None).unwrap();
480            resp.append_header("Accept-Ranges", "bytes").unwrap();
481            if let Some(etag) = etag {
482                resp.append_header("ETag", etag).unwrap();
483            }
484            super::body_will_change(&mut resp);
485            assert_eq!(false, resp.headers.contains_key("Accept-Ranges"));
486            resp.headers
487                .get("ETag")
488                .map(|value| value.to_str().unwrap().to_string())
489        };
490        assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("\"v1\"")));
491        assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("W/\"v1\"")));
492        // Not a validator to begin with.
493        assert_eq!(None, changed(Some("v1")));
494        assert_eq!(None, changed(None));
495
496        let mut part = ResponseHeader::build(206, None).unwrap();
497        assert_eq!(true, super::is_partial_content(&part));
498        part.set_status(200).unwrap();
499        assert_eq!(false, super::is_partial_content(&part));
500        part.append_header("Content-Range", "bytes 0-9/100")
501            .unwrap();
502        assert_eq!(true, super::is_partial_content(&part));
503    }
504
505    #[test]
506    fn test_wrong_typed_values_are_rejected() {
507        let create = |conf: &str| {
508            get_plugin_factory()
509                .create(&toml::from_str::<PluginConf>(conf).unwrap())
510                .map(|_| ())
511                .map_err(|e| e.to_string())
512        };
513        let limit = "category = \"limit\"\ntag = \"ip\"\n";
514        let key_auth = "category = \"key_auth\"\nheader = \"X-Key\"\n";
515        for (conf, expected) in [
516            (
517                format!("{limit}max = \"100\""),
518                "Plugin limit invalid, message: max must be an integer, got string \"100\"",
519            ),
520            (
521                format!("{limit}max = 10\ninterval = 10"),
522                "interval must be a string, got integer 10",
523            ),
524            // The value of what is a credential is not repeated: this
525            // message goes to the log and to the webhook.
526            (
527                format!("{key_auth}keys = [\"a\", 123]"),
528                "keys must be a list of strings, got integer",
529            ),
530            (
531                format!("{key_auth}keys = [\"a\"]\nhide_credentials = \"true\""),
532                "hide_credentials must be true or false, got string \"true\"",
533            ),
534            (
535                "category = \"forward_auth\"\nauth_url = \"http://127.0.0.1/\"\ntimeout = \"10\"".to_string(),
536                "timeout must be a duration such as \"10s\", got string \"10\"",
537            ),
538        ] {
539            let err = create(&conf).unwrap_err();
540            assert_eq!(true, err.contains(expected), "{conf}: {err}");
541        }
542        // The plugin's own error is the one reported when it has one.
543        let err =
544            create(&format!("{limit}max = -1\ninterval = 10")).unwrap_err();
545        assert_eq!(true, err.contains("max must not be negative"), "{err}");
546
547        assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
548        assert_eq!(Ok(()), create(&format!("{key_auth}keys = \"a\"")));
549        // Nothing is left over for the plugin built next on this thread.
550        assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
551    }
552
553    /// Outside the factory the getters answer the default, as they did.
554    #[test]
555    fn test_getters_default_outside_the_factory() {
556        let conf: PluginConf =
557            toml::from_str("max = \"100\"\nkeys = 1\nflag = \"true\"").unwrap();
558        assert_eq!(0, super::get_int_conf(&conf, "max"));
559        assert_eq!(true, get_str_slice_conf(&conf, "keys").is_empty());
560        assert_eq!(false, super::get_bool_conf(&conf, "flag"));
561        assert_eq!("", super::get_str_conf(&conf, "keys"));
562        assert_eq!(None, super::get_duration_conf(&conf, "max"));
563    }
564
565    #[test]
566    fn test_get_str_slice_conf() {
567        let conf: PluginConf = toml::from_str(
568            r#"
569list = ["a", "b"]
570one = ["a"]
571bare = "a"
572empty = []
573blank = ""
574number = 1
575"#,
576        )
577        .unwrap();
578        assert_eq!(vec!["a", "b"], get_str_slice_conf(&conf, "list"));
579        assert_eq!(vec!["a"], get_str_slice_conf(&conf, "one"));
580        // One value written without the brackets, as KDL has it.
581        assert_eq!(vec!["a"], get_str_slice_conf(&conf, "bare"));
582        for key in ["empty", "blank", "number", "missing"] {
583            assert_eq!(
584                true,
585                get_str_slice_conf(&conf, key).is_empty(),
586                "{key}"
587            );
588        }
589    }
590
591    #[test]
592    fn test_accepts_encoding() {
593        assert_eq!(true, accepts_encoding("gzip, br", "br"));
594        assert_eq!(true, accepts_encoding("gzip, deflate, br;q=0.9", "br"));
595        assert_eq!(true, accepts_encoding("BR", "br"));
596        // Substring false-matches must be rejected.
597        assert_eq!(false, accepts_encoding("x-gzip", "gzip"));
598        assert_eq!(false, accepts_encoding("gzipx", "gzip"));
599        // Explicit q=0 means "not acceptable".
600        assert_eq!(false, accepts_encoding("br;q=0", "br"));
601    }
602}