1use humantime::parse_duration;
16use pingap_config::PluginConf;
17use pingap_core::{Ctx, HttpResponse, PluginStep, RequestPluginResult};
18use pingora::proxy::Session;
19use snafu::Snafu;
20use std::cell::RefCell;
21use std::fmt::Write;
22use std::str::FromStr;
23use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
24use std::time::Duration;
25
26#[derive(Debug, Snafu)]
27pub enum Error {
28 #[snafu(display("Plugin {category} invalid, message: {message}"))]
29 Invalid { category: String, message: String },
30 #[snafu(display("Plugin {category} not found"))]
31 NotFound { category: String },
32 #[snafu(display("Plugin {category}, base64 decode error {source}"))]
33 Base64Decode {
34 category: String,
35 source: base64::DecodeError,
36 },
37 #[snafu(display("Plugin {category}, exceed limit {value}/{max}"))]
38 Exceed {
39 category: String,
40 max: f64,
41 value: f64,
42 },
43 #[snafu(display("Plugin {category}, regex error {source}"))]
44 Regex {
45 category: String,
46 source: Box<fancy_regex::Error>,
47 },
48 #[snafu(display("Plugin {category}, base64 decode error {source}"))]
49 ParseDuration {
50 category: String,
51 source: humantime::DurationError,
52 },
53}
54
55thread_local! {
56 static WRONG_TYPES: RefCell<Option<Vec<String>>> =
59 const { RefCell::new(None) };
60}
61
62fn note_wrong_type(key: &str, expected: &str, value: &toml::Value) {
70 WRONG_TYPES.with_borrow_mut(|notes| {
71 if let Some(notes) = notes {
72 let shown = if pingap_config::is_secret_key(key) {
76 String::new()
77 } else {
78 format!(" {value}")
79 };
80 notes.push(format!(
81 "{key} must be {expected}, got {}{shown}",
82 value.type_str()
83 ));
84 }
85 });
86}
87
88pub(crate) fn wrong_types_error(category: &str) -> Option<Error> {
92 WRONG_TYPES.with_borrow(|notes| {
93 let notes = notes.as_ref().filter(|notes| !notes.is_empty())?;
94 Some(Error::Invalid {
95 category: category.to_string(),
96 message: notes.join(", "),
97 })
98 })
99}
100
101pub(crate) fn build_plugin<T>(
107 category: &str,
108 build: impl FnOnce() -> Result<T, Error>,
109) -> Result<T, Error> {
110 struct Restore(Option<Vec<String>>);
112 impl Drop for Restore {
113 fn drop(&mut self) {
114 WRONG_TYPES.set(self.0.take());
115 }
116 }
117 let restore = Restore(WRONG_TYPES.replace(Some(vec![])));
118 let result = build();
119 let notes = WRONG_TYPES.take().unwrap_or_default();
120 drop(restore);
121 let plugin = result?;
123 if notes.is_empty() {
124 return Ok(plugin);
125 }
126 Err(Error::Invalid {
127 category: category.to_string(),
128 message: notes.join(", "),
129 })
130}
131
132pub fn get_str_conf(value: &PluginConf, key: &str) -> String {
134 match value.get(key) {
135 Some(toml::Value::String(item)) => item.clone(),
136 Some(other) => {
137 note_wrong_type(key, "a string", other);
138 String::new()
139 },
140 None => String::new(),
141 }
142}
143
144pub fn get_duration_conf(value: &PluginConf, key: &str) -> Option<Duration> {
146 let item = value.get(key)?;
147 let duration = item.as_str().and_then(|s| parse_duration(s).ok());
148 if duration.is_none() {
149 note_wrong_type(key, "a duration such as \"10s\"", item);
150 }
151 duration
152}
153
154pub fn new_body_handler_id(prefix: &str) -> String {
164 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
165 format!("{prefix}{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
166}
167
168pub fn get_str_slice_conf(value: &PluginConf, key: &str) -> Vec<String> {
173 match value.get(key) {
174 Some(toml::Value::Array(arr)) => arr
175 .iter()
176 .filter_map(|item| {
177 let text = item.as_str();
178 if text.is_none() {
179 note_wrong_type(key, "a list of strings", item);
180 }
181 text
182 })
183 .map(String::from) .collect(),
185 Some(toml::Value::String(item)) => {
187 if item.is_empty() {
188 vec![]
189 } else {
190 vec![item.clone()]
191 }
192 },
193 Some(other) => {
194 note_wrong_type(key, "a list of strings", other);
195 vec![]
196 },
197 None => vec![],
198 }
199}
200
201pub(crate) use pingap_core::decode_query_value;
202
203pub(crate) fn get_bool_conf(value: &PluginConf, key: &str) -> bool {
204 match value.get(key) {
205 Some(toml::Value::Boolean(item)) => *item,
206 Some(other) => {
207 note_wrong_type(key, "true or false", other);
208 false
209 },
210 None => false,
211 }
212}
213
214pub fn get_int_conf(value: &PluginConf, key: &str) -> i64 {
215 get_int_conf_or_default(value, key, 0)
216}
217
218pub fn get_int_conf_or_default(
219 value: &PluginConf,
220 key: &str,
221 default_value: i64,
222) -> i64 {
223 match value.get(key) {
224 Some(toml::Value::Integer(item)) => *item,
225 Some(other) => {
226 note_wrong_type(key, "an integer", other);
227 default_value
228 },
229 None => default_value,
230 }
231}
232
233pub fn get_step_conf(
234 value: &PluginConf,
235 default_value: PluginStep,
236) -> PluginStep {
237 value
238 .get("step")
239 .and_then(|v| v.as_str())
240 .and_then(|s| PluginStep::from_str(s).ok())
241 .unwrap_or(default_value)
242}
243
244pub fn get_step_conf_in(
251 value: &PluginConf,
252 category: &str,
253 default_value: PluginStep,
254 allowed: &[PluginStep],
255) -> Result<PluginStep, Error> {
256 let Some(step) = value.get("step").and_then(|v| v.as_str()) else {
257 return Ok(default_value);
258 };
259 let invalid = || Error::Invalid {
260 category: category.to_string(),
261 message: format!(
262 "Invalid step({step}), expect one of: {}",
263 allowed
264 .iter()
265 .map(|item| item.to_string())
266 .collect::<Vec<_>>()
267 .join(", ")
268 ),
269 };
270 let step = PluginStep::from_str(step).map_err(|_| invalid())?;
271 if !allowed.contains(&step) {
272 return Err(invalid());
273 }
274 Ok(step)
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
283pub(crate) enum RestrictionCategory {
284 #[default]
285 Allow,
286 Deny,
287}
288
289impl RestrictionCategory {
290 #[inline]
293 pub(crate) fn allows(&self, found: bool) -> bool {
294 match self {
295 Self::Allow => found,
296 Self::Deny => !found,
297 }
298 }
299}
300
301pub(crate) fn get_restriction_category_conf(
304 value: &PluginConf,
305 category: &str,
306) -> Result<RestrictionCategory, Error> {
307 match get_str_conf(value, "type").to_lowercase().as_str() {
308 "" | "allow" => Ok(RestrictionCategory::Allow),
309 "deny" => Ok(RestrictionCategory::Deny),
310 other => Err(Error::Invalid {
311 category: category.to_string(),
312 message: format!("Invalid type({other}), expect allow or deny"),
313 }),
314 }
315}
316
317fn verified_address(session: &Session, ctx: &Ctx) -> String {
322 if pingap_core::has_trusted_proxies() {
323 return ctx
324 .conn
325 .client_ip
326 .clone()
327 .unwrap_or_else(|| pingap_core::get_client_ip(session));
328 }
329 ctx.conn.remote_addr.clone().unwrap_or_default()
330}
331
332const LINES_PER_SECOND: u32 = 10;
337
338#[derive(Default)]
340pub(crate) struct LineBudget {
341 second: AtomicU64,
343 logged: AtomicU32,
344 skipped: AtomicU64,
346}
347
348impl LineBudget {
349 pub(crate) fn take_line(&self, now: u64) -> Option<u64> {
353 let second = self.second.load(Ordering::Relaxed);
354 if second != now
355 && self
356 .second
357 .compare_exchange(
358 second,
359 now,
360 Ordering::Relaxed,
361 Ordering::Relaxed,
362 )
363 .is_ok()
364 {
365 self.logged.store(0, Ordering::Relaxed);
366 }
367 if self.logged.fetch_add(1, Ordering::Relaxed) < LINES_PER_SECOND {
368 return Some(self.skipped.swap(0, Ordering::Relaxed));
369 }
370 self.skipped.fetch_add(1, Ordering::Relaxed);
371 None
372 }
373}
374
375#[derive(Default)]
377pub(crate) struct DryRun {
378 on: bool,
379 lines: LineBudget,
380}
381
382impl DryRun {
383 pub(crate) fn new(value: &PluginConf) -> Self {
384 Self {
385 on: get_bool_conf(value, "dry_run"),
386 ..Default::default()
387 }
388 }
389
390 pub(crate) fn is_on(&self) -> bool {
391 self.on
392 }
393
394 pub(crate) fn refuse(
401 &self,
402 category: &str,
403 session: &Session,
404 ctx: &mut Ctx,
405 response: HttpResponse,
406 matched: impl FnOnce(&mut Ctx) -> String,
407 ) -> RequestPluginResult {
408 if !self.on {
409 return RequestPluginResult::Respond(response);
410 }
411 let Some(skipped) = self.lines.take_line(pingap_core::now_sec()) else {
412 return RequestPluginResult::Continue;
413 };
414 let matched = matched(ctx);
415 let header = session.req_header();
416 tracing::warn!(
417 category,
418 location = &*ctx.upstream.location,
419 status = response.status.as_u16(),
420 reason = %String::from_utf8_lossy(&response.body),
421 matched,
422 client_ip = verified_address(session, ctx),
423 method = header.method.as_str(),
424 uri = %header.uri,
425 request_id = ctx.state.request_id.as_deref().unwrap_or_default(),
426 skipped,
427 "dry run, the request would have been refused"
428 );
429 RequestPluginResult::Continue
430 }
431}
432
433pub fn unread_keys(conf: &PluginConf) -> Vec<String> {
437 match get_str_conf(conf, "category").as_str() {
438 "limit" => limit::unread_keys(conf),
439 _ => vec![],
440 }
441}
442
443pub fn needs_ja4(conf: &PluginConf) -> bool {
446 match get_str_conf(conf, "category").as_str() {
447 "ja4_restriction" => true,
448 "limit" => {
449 matches!(get_str_conf(conf, "tag").as_str(), "ja4" | "ip_ja4")
450 },
451 _ => false,
452 }
453}
454
455pub fn is_partial_content(resp: &pingora::http::ResponseHeader) -> bool {
464 resp.status == http::StatusCode::PARTIAL_CONTENT
465 || resp.headers.contains_key(http::header::CONTENT_RANGE)
466}
467
468pub fn body_will_change(resp: &mut pingora::http::ResponseHeader) {
476 resp.remove_header(&http::header::ACCEPT_RANGES);
477 weaken_etag(resp);
478}
479
480pub fn weaken_etag(resp: &mut pingora::http::ResponseHeader) -> bool {
486 let Some(etag) = resp.headers.get(http::header::ETAG) else {
487 return false;
488 };
489 let value = etag.as_bytes();
490 if value.starts_with(b"W/") {
491 return false;
492 }
493 let weak = value
494 .starts_with(b"\"")
495 .then(|| [b"W/", value].concat())
496 .and_then(|weak| http::HeaderValue::from_bytes(&weak).ok());
497 match weak {
498 Some(weak) => {
499 let _ = resp.insert_header(http::header::ETAG, weak);
500 },
501 None => {
502 resp.remove_header(&http::header::ETAG);
503 },
504 }
505 true
506}
507
508pub fn accepts_encoding(accept_encoding: &str, coding: &str) -> bool {
515 accept_encoding.split(',').any(|part| {
516 let mut segments = part.split(';');
517 let name = segments.next().unwrap_or_default().trim();
518 if !name.eq_ignore_ascii_case(coding) {
519 return false;
520 }
521 !segments.any(|seg| {
523 let seg = seg.trim();
524 seg.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("q="))
525 && seg[2..].trim().parse::<f32>().is_ok_and(|q| q <= 0.0)
526 })
527 })
528}
529
530pub fn get_hash_key(conf: &PluginConf) -> String {
538 let mut items: Vec<_> = conf.iter().collect();
539 items.sort_unstable_by_key(|(k, _)| *k);
541
542 let mut buf = String::with_capacity(256);
544 for (i, (key, value)) in items.iter().enumerate() {
545 if i > 0 {
546 buf.push('\n');
547 }
548 let _ = write!(&mut buf, "{key}:{value}");
551 }
552
553 let hash = crc32fast::hash(buf.as_bytes());
554 format!("{hash:X}")
555}
556
557macro_rules! register_plugin {
561 ($category:literal, $ty:ty) => {
562 #[::ctor::ctor(unsafe)]
563 fn init() {
564 $crate::get_plugin_factory().register($category, |params| {
565 Ok(::std::sync::Arc::new(<$ty>::new(params)?))
566 });
567 }
568 };
569}
570
571mod accept_encoding;
572mod bandwidth_limit;
573mod basic_auth;
574mod cache;
575mod combined_auth;
576mod compression;
577mod cors;
578mod csrf;
579mod directory;
580mod error_page;
581mod forward_auth;
582#[cfg(feature = "geo")]
583mod geo_restriction;
584mod hmac_auth;
585mod ip_restriction;
586mod ja4_restriction;
587mod jwt;
588mod key_auth;
589mod limit;
590mod maintenance;
591mod mirror;
592mod mock;
593mod oidc;
594mod ping;
595mod redirect;
596mod referer_restriction;
597mod request_headers;
598mod request_id;
599mod response_headers;
600mod sub_filter;
601mod traffic_splitting;
602mod ua_restriction;
603mod uri_block;
604
605mod plugin;
606
607pub use plugin::get_plugin_factory;
608
609#[cfg(test)]
610mod tests {
611 use super::{accepts_encoding, get_plugin_factory, get_str_slice_conf};
612 use pingap_config::PluginConf;
613 use pingap_core::{Ctx, PluginStep, RequestPluginResult};
614 use pingora::proxy::Session;
615 use pretty_assertions::assert_eq;
616
617 #[tokio::test]
621 async fn test_dry_run_logs_the_verified_address() {
622 let input =
623 b"GET / HTTP/1.1\r\nHost: a\r\nX-Forwarded-For: 8.8.8.8\r\n\r\n";
624 let mock_io = tokio_test::io::Builder::new().read(input).build();
625 let mut session = Session::new_h1(Box::new(mock_io));
626 session.read_request().await.unwrap();
627 let mut ctx = Ctx::default();
628 ctx.conn.remote_addr = Some("1.1.1.1".to_string());
629 assert_eq!("1.1.1.1", super::verified_address(&session, &ctx));
630 }
631
632 #[test]
633 fn test_needs_ja4() {
634 let needs = |conf: &str| {
635 super::needs_ja4(&toml::from_str::<PluginConf>(conf).unwrap())
636 };
637 assert_eq!(true, needs("category = \"ja4_restriction\""));
638 assert_eq!(true, needs("category = \"limit\"\ntag = \"ja4\""));
639 assert_eq!(true, needs("category = \"limit\"\ntag = \"ip_ja4\""));
640 assert_eq!(false, needs("category = \"limit\""));
641 assert_eq!(false, needs("category = \"limit\"\ntag = \"header\""));
642 assert_eq!(false, needs("category = \"ip_restriction\""));
643 }
644
645 #[test]
648 fn test_lines_per_second() {
649 let dry_run = super::DryRun::new(
650 &toml::from_str::<PluginConf>("dry_run = true").unwrap(),
651 );
652 assert_eq!(true, dry_run.is_on());
653 let budget = super::LineBudget::default();
654 let lines =
655 |now| (0..15).map(|_| budget.take_line(now)).collect::<Vec<_>>();
656 let mut expected = vec![Some(0); 10];
657 expected.extend([None; 5]);
658 assert_eq!(expected, lines(100));
659 expected[0] = Some(5);
661 assert_eq!(expected, lines(101));
662 assert_eq!(Some(5), budget.take_line(102));
663 assert_eq!(Some(0), budget.take_line(102));
664 }
665
666 #[tokio::test]
669 async fn test_dry_run_lets_the_request_through() {
670 let mut cases = vec![
671 (
672 "category = \"ip_restriction\"\nip_list = [\"1.1.1.1\"]\ntype = \"deny\"",
673 "",
674 ),
675 (
676 "category = \"ua_restriction\"\nua_list = [\"^curl\"]\ntype = \"deny\"",
677 "User-Agent: curl/8.0\r\n",
678 ),
679 (
680 "category = \"referer_restriction\"\nreferer_list = [\"bad.example\"]\ntype = \"deny\"",
681 "Referer: https://bad.example/page\r\n",
682 ),
683 ("category = \"uri_block\"\npaths = [\"^/admin\"]", ""),
684 ("category = \"limit\"\nmax = 0", ""),
685 ("category = \"ja4_restriction\"\nja4_list = [\"^t13\"]", ""),
687 ];
688 if cfg!(feature = "geo") {
689 cases.push((
690 "category = \"geo_restriction\"\ncountry_codes = [\"CN\"]\ntype = \"allow\"",
691 "",
692 ));
693 }
694 for (conf, headers) in cases {
695 for dry_run in [false, true] {
696 let conf = format!("{conf}\ndry_run = {dry_run}");
697 let plugin = get_plugin_factory()
698 .create(&toml::from_str::<PluginConf>(&conf).unwrap())
699 .unwrap();
700 let input = format!(
701 "GET /admin/users HTTP/1.1\r\nHost: example.com\r\n{headers}\r\n"
702 );
703 let mock_io = tokio_test::io::Builder::new()
704 .read(input.as_bytes())
705 .build();
706 let mut session = Session::new_h1(Box::new(mock_io));
707 session.read_request().await.unwrap();
708 let mut ctx = Ctx::default();
709 ctx.conn.remote_addr = Some("1.1.1.1".to_string());
710 let result = plugin
711 .handle_request(PluginStep::Request, &mut session, &mut ctx)
712 .await
713 .unwrap();
714 assert_eq!(
715 dry_run,
716 result == RequestPluginResult::Continue,
717 "{conf}"
718 );
719 }
720 }
721 }
722
723 #[test]
726 fn test_body_will_change() {
727 use pingora::http::ResponseHeader;
728 let changed = |etag: Option<&str>| {
729 let mut resp = ResponseHeader::build(200, None).unwrap();
730 resp.append_header("Accept-Ranges", "bytes").unwrap();
731 if let Some(etag) = etag {
732 resp.append_header("ETag", etag).unwrap();
733 }
734 super::body_will_change(&mut resp);
735 assert_eq!(false, resp.headers.contains_key("Accept-Ranges"));
736 resp.headers
737 .get("ETag")
738 .map(|value| value.to_str().unwrap().to_string())
739 };
740 assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("\"v1\"")));
741 assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("W/\"v1\"")));
742 assert_eq!(None, changed(Some("v1")));
744 assert_eq!(None, changed(None));
745
746 let mut part = ResponseHeader::build(206, None).unwrap();
747 assert_eq!(true, super::is_partial_content(&part));
748 part.set_status(200).unwrap();
749 assert_eq!(false, super::is_partial_content(&part));
750 part.append_header("Content-Range", "bytes 0-9/100")
751 .unwrap();
752 assert_eq!(true, super::is_partial_content(&part));
753 }
754
755 #[test]
756 fn test_wrong_typed_values_are_rejected() {
757 let create = |conf: &str| {
758 get_plugin_factory()
759 .create(&toml::from_str::<PluginConf>(conf).unwrap())
760 .map(|_| ())
761 .map_err(|e| e.to_string())
762 };
763 let limit = "category = \"limit\"\ntag = \"ip\"\n";
764 let key_auth = "category = \"key_auth\"\nheader = \"X-Key\"\n";
765 for (conf, expected) in [
766 (
767 format!("{limit}max = \"100\""),
768 "Plugin limit invalid, message: max must be an integer, got string \"100\"",
769 ),
770 (
771 format!("{limit}max = 10\ninterval = 10"),
772 "interval must be a string, got integer 10",
773 ),
774 (
777 format!("{key_auth}keys = [\"a\", 123]"),
778 "keys must be a list of strings, got integer",
779 ),
780 (
781 format!("{key_auth}keys = [\"a\"]\nhide_credentials = \"true\""),
782 "hide_credentials must be true or false, got string \"true\"",
783 ),
784 (
785 "category = \"forward_auth\"\nauth_url = \"http://127.0.0.1/\"\ntimeout = \"10\"".to_string(),
786 "timeout must be a duration such as \"10s\", got string \"10\"",
787 ),
788 ] {
789 let err = create(&conf).unwrap_err();
790 assert_eq!(true, err.contains(expected), "{conf}: {err}");
791 }
792 let err =
794 create(&format!("{limit}max = -1\ninterval = 10")).unwrap_err();
795 assert_eq!(true, err.contains("max must not be negative"), "{err}");
796
797 assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
798 assert_eq!(Ok(()), create(&format!("{key_auth}keys = \"a\"")));
799 assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
801 }
802
803 #[test]
805 fn test_getters_default_outside_the_factory() {
806 let conf: PluginConf =
807 toml::from_str("max = \"100\"\nkeys = 1\nflag = \"true\"").unwrap();
808 assert_eq!(0, super::get_int_conf(&conf, "max"));
809 assert_eq!(true, get_str_slice_conf(&conf, "keys").is_empty());
810 assert_eq!(false, super::get_bool_conf(&conf, "flag"));
811 assert_eq!("", super::get_str_conf(&conf, "keys"));
812 assert_eq!(None, super::get_duration_conf(&conf, "max"));
813 }
814
815 #[test]
816 fn test_get_str_slice_conf() {
817 let conf: PluginConf = toml::from_str(
818 r#"
819list = ["a", "b"]
820one = ["a"]
821bare = "a"
822empty = []
823blank = ""
824number = 1
825"#,
826 )
827 .unwrap();
828 assert_eq!(vec!["a", "b"], get_str_slice_conf(&conf, "list"));
829 assert_eq!(vec!["a"], get_str_slice_conf(&conf, "one"));
830 assert_eq!(vec!["a"], get_str_slice_conf(&conf, "bare"));
832 for key in ["empty", "blank", "number", "missing"] {
833 assert_eq!(
834 true,
835 get_str_slice_conf(&conf, key).is_empty(),
836 "{key}"
837 );
838 }
839 }
840
841 #[test]
842 fn test_accepts_encoding() {
843 assert_eq!(true, accepts_encoding("gzip, br", "br"));
844 assert_eq!(true, accepts_encoding("gzip, deflate, br;q=0.9", "br"));
845 assert_eq!(true, accepts_encoding("BR", "br"));
846 assert_eq!(false, accepts_encoding("x-gzip", "gzip"));
848 assert_eq!(false, accepts_encoding("gzipx", "gzip"));
849 assert_eq!(false, accepts_encoding("br;q=0", "br"));
851 }
852}