1use 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 static WRONG_TYPES: RefCell<Option<Vec<String>>> =
58 const { RefCell::new(None) };
59}
60
61fn note_wrong_type(key: &str, expected: &str, value: &toml::Value) {
69 WRONG_TYPES.with_borrow_mut(|notes| {
70 if let Some(notes) = notes {
71 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
87pub(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
100pub(crate) fn build_plugin<T>(
106 category: &str,
107 build: impl FnOnce() -> Result<T, Error>,
108) -> Result<T, Error> {
109 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 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
131pub 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
143pub 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
153pub 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
165pub 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) .collect(),
182 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
241pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub(crate) enum RestrictionCategory {
281 #[default]
282 Allow,
283 Deny,
284}
285
286impl RestrictionCategory {
287 #[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
298pub(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
314pub 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
327pub fn body_will_change(resp: &mut pingora::http::ResponseHeader) {
335 resp.remove_header(&http::header::ACCEPT_RANGES);
336 weaken_etag(resp);
337}
338
339pub 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
367pub 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 !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
389pub fn get_hash_key(conf: &PluginConf) -> String {
397 let mut items: Vec<_> = conf.iter().collect();
398 items.sort_unstable_by_key(|(k, _)| *k);
400
401 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 let _ = write!(&mut buf, "{key}:{value}");
410 }
411
412 let hash = crc32fast::hash(buf.as_bytes());
413 format!("{hash:X}")
414}
415
416macro_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 #[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 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 (
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 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 assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
551 }
552
553 #[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 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 assert_eq!(false, accepts_encoding("x-gzip", "gzip"));
598 assert_eq!(false, accepts_encoding("gzipx", "gzip"));
599 assert_eq!(false, accepts_encoding("br;q=0", "br"));
601 }
602}