1use http::HeaderMap;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::{LazyLock, RwLock};
8use uuid::Uuid;
9
10#[derive(Clone, Debug, Serialize, Deserialize, Default)]
12pub struct ProxyConfig {
13 #[serde(default)]
15 pub server: ServerConfig,
16
17 #[serde(default)]
19 pub tls: TlsConfig,
20
21 #[serde(default)]
23 pub rate_limit: RateLimitDefaults,
24
25 #[serde(default)]
27 pub routes: Vec<Route>,
28
29 #[serde(default)]
31 pub upstreams: Vec<Upstream>,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ServerConfig {
37 #[serde(default = "default_http_host")]
39 pub http_host: String,
40
41 #[serde(default = "default_http_port")]
43 pub http_port: u16,
44
45 #[serde(default)]
53 pub http2_port: Option<u16>,
54
55 #[serde(default = "default_https_host")]
57 pub https_host: String,
58
59 #[serde(default = "default_https_port")]
61 pub https_port: u16,
62
63 #[serde(default)]
65 pub https_enabled: bool,
66
67 #[serde(default = "default_true")]
69 pub database_config: bool,
70
71 pub config_file: Option<String>,
73}
74
75impl Default for ServerConfig {
76 fn default() -> Self {
77 Self {
78 http_host: default_http_host(),
79 http_port: default_http_port(),
80 http2_port: None,
81 https_host: default_https_host(),
82 https_port: default_https_port(),
83 https_enabled: false,
84 database_config: true,
85 config_file: None,
86 }
87 }
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct TlsConfig {
93 #[serde(default)]
95 pub acme_enabled: bool,
96
97 #[serde(default = "default_acme_directory")]
99 pub acme_directory: String,
100
101 pub acme_email: Option<String>,
103
104 #[serde(default = "default_cert_dir")]
106 pub cert_dir: String,
107
108 #[serde(default)]
110 pub acme_staging: bool,
111
112 #[serde(default)]
118 pub cert_file: Option<String>,
119
120 #[serde(default)]
122 pub key_file: Option<String>,
123
124 #[serde(default)]
131 pub acme_directory_url: Option<String>,
132
133 #[serde(default)]
138 pub acme_root_pem: Option<String>,
139}
140
141impl Default for TlsConfig {
142 fn default() -> Self {
143 Self {
144 acme_enabled: false,
145 acme_directory: default_acme_directory(),
146 acme_email: None,
147 cert_dir: default_cert_dir(),
148 acme_staging: false,
149 cert_file: None,
150 key_file: None,
151 acme_directory_url: None,
152 acme_root_pem: None,
153 }
154 }
155}
156
157#[derive(Clone, Debug, Serialize, Deserialize)]
159pub struct RateLimitDefaults {
160 #[serde(default = "default_rate_limit_requests")]
162 pub requests: u32,
163
164 #[serde(default = "default_rate_limit_window")]
166 pub window_secs: u32,
167
168 #[serde(default = "default_burst")]
170 pub burst: u32,
171}
172
173impl Default for RateLimitDefaults {
174 fn default() -> Self {
175 Self {
176 requests: default_rate_limit_requests(),
177 window_secs: default_rate_limit_window(),
178 burst: default_burst(),
179 }
180 }
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct Route {
186 pub id: Option<Uuid>,
188
189 pub name: String,
191
192 pub description: Option<String>,
194
195 #[serde(default, rename = "match")]
197 pub match_: RouteMatch,
198
199 #[serde(default = "default_priority")]
201 pub priority: i32,
202
203 pub upstream: String,
205
206 #[serde(default)]
208 pub strip_path: bool,
209
210 #[serde(default)]
212 pub add_headers: HashMap<String, String>,
213
214 #[serde(default)]
216 pub remove_headers: Vec<String>,
217
218 pub rate_limit: Option<RouteRateLimit>,
220
221 #[serde(default = "default_timeout")]
223 pub timeout_secs: u32,
224
225 #[serde(default)]
227 pub retry_count: u32,
228
229 #[serde(default = "default_true")]
231 pub enabled: bool,
232}
233
234#[derive(Clone, Debug, Default, Serialize, Deserialize)]
236pub struct RouteMatch {
237 pub host: Option<String>,
239
240 pub path: Option<String>,
242
243 #[serde(default)]
245 pub path_type: PathMatchType,
246
247 #[serde(default)]
249 pub headers: HashMap<String, String>,
250
251 pub methods: Option<Vec<String>>,
253}
254
255static REGEX_CACHE: LazyLock<RwLock<HashMap<String, Option<Regex>>>> =
270 LazyLock::new(|| RwLock::new(HashMap::new()));
271
272fn compiled_regex(pattern: &str) -> Option<Regex> {
276 if let Ok(cache) = REGEX_CACHE.read() {
277 if let Some(entry) = cache.get(pattern) {
278 return entry.clone();
279 }
280 }
281
282 let compiled = match Regex::new(pattern) {
283 Ok(re) => Some(re),
284 Err(error) => {
285 tracing::warn!(
286 %pattern, %error,
287 "route path regex does not compile; the route matches nothing"
288 );
289 None
290 }
291 };
292
293 if let Ok(mut cache) = REGEX_CACHE.write() {
294 cache.insert(pattern.to_owned(), compiled.clone());
295 }
296 compiled
297}
298
299impl RouteMatch {
300 pub fn matches(&self, host: &str, path: &str, method: &str, headers: &HeaderMap) -> bool {
313 self.host_matches(host)
314 && self.path_matches(path)
315 && self.method_matches(method)
316 && self.headers_match(headers)
317 }
318
319 fn host_matches(&self, host: &str) -> bool {
323 let Some(pattern) = self.host.as_deref() else {
324 return true;
325 };
326 let pattern = pattern.trim().to_ascii_lowercase();
327 let host = host.trim().to_ascii_lowercase();
328
329 if pattern == "*" || pattern.is_empty() {
330 return true;
331 }
332 if pattern == host {
333 return true;
334 }
335 match pattern.strip_prefix("*.") {
338 Some(suffix) if !suffix.is_empty() => host
339 .strip_suffix(&format!(".{suffix}"))
340 .is_some_and(|label| !label.is_empty() && !label.contains('.')),
341 _ => false,
342 }
343 }
344
345 fn path_matches(&self, path: &str) -> bool {
351 let Some(pattern) = self.path.as_deref() else {
352 return true;
353 };
354 match self.path_type {
355 PathMatchType::Prefix => path.starts_with(pattern),
356 PathMatchType::Exact => path == pattern,
357 PathMatchType::Regex => match compiled_regex(pattern) {
358 Some(re) => re.is_match(path),
359 None => false,
360 },
361 }
362 }
363
364 fn method_matches(&self, method: &str) -> bool {
366 match self.methods.as_deref() {
367 None | Some([]) => true,
368 Some(allowed) => allowed.iter().any(|m| m.eq_ignore_ascii_case(method)),
369 }
370 }
371
372 fn headers_match(&self, headers: &HeaderMap) -> bool {
375 self.headers.iter().all(|(name, expected)| {
376 headers
377 .get(name.as_str())
378 .and_then(|value| value.to_str().ok())
379 .is_some_and(|value| value == expected)
380 })
381 }
382}
383
384#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "lowercase")]
387pub enum PathMatchType {
388 #[default]
390 Prefix,
391 Exact,
393 Regex,
395}
396
397#[derive(Clone, Debug, Serialize, Deserialize)]
399pub struct RouteRateLimit {
400 pub requests: u32,
402 pub window_secs: u32,
404 #[serde(default)]
406 pub key: RateLimitKey,
407}
408
409#[derive(Clone, Debug, Default, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum RateLimitKey {
413 #[default]
415 ClientIp,
416 Header(String),
418 Route,
420}
421
422#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct Upstream {
425 pub id: Option<Uuid>,
427
428 pub name: String,
430
431 pub description: Option<String>,
433
434 #[serde(default)]
436 pub lb_strategy: LoadBalanceStrategy,
437
438 #[serde(default)]
440 pub backends: Vec<Backend>,
441
442 #[serde(default)]
444 pub health_check: HealthCheckConfig,
445
446 #[serde(default = "default_true")]
448 pub enabled: bool,
449}
450
451impl Upstream {
452 pub fn resolved_id(&self) -> Uuid {
461 self.id
462 .unwrap_or_else(|| Uuid::new_v5(&Uuid::NAMESPACE_OID, self.name.as_bytes()))
463 }
464}
465
466#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(rename_all = "snake_case")]
469pub enum LoadBalanceStrategy {
470 #[default]
472 RoundRobin,
473 LeastConnections,
475 Weighted,
477 Random,
479 Sticky,
481}
482
483#[derive(Clone, Debug, Serialize, Deserialize)]
485pub struct Backend {
486 pub id: Option<Uuid>,
488
489 pub address: String,
491
492 #[serde(default = "default_scheme")]
494 pub scheme: String,
495
496 #[serde(default = "default_weight")]
498 pub weight: u32,
499
500 #[serde(default = "default_true")]
502 pub enabled: bool,
503
504 #[serde(default)]
511 pub http_version: UpstreamHttpVersion,
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
516#[serde(rename_all = "lowercase")]
517pub enum UpstreamHttpVersion {
518 #[default]
520 #[serde(alias = "http1", alias = "http/1.1")]
521 Http11,
522 #[serde(alias = "http2", alias = "h2")]
524 H2c,
525}
526
527#[derive(Clone, Debug, Serialize, Deserialize)]
529pub struct HealthCheckConfig {
530 #[serde(default = "default_true")]
532 pub enabled: bool,
533
534 #[serde(default = "default_health_path")]
536 pub path: String,
537
538 #[serde(default = "default_health_interval")]
540 pub interval_secs: u32,
541
542 #[serde(default = "default_health_timeout")]
544 pub timeout_secs: u32,
545
546 #[serde(default = "default_healthy_threshold")]
548 pub healthy_threshold: u32,
549
550 #[serde(default = "default_unhealthy_threshold")]
552 pub unhealthy_threshold: u32,
553}
554
555impl Default for HealthCheckConfig {
556 fn default() -> Self {
557 Self {
558 enabled: true,
559 path: default_health_path(),
560 interval_secs: default_health_interval(),
561 timeout_secs: default_health_timeout(),
562 healthy_threshold: default_healthy_threshold(),
563 unhealthy_threshold: default_unhealthy_threshold(),
564 }
565 }
566}
567
568fn default_http_host() -> String {
570 "0.0.0.0".into()
571}
572fn default_http_port() -> u16 {
573 8080
574}
575fn default_https_host() -> String {
576 "0.0.0.0".into()
577}
578fn default_https_port() -> u16 {
579 8443
580}
581fn default_true() -> bool {
582 true
583}
584fn default_acme_directory() -> String {
585 "https://acme-v02.api.letsencrypt.org/directory".into()
586}
587fn default_cert_dir() -> String {
588 "./certs".into()
589}
590fn default_rate_limit_requests() -> u32 {
591 1000
592}
593fn default_rate_limit_window() -> u32 {
594 60
595}
596fn default_burst() -> u32 {
597 50
598}
599fn default_priority() -> i32 {
600 100
601}
602fn default_timeout() -> u32 {
603 30
604}
605fn default_scheme() -> String {
606 "http".into()
607}
608fn default_weight() -> u32 {
609 100
610}
611fn default_health_path() -> String {
612 "/health".into()
613}
614fn default_health_interval() -> u32 {
615 10
616}
617fn default_health_timeout() -> u32 {
618 5
619}
620fn default_healthy_threshold() -> u32 {
621 2
622}
623fn default_unhealthy_threshold() -> u32 {
624 3
625}
626
627#[derive(Clone, Debug, Serialize, Deserialize, Default)]
629pub struct AcmeConfig {
630 #[serde(default)]
632 pub enabled: bool,
633
634 pub email: Option<String>,
636
637 #[serde(default)]
639 pub staging: bool,
640
641 #[serde(default)]
643 pub domains: Vec<String>,
644}
645
646#[cfg(test)]
647mod upstream_version_tests {
648 use super::*;
649
650 fn header_map(pairs: &[(&str, &str)]) -> HeaderMap {
651 let mut map = HeaderMap::new();
652 for (name, value) in pairs {
653 map.insert(
654 http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
655 http::HeaderValue::from_str(value).unwrap(),
656 );
657 }
658 map
659 }
660
661 fn any() -> RouteMatch {
663 RouteMatch::default()
664 }
665
666 #[test]
667 fn an_empty_match_matches_anything() {
668 let m = any();
669 assert!(m.matches("example.com", "/anything", "GET", &HeaderMap::new()));
670 assert!(m.matches("", "/", "DELETE", &HeaderMap::new()));
671 }
672
673 #[test]
674 fn a_prefix_path_matches_by_prefix() {
675 let m = RouteMatch {
676 path: Some("/v1".into()),
677 ..any()
678 };
679 assert!(m.matches("h", "/v1", "GET", &HeaderMap::new()));
680 assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
681 assert!(!m.matches("h", "/v2", "GET", &HeaderMap::new()));
682 }
683
684 #[test]
685 fn an_exact_path_does_not_match_a_longer_one() {
686 let m = RouteMatch {
689 path: Some("/health".into()),
690 path_type: PathMatchType::Exact,
691 ..any()
692 };
693 assert!(m.matches("h", "/health", "GET", &HeaderMap::new()));
694 assert!(!m.matches("h", "/health-internal", "GET", &HeaderMap::new()));
695 assert!(!m.matches("h", "/health/deep", "GET", &HeaderMap::new()));
696 }
697
698 #[test]
699 fn a_regex_path_matches_the_pattern() {
700 let m = RouteMatch {
701 path: Some(r"^/v[0-9]+/users$".into()),
702 path_type: PathMatchType::Regex,
703 ..any()
704 };
705 assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
706 assert!(m.matches("h", "/v22/users", "GET", &HeaderMap::new()));
707 assert!(!m.matches("h", "/v1/users/1", "GET", &HeaderMap::new()));
708 assert!(!m.matches("h", "/users", "GET", &HeaderMap::new()));
709 }
710
711 #[test]
712 fn a_regex_is_compiled_once_and_reused() {
713 let pattern = format!("^/cache-probe-{}/[0-9]+$", uuid::Uuid::new_v4());
717 let m = RouteMatch {
718 path: Some(pattern.clone()),
719 path_type: PathMatchType::Regex,
720 ..any()
721 };
722
723 assert!(!REGEX_CACHE.read().unwrap().contains_key(&pattern));
724 assert!(m.matches("h", "/nope", "GET", &HeaderMap::new()) || true);
725 assert!(
726 REGEX_CACHE.read().unwrap().contains_key(&pattern),
727 "the compiled regex was not cached"
728 );
729
730 let path = pattern
732 .trim_start_matches('^')
733 .trim_end_matches("/[0-9]+$")
734 .to_string();
735 assert!(m.matches("h", &format!("{path}/42"), "GET", &HeaderMap::new()));
736 }
737
738 #[test]
739 fn a_broken_regex_is_cached_as_a_failure() {
740 let pattern = format!("/broken-{}/(unclosed", uuid::Uuid::new_v4());
742 let m = RouteMatch {
743 path: Some(pattern.clone()),
744 path_type: PathMatchType::Regex,
745 ..any()
746 };
747 assert!(!m.matches("h", "/anything", "GET", &HeaderMap::new()));
748 let cache = REGEX_CACHE.read().unwrap();
749 let entry = cache
750 .get(&pattern)
751 .expect("a pattern that does not compile should still be cached");
752 assert!(
753 entry.is_none(),
754 "it should be cached as a failure, not as a compiled regex"
755 );
756 }
757
758 #[test]
759 fn a_regex_that_does_not_compile_matches_nothing() {
760 let m = RouteMatch {
763 path: Some("/v1/(unclosed".into()),
764 path_type: PathMatchType::Regex,
765 ..any()
766 };
767 assert!(!m.matches("h", "/v1/anything", "GET", &HeaderMap::new()));
768 assert!(!m.matches("h", "/v1/(unclosed", "GET", &HeaderMap::new()));
769 }
770
771 #[test]
772 fn methods_are_honoured_case_insensitively() {
773 let m = RouteMatch {
774 methods: Some(vec!["GET".into(), "head".into()]),
775 ..any()
776 };
777 assert!(m.matches("h", "/", "GET", &HeaderMap::new()));
778 assert!(m.matches("h", "/", "get", &HeaderMap::new()));
779 assert!(m.matches("h", "/", "HEAD", &HeaderMap::new()));
780 assert!(!m.matches("h", "/", "DELETE", &HeaderMap::new()));
782 assert!(!m.matches("h", "/", "POST", &HeaderMap::new()));
783 }
784
785 #[test]
786 fn an_empty_method_list_means_any() {
787 let m = RouteMatch {
788 methods: Some(vec![]),
789 ..any()
790 };
791 assert!(m.matches("h", "/", "PATCH", &HeaderMap::new()));
792 }
793
794 #[test]
795 fn every_named_header_must_be_present_with_that_value() {
796 let m = RouteMatch {
797 headers: HashMap::from([
798 ("x-tenant".to_string(), "acme".to_string()),
799 ("x-env".to_string(), "prod".to_string()),
800 ]),
801 ..any()
802 };
803
804 assert!(m.matches(
805 "h",
806 "/",
807 "GET",
808 &header_map(&[("x-tenant", "acme"), ("x-env", "prod")])
809 ));
810 assert!(!m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
812 assert!(!m.matches(
814 "h",
815 "/",
816 "GET",
817 &header_map(&[("x-tenant", "other"), ("x-env", "prod")])
818 ));
819 assert!(!m.matches("h", "/", "GET", &HeaderMap::new()));
820 }
821
822 #[test]
823 fn header_names_are_case_insensitive() {
824 let m = RouteMatch {
825 headers: HashMap::from([("X-Tenant".to_string(), "acme".to_string())]),
826 ..any()
827 };
828 assert!(m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
829 }
830
831 #[test]
832 fn a_host_matches_exactly_or_by_a_single_label_wildcard() {
833 let exact = RouteMatch {
834 host: Some("api.example.com".into()),
835 ..any()
836 };
837 assert!(exact.matches("api.example.com", "/", "GET", &HeaderMap::new()));
838 assert!(!exact.matches("www.example.com", "/", "GET", &HeaderMap::new()));
839
840 let wild = RouteMatch {
841 host: Some("*.example.com".into()),
842 ..any()
843 };
844 assert!(wild.matches("api.example.com", "/", "GET", &HeaderMap::new()));
845 assert!(!wild.matches("example.com", "/", "GET", &HeaderMap::new()));
847 assert!(!wild.matches("a.b.example.com", "/", "GET", &HeaderMap::new()));
848 assert!(!wild.matches("api.example.com.evil.test", "/", "GET", &HeaderMap::new()));
850 }
851
852 #[test]
853 fn a_star_host_matches_anything() {
854 let m = RouteMatch {
855 host: Some("*".into()),
856 ..any()
857 };
858 assert!(m.matches("anything.test", "/", "GET", &HeaderMap::new()));
859 assert!(m.matches("", "/", "GET", &HeaderMap::new()));
860 }
861
862 #[test]
863 fn host_matching_is_case_insensitive() {
864 let m = RouteMatch {
865 host: Some("API.Example.COM".into()),
866 ..any()
867 };
868 assert!(m.matches("api.example.com", "/", "GET", &HeaderMap::new()));
869 }
870
871 #[test]
872 fn every_set_criterion_has_to_hold() {
873 let m = RouteMatch {
875 host: Some("api.example.com".into()),
876 path: Some("/v1".into()),
877 path_type: PathMatchType::Prefix,
878 methods: Some(vec!["POST".into()]),
879 headers: HashMap::from([("x-key".to_string(), "k".to_string())]),
880 };
881 let headers = header_map(&[("x-key", "k")]);
882
883 assert!(m.matches("api.example.com", "/v1/x", "POST", &headers));
884 assert!(!m.matches("other.example.com", "/v1/x", "POST", &headers));
885 assert!(!m.matches("api.example.com", "/v2/x", "POST", &headers));
886 assert!(!m.matches("api.example.com", "/v1/x", "GET", &headers));
887 assert!(!m.matches("api.example.com", "/v1/x", "POST", &HeaderMap::new()));
888 }
889
890 #[test]
891 fn test_backend_defaults_to_http11() {
892 let backend: Backend = toml::from_str(r#"address = "127.0.0.1:8080""#).unwrap();
894 assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
895 }
896
897 #[test]
898 fn test_backend_h2c_aliases() {
899 for spelling in ["h2c", "h2", "http2"] {
902 let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
903 let backend: Backend = toml::from_str(&toml_src).unwrap();
904 assert_eq!(
905 backend.http_version,
906 UpstreamHttpVersion::H2c,
907 "spelling {spelling} should select h2c"
908 );
909 }
910 }
911
912 #[test]
913 fn test_backend_http11_aliases() {
914 for spelling in ["http11", "http1"] {
915 let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
916 let backend: Backend = toml::from_str(&toml_src).unwrap();
917 assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
918 }
919 }
920
921 #[test]
922 fn test_http2_port_is_optional_and_off_by_default() {
923 let server: ServerConfig = toml::from_str("").unwrap();
924 assert_eq!(server.http2_port, None);
925
926 let server: ServerConfig = toml::from_str("http2_port = 19081").unwrap();
927 assert_eq!(server.http2_port, Some(19081));
928 }
929}