1use crate::context::CardinalContext;
2use crate::destinations::matcher::DestinationMatcherIndex;
3use crate::provider::Provider;
4use crate::router::CardinalRouter;
5use async_trait::async_trait;
6use cardinal_config::{Destination, Middleware, MiddlewareType};
7use cardinal_errors::CardinalError;
8use pingora::http::RequestHeader;
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12pub struct DestinationWrapper {
13 pub destination: Destination,
14 pub router: CardinalRouter,
15 pub has_routes: bool,
16 inbound_middleware: Vec<Middleware>,
17 outbound_middleware: Vec<Middleware>,
18}
19
20impl DestinationWrapper {
21 pub fn new(destination: Destination, router: Option<CardinalRouter>) -> Self {
22 let inbound_middleware = destination
23 .middleware
24 .iter()
25 .filter(|&e| e.r#type == MiddlewareType::Inbound)
26 .cloned()
27 .collect();
28 let outbound_middleware = destination
29 .middleware
30 .iter()
31 .filter(|&e| e.r#type == MiddlewareType::Outbound)
32 .cloned()
33 .collect();
34
35 Self {
36 has_routes: !destination.routes.is_empty(),
37 destination,
38 router: router.unwrap_or_default(),
39 inbound_middleware,
40 outbound_middleware,
41 }
42 }
43
44 pub fn get_inbound_middleware(&self) -> &Vec<Middleware> {
45 &self.inbound_middleware
46 }
47
48 pub fn get_outbound_middleware(&self) -> &Vec<Middleware> {
49 &self.outbound_middleware
50 }
51}
52
53pub struct DestinationContainer {
54 destinations: BTreeMap<String, Arc<DestinationWrapper>>,
55 default_destination: Option<Arc<DestinationWrapper>>,
56 matcher: DestinationMatcherIndex,
57}
58
59impl DestinationContainer {
60 pub fn get_backend_for_request(
61 &self,
62 req: &RequestHeader,
63 force_parameter: bool,
64 ) -> Option<Arc<DestinationWrapper>> {
65 let matcher_hit = if force_parameter {
66 None
67 } else {
68 self.matcher.resolve(req)
69 };
70
71 matcher_hit.or_else(|| {
72 let candidate = if force_parameter {
73 first_path_segment(req)
74 } else {
75 extract_subdomain(req)
76 };
77
78 candidate
79 .and_then(|key| self.destinations.get(&key).cloned())
80 .or_else(|| self.default_destination.clone())
81 })
82 }
83}
84
85#[async_trait]
86impl Provider for DestinationContainer {
87 async fn provide(ctx: &CardinalContext) -> Result<Self, CardinalError> {
88 let mut destinations: BTreeMap<String, Arc<DestinationWrapper>> = BTreeMap::new();
89 let mut default_destination = None;
90 let mut wrappers: Vec<Arc<DestinationWrapper>> = Vec::new();
91
92 for (key, destination) in ctx.config.destinations.clone() {
93 let has_match = destination
94 .r#match
95 .as_ref()
96 .map(|entries| !entries.is_empty())
97 .unwrap_or(false);
98 let router = destination
99 .routes
100 .iter()
101 .fold(CardinalRouter::new(), |mut r, route| {
102 let _ = r.add(route.method.as_str(), route.path.as_str());
103 r
104 });
105
106 let wrapper = Arc::new(DestinationWrapper::new(destination, Some(router)));
107
108 if wrapper.destination.default {
109 default_destination = Some(wrapper.clone());
110 }
111
112 if !has_match {
113 destinations.insert(key, Arc::clone(&wrapper));
114 }
115 wrappers.push(wrapper);
118 }
119
120 let matcher = DestinationMatcherIndex::new(wrappers.into_iter())?;
121
122 Ok(Self {
123 destinations,
124 default_destination,
125 matcher,
126 })
127 }
128}
129
130fn first_path_segment(req: &RequestHeader) -> Option<String> {
131 let path = req.uri.path();
132 path.strip_prefix('/')
133 .and_then(|p| p.split('/').next())
134 .filter(|s| !s.is_empty())
135 .map(|s| s.to_ascii_lowercase())
136}
137
138fn extract_subdomain(req: &RequestHeader) -> Option<String> {
139 let host = req.uri.host().map(|h| h.to_string()).or_else(|| {
140 req.headers
141 .get("host")
142 .and_then(|v| v.to_str().ok())
143 .map(|s| s.to_string())
144 })?;
145
146 let host_no_port = host.split(':').next()?.to_ascii_lowercase();
147
148 let parts: Vec<&str> = host_no_port.split('.').collect();
150 if parts.len() < 3 {
151 return None;
152 }
153
154 let first = parts[0];
155 if first.is_empty() || first == "www" {
156 None
157 } else {
158 Some(first.to_string())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use cardinal_config::{Destination, DestinationMatch, DestinationMatchValue};
166 use http::{Method, Uri};
167 use std::collections::BTreeMap;
168 use std::sync::Arc;
169
170 fn req_with_path(pq: &str) -> RequestHeader {
171 RequestHeader::build(Method::GET, pq.as_bytes(), None).unwrap()
172 }
173
174 #[test]
175 fn first_segment_basic() {
176 let req = req_with_path("/api/users");
177 assert_eq!(first_path_segment(&req), Some("api".to_string()));
178 }
179
180 #[test]
181 fn first_segment_root_none() {
182 let req = req_with_path("/");
183 assert_eq!(first_path_segment(&req), None);
184 }
185
186 #[test]
187 fn first_segment_case_insensitive() {
188 let req = req_with_path("/API/v1");
189 assert_eq!(first_path_segment(&req), Some("api".to_string()));
190 }
191
192 #[test]
193 fn first_segment_trailing_slash() {
194 let req = req_with_path("/api/");
195 assert_eq!(first_path_segment(&req), Some("api".to_string()));
196 }
197
198 fn req_with_host_header(host: &str, path: &str) -> RequestHeader {
199 let mut req = req_with_path(path);
200 req.insert_header("host", host).unwrap();
201 req
202 }
203
204 #[test]
205 fn subdomain_from_host_header_basic() {
206 let req = req_with_host_header("api.mygateway.com", "/any");
207 assert_eq!(extract_subdomain(&req), Some("api".to_string()));
208 }
209
210 #[test]
211 fn subdomain_from_host_header_with_port() {
212 let req = req_with_host_header("api.mygateway.com:8080", "/any");
213 assert_eq!(extract_subdomain(&req), Some("api".to_string()));
214 }
215
216 #[test]
217 fn subdomain_www_is_ignored() {
218 let req = req_with_host_header("www.mygateway.com", "/any");
219 assert_eq!(extract_subdomain(&req), None);
220 }
221
222 #[test]
223 fn subdomain_requires_at_least_domain_and_tld() {
224 let req = req_with_host_header("localhost", "/any");
225 assert_eq!(extract_subdomain(&req), None);
226 }
227
228 #[test]
229 fn apex_domain_returns_none() {
230 let req = req_with_host_header("mygateway.com", "/any");
231 assert_eq!(extract_subdomain(&req), None);
232 }
233
234 #[test]
235 fn subdomain_from_uri_authority() {
236 let mut req = req_with_path("/any");
237 let uri: Uri = "http://API.Example.com/any".parse().unwrap();
238 req.set_uri(uri);
239 assert_eq!(extract_subdomain(&req), Some("api".to_string()));
240 }
241
242 fn destination_config(
243 name: &str,
244 host: Option<DestinationMatchValue>,
245 path_prefix: Option<DestinationMatchValue>,
246 path_exact: Option<&str>,
247 default: bool,
248 ) -> Destination {
249 Destination {
250 name: name.to_string(),
251 url: format!("https://{name}.internal"),
252 health_check: None,
253 default,
254 r#match: Some(vec![DestinationMatch {
255 host,
256 path_prefix,
257 path_exact: path_exact.map(|s| s.to_string()),
258 }]),
259 routes: Vec::new(),
260 middleware: Vec::new(),
261 timeout: None,
262 retry: None,
263 }
264 }
265
266 fn build_container(entries: Vec<(&str, Destination)>) -> DestinationContainer {
267 let mut destinations = BTreeMap::new();
268 let mut default_destination = None;
269 let mut wrappers = Vec::new();
270
271 for (key, destination) in entries {
272 let has_match = destination
273 .r#match
274 .as_ref()
275 .map(|entries| !entries.is_empty())
276 .unwrap_or(false);
277 let wrapper = Arc::new(DestinationWrapper::new(destination, None));
278 if wrapper.destination.default {
279 default_destination = Some(wrapper.clone());
280 }
281 if !has_match {
282 destinations.insert(key.to_string(), Arc::clone(&wrapper));
283 }
284 wrappers.push(wrapper);
286 }
287
288 let matcher = DestinationMatcherIndex::new(wrappers.into_iter()).unwrap();
289
290 DestinationContainer {
291 destinations,
292 default_destination,
293 matcher,
294 }
295 }
296
297 #[test]
298 fn resolves_destination_by_host_match() {
299 let container = build_container(vec![(
300 "customer",
301 destination_config(
302 "customer",
303 Some(DestinationMatchValue::String("support.example.com".into())),
304 None,
305 None,
306 false,
307 ),
308 )]);
309
310 let req = req_with_host_header("support.example.com", "/any");
311 let resolved = container.get_backend_for_request(&req, false).unwrap();
312 assert_eq!(resolved.destination.name, "customer");
313 }
314
315 #[test]
316 fn resolves_destination_by_host_regex() {
317 let container = build_container(vec![(
318 "billing",
319 destination_config(
320 "billing",
321 Some(DestinationMatchValue::Regex {
322 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
323 }),
324 None,
325 None,
326 false,
327 ),
328 )]);
329
330 let req = req_with_host_header("api.eu.example.com", "/billing/pay");
331 let resolved = container.get_backend_for_request(&req, false).unwrap();
332 assert_eq!(resolved.destination.name, "billing");
333 }
334
335 #[test]
336 fn resolves_destination_by_path_prefix() {
337 let container = build_container(vec![(
338 "helpdesk",
339 destination_config(
340 "helpdesk",
341 None,
342 Some(DestinationMatchValue::String("/helpdesk".into())),
343 None,
344 false,
345 ),
346 )]);
347
348 let req = req_with_host_header("any.example.com", "/helpdesk/ticket");
349 let resolved = container.get_backend_for_request(&req, false).unwrap();
350 assert_eq!(resolved.destination.name, "helpdesk");
351 }
352
353 #[test]
354 fn falls_back_to_default_when_no_match() {
355 let container = build_container(vec![(
356 "primary",
357 destination_config(
358 "primary",
359 Some(DestinationMatchValue::String("app.example.com".into())),
360 None,
361 None,
362 true,
363 ),
364 )]);
365
366 let req = req_with_host_header("unknown.example.com", "/unknown");
367 let resolved = container.get_backend_for_request(&req, false).unwrap();
368 assert_eq!(resolved.destination.name, "primary");
369 }
370
371 #[test]
372 fn does_not_reintroduce_non_matching_host_rule() {
373 let mut entries = Vec::new();
374 entries.push((
375 "billing",
376 destination_config(
377 "billing",
378 Some(DestinationMatchValue::String("billing.example.com".into())),
379 Some(DestinationMatchValue::String("/billing".into())),
380 None,
381 false,
382 ),
383 ));
384
385 let default_destination = Destination {
386 name: "fallback".into(),
387 url: "https://fallback.internal".into(),
388 health_check: None,
389 default: true,
390 r#match: None,
391 routes: Vec::new(),
392 middleware: Vec::new(),
393 timeout: None,
394 retry: None,
395 };
396
397 entries.push(("fallback", default_destination));
398
399 let container = build_container(entries);
400 let req = req_with_host_header("billing.example.com", "/other");
401
402 let resolved = container.get_backend_for_request(&req, false).unwrap();
403 assert_eq!(resolved.destination.name, "fallback");
404 }
405
406 #[test]
407 fn selects_destination_among_shared_host_paths() {
408 let container = build_container(vec![
409 (
410 "billing",
411 destination_config(
412 "billing",
413 Some(DestinationMatchValue::String("api.example.com".into())),
414 Some(DestinationMatchValue::String("/billing".into())),
415 None,
416 false,
417 ),
418 ),
419 (
420 "support",
421 destination_config(
422 "support",
423 Some(DestinationMatchValue::String("api.example.com".into())),
424 Some(DestinationMatchValue::String("/support".into())),
425 None,
426 false,
427 ),
428 ),
429 (
430 "fallback",
431 Destination {
432 name: "fallback".into(),
433 url: "https://fallback.internal".into(),
434 health_check: None,
435 default: true,
436 r#match: None,
437 routes: Vec::new(),
438 middleware: Vec::new(),
439 timeout: None,
440 retry: None,
441 },
442 ),
443 ]);
444
445 let req = req_with_host_header("api.example.com", "/support/ticket");
446 let resolved = container.get_backend_for_request(&req, false).unwrap();
447 assert_eq!(resolved.destination.name, "support");
448 }
449
450 #[test]
451 fn falls_back_when_shared_host_paths_do_not_match() {
452 let container = build_container(vec![
453 (
454 "billing",
455 destination_config(
456 "billing",
457 Some(DestinationMatchValue::String("api.example.com".into())),
458 Some(DestinationMatchValue::String("/billing".into())),
459 None,
460 false,
461 ),
462 ),
463 (
464 "fallback",
465 Destination {
466 name: "fallback".into(),
467 url: "https://fallback.internal".into(),
468 health_check: None,
469 default: true,
470 r#match: None,
471 routes: Vec::new(),
472 middleware: Vec::new(),
473 timeout: None,
474 retry: None,
475 },
476 ),
477 ]);
478
479 let req = req_with_host_header("api.example.com", "/reports");
480 let resolved = container.get_backend_for_request(&req, false).unwrap();
481 assert_eq!(resolved.destination.name, "fallback");
482 }
483
484 #[test]
485 fn host_regex_entries_consider_path_rules() {
486 let container = build_container(vec![
487 (
488 "billing",
489 destination_config(
490 "billing",
491 Some(DestinationMatchValue::Regex {
492 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
493 }),
494 Some(DestinationMatchValue::String("/billing".into())),
495 None,
496 false,
497 ),
498 ),
499 (
500 "support",
501 destination_config(
502 "support",
503 Some(DestinationMatchValue::Regex {
504 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
505 }),
506 Some(DestinationMatchValue::String("/support".into())),
507 None,
508 false,
509 ),
510 ),
511 (
512 "fallback",
513 Destination {
514 name: "fallback".into(),
515 url: "https://fallback.internal".into(),
516 health_check: None,
517 default: true,
518 r#match: None,
519 routes: Vec::new(),
520 middleware: Vec::new(),
521 timeout: None,
522 retry: None,
523 },
524 ),
525 ]);
526
527 let req = req_with_host_header("api.eu.example.com", "/support/chat");
528 let resolved = container.get_backend_for_request(&req, false).unwrap();
529 assert_eq!(resolved.destination.name, "support");
530 }
531
532 #[test]
533 fn hostless_entries_respect_path_order() {
534 let container = build_container(vec![
535 (
536 "reports",
537 destination_config(
538 "reports",
539 None,
540 Some(DestinationMatchValue::Regex {
541 regex: "^/reports/(daily|weekly)".into(),
542 }),
543 None,
544 false,
545 ),
546 ),
547 (
548 "billing",
549 destination_config(
550 "billing",
551 None,
552 Some(DestinationMatchValue::String("/billing".into())),
553 None,
554 false,
555 ),
556 ),
557 (
558 "fallback",
559 Destination {
560 name: "fallback".into(),
561 url: "https://fallback.internal".into(),
562 health_check: None,
563 default: true,
564 r#match: None,
565 routes: Vec::new(),
566 middleware: Vec::new(),
567 timeout: None,
568 retry: None,
569 },
570 ),
571 ]);
572
573 let req_reports = req_with_host_header("any.example.com", "/reports/daily/summary");
574 let resolved_reports = container
575 .get_backend_for_request(&req_reports, false)
576 .unwrap();
577 assert_eq!(resolved_reports.destination.name, "reports");
578
579 let req_billing = req_with_host_header("any.example.com", "/billing/invoice");
580 let resolved_billing = container
581 .get_backend_for_request(&req_billing, false)
582 .unwrap();
583 assert_eq!(resolved_billing.destination.name, "billing");
584
585 let req_fallback = req_with_host_header("any.example.com", "/unknown");
586 let resolved_fallback = container
587 .get_backend_for_request(&req_fallback, false)
588 .unwrap();
589 assert_eq!(resolved_fallback.destination.name, "fallback");
590 }
591
592 #[test]
593 fn force_parameter_ignores_match_enabled_destinations() {
594 let container = build_container(vec![
595 (
596 "matched",
597 destination_config(
598 "matched",
599 Some(DestinationMatchValue::String("api.example.com".into())),
600 Some(DestinationMatchValue::String("/matched".into())),
601 None,
602 false,
603 ),
604 ),
605 (
606 "fallback",
607 Destination {
608 name: "fallback".into(),
609 url: "https://fallback.internal".into(),
610 health_check: None,
611 default: true,
612 r#match: None,
613 routes: Vec::new(),
614 middleware: Vec::new(),
615 timeout: None,
616 retry: None,
617 },
618 ),
619 ]);
620
621 let req = req_with_path("/matched/orders");
622 let resolved = container.get_backend_for_request(&req, true).unwrap();
623 assert_eq!(resolved.destination.name, "fallback");
624 }
625
626 #[test]
627 fn path_exact_precedence_over_prefix() {
628 let container = build_container(vec![
629 (
630 "status_exact",
631 destination_config(
632 "status_exact",
633 Some(DestinationMatchValue::String("status.example.com".into())),
634 None,
635 Some("/status"),
636 false,
637 ),
638 ),
639 (
640 "status_prefix",
641 destination_config(
642 "status_prefix",
643 Some(DestinationMatchValue::String("status.example.com".into())),
644 Some(DestinationMatchValue::String("/status".into())),
645 None,
646 false,
647 ),
648 ),
649 ]);
650
651 let req = req_with_host_header("status.example.com", "/status");
652 let resolved = container.get_backend_for_request(&req, false).unwrap();
653 assert_eq!(resolved.destination.name, "status_exact");
654 }
655
656 #[test]
657 fn regex_host_prefers_matching_path_before_default() {
658 let container = build_container(vec![
659 (
660 "v1",
661 destination_config(
662 "v1",
663 Some(DestinationMatchValue::Regex {
664 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
665 }),
666 Some(DestinationMatchValue::String("/v1".into())),
667 None,
668 false,
669 ),
670 ),
671 (
672 "v2",
673 destination_config(
674 "v2",
675 Some(DestinationMatchValue::Regex {
676 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
677 }),
678 Some(DestinationMatchValue::String("/v2".into())),
679 None,
680 false,
681 ),
682 ),
683 (
684 "fallback",
685 Destination {
686 name: "fallback".into(),
687 url: "https://fallback.internal".into(),
688 health_check: None,
689 default: true,
690 r#match: None,
691 routes: Vec::new(),
692 middleware: Vec::new(),
693 timeout: None,
694 retry: None,
695 },
696 ),
697 ]);
698
699 let req_v2 = req_with_host_header("api.eu.example.com", "/v2/items");
700 let resolved_v2 = container.get_backend_for_request(&req_v2, false).unwrap();
701 assert_eq!(resolved_v2.destination.name, "v2");
702
703 let req_none = req_with_host_header("api.eu.example.com", "/v3/unknown");
704 let resolved_none = container.get_backend_for_request(&req_none, false).unwrap();
705 assert_eq!(resolved_none.destination.name, "fallback");
706 }
707
708 #[test]
709 fn hostless_entries_prioritize_config_order() {
710 let container = build_container(vec![
711 (
712 "reports_regex",
713 destination_config(
714 "reports_regex",
715 None,
716 Some(DestinationMatchValue::Regex {
717 regex: "^/reports/.*".into(),
718 }),
719 None,
720 false,
721 ),
722 ),
723 (
724 "reports_prefix",
725 destination_config(
726 "reports_prefix",
727 None,
728 Some(DestinationMatchValue::String("/reports".into())),
729 None,
730 false,
731 ),
732 ),
733 ]);
734
735 let req = req_with_host_header("any.example.com", "/reports/daily");
736 let resolved = container.get_backend_for_request(&req, false).unwrap();
737 assert_eq!(resolved.destination.name, "reports_regex");
738 }
739
740 #[test]
741 fn force_parameter_falls_back_to_default_when_unknown() {
742 let container = build_container(vec![(
743 "fallback",
744 Destination {
745 name: "fallback".into(),
746 url: "https://fallback.internal".into(),
747 health_check: None,
748 default: true,
749 r#match: None,
750 routes: Vec::new(),
751 middleware: Vec::new(),
752 timeout: None,
753 retry: None,
754 },
755 )]);
756
757 let req = req_with_path("/unknown/path");
758 let resolved = container.get_backend_for_request(&req, true).unwrap();
759 assert_eq!(resolved.destination.name, "fallback");
760 }
761
762 #[test]
763 fn returns_none_when_no_match_and_no_default() {
764 let container = build_container(vec![(
765 "matcher_only",
766 destination_config(
767 "matcher_only",
768 Some(DestinationMatchValue::String("api.example.com".into())),
769 Some(DestinationMatchValue::String("/matcher".into())),
770 None,
771 false,
772 ),
773 )]);
774
775 let req = req_with_host_header("api.example.com", "/unknown");
776 let resolved = container.get_backend_for_request(&req, false);
777 assert!(resolved.is_none());
778 }
779
780 #[test]
781 fn multi_match_destination_skips_legacy_map() {
782 let destination = Destination {
783 name: "shared".into(),
784 url: "https://shared.internal".into(),
785 health_check: None,
786 default: false,
787 r#match: Some(vec![
788 DestinationMatch {
789 host: Some(DestinationMatchValue::String("api.example.com".into())),
790 path_prefix: Some(DestinationMatchValue::String("/billing".into())),
791 path_exact: None,
792 },
793 DestinationMatch {
794 host: Some(DestinationMatchValue::Regex {
795 regex: "^api\\..+".into(),
796 }),
797 path_prefix: Some(DestinationMatchValue::String("/regex".into())),
798 path_exact: None,
799 },
800 ]),
801 routes: Vec::new(),
802 middleware: Vec::new(),
803 timeout: None,
804 retry: None,
805 };
806
807 let container = build_container(vec![("shared", destination)]);
808
809 assert!(container.destinations.get("shared").is_none());
810
811 let exact_req = req_with_host_header("api.example.com", "/billing/invoices");
812 let exact_resolved = container
813 .get_backend_for_request(&exact_req, false)
814 .unwrap();
815 assert_eq!(exact_resolved.destination.name, "shared");
816
817 let regex_req = req_with_host_header("api.example.com", "/regex/search");
818 let regex_resolved = container
819 .get_backend_for_request(®ex_req, false)
820 .unwrap();
821 assert_eq!(regex_resolved.destination.name, "shared");
822 }
823
824 #[test]
825 fn force_parameter_uses_path_segment_lookup() {
826 let destination = Destination {
827 name: "segment".into(),
828 url: "https://segment.internal".into(),
829 health_check: None,
830 default: false,
831 r#match: None,
832 routes: Vec::new(),
833 middleware: Vec::new(),
834 timeout: None,
835 retry: None,
836 };
837
838 let container = build_container(vec![("segment", destination)]);
839 let req = req_with_path("/segment/orders");
840
841 let resolved = container.get_backend_for_request(&req, true).unwrap();
842 assert_eq!(resolved.destination.name, "segment");
843 }
844
845 #[test]
846 fn falls_back_to_subdomain_key_when_present() {
847 let destination = Destination {
848 name: "api".into(),
849 url: "https://api.internal".into(),
850 health_check: None,
851 default: false,
852 r#match: None,
853 routes: Vec::new(),
854 middleware: Vec::new(),
855 timeout: None,
856 retry: None,
857 };
858
859 let container = build_container(vec![("api", destination)]);
860 let req = req_with_host_header("api.mygateway.com", "/any");
861
862 let resolved = container.get_backend_for_request(&req, false).unwrap();
863 assert_eq!(resolved.destination.name, "api");
864 }
865}