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 }
262 }
263
264 fn build_container(entries: Vec<(&str, Destination)>) -> DestinationContainer {
265 let mut destinations = BTreeMap::new();
266 let mut default_destination = None;
267 let mut wrappers = Vec::new();
268
269 for (key, destination) in entries {
270 let has_match = destination
271 .r#match
272 .as_ref()
273 .map(|entries| !entries.is_empty())
274 .unwrap_or(false);
275 let wrapper = Arc::new(DestinationWrapper::new(destination, None));
276 if wrapper.destination.default {
277 default_destination = Some(wrapper.clone());
278 }
279 if !has_match {
280 destinations.insert(key.to_string(), Arc::clone(&wrapper));
281 }
282 wrappers.push(wrapper);
284 }
285
286 let matcher = DestinationMatcherIndex::new(wrappers.into_iter()).unwrap();
287
288 DestinationContainer {
289 destinations,
290 default_destination,
291 matcher,
292 }
293 }
294
295 #[test]
296 fn resolves_destination_by_host_match() {
297 let container = build_container(vec![(
298 "customer",
299 destination_config(
300 "customer",
301 Some(DestinationMatchValue::String("support.example.com".into())),
302 None,
303 None,
304 false,
305 ),
306 )]);
307
308 let req = req_with_host_header("support.example.com", "/any");
309 let resolved = container.get_backend_for_request(&req, false).unwrap();
310 assert_eq!(resolved.destination.name, "customer");
311 }
312
313 #[test]
314 fn resolves_destination_by_host_regex() {
315 let container = build_container(vec![(
316 "billing",
317 destination_config(
318 "billing",
319 Some(DestinationMatchValue::Regex {
320 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
321 }),
322 None,
323 None,
324 false,
325 ),
326 )]);
327
328 let req = req_with_host_header("api.eu.example.com", "/billing/pay");
329 let resolved = container.get_backend_for_request(&req, false).unwrap();
330 assert_eq!(resolved.destination.name, "billing");
331 }
332
333 #[test]
334 fn resolves_destination_by_path_prefix() {
335 let container = build_container(vec![(
336 "helpdesk",
337 destination_config(
338 "helpdesk",
339 None,
340 Some(DestinationMatchValue::String("/helpdesk".into())),
341 None,
342 false,
343 ),
344 )]);
345
346 let req = req_with_host_header("any.example.com", "/helpdesk/ticket");
347 let resolved = container.get_backend_for_request(&req, false).unwrap();
348 assert_eq!(resolved.destination.name, "helpdesk");
349 }
350
351 #[test]
352 fn falls_back_to_default_when_no_match() {
353 let container = build_container(vec![(
354 "primary",
355 destination_config(
356 "primary",
357 Some(DestinationMatchValue::String("app.example.com".into())),
358 None,
359 None,
360 true,
361 ),
362 )]);
363
364 let req = req_with_host_header("unknown.example.com", "/unknown");
365 let resolved = container.get_backend_for_request(&req, false).unwrap();
366 assert_eq!(resolved.destination.name, "primary");
367 }
368
369 #[test]
370 fn does_not_reintroduce_non_matching_host_rule() {
371 let mut entries = Vec::new();
372 entries.push((
373 "billing",
374 destination_config(
375 "billing",
376 Some(DestinationMatchValue::String("billing.example.com".into())),
377 Some(DestinationMatchValue::String("/billing".into())),
378 None,
379 false,
380 ),
381 ));
382
383 let default_destination = Destination {
384 name: "fallback".into(),
385 url: "https://fallback.internal".into(),
386 health_check: None,
387 default: true,
388 r#match: None,
389 routes: Vec::new(),
390 middleware: Vec::new(),
391 };
392
393 entries.push(("fallback", default_destination));
394
395 let container = build_container(entries);
396 let req = req_with_host_header("billing.example.com", "/other");
397
398 let resolved = container.get_backend_for_request(&req, false).unwrap();
399 assert_eq!(resolved.destination.name, "fallback");
400 }
401
402 #[test]
403 fn selects_destination_among_shared_host_paths() {
404 let container = build_container(vec![
405 (
406 "billing",
407 destination_config(
408 "billing",
409 Some(DestinationMatchValue::String("api.example.com".into())),
410 Some(DestinationMatchValue::String("/billing".into())),
411 None,
412 false,
413 ),
414 ),
415 (
416 "support",
417 destination_config(
418 "support",
419 Some(DestinationMatchValue::String("api.example.com".into())),
420 Some(DestinationMatchValue::String("/support".into())),
421 None,
422 false,
423 ),
424 ),
425 (
426 "fallback",
427 Destination {
428 name: "fallback".into(),
429 url: "https://fallback.internal".into(),
430 health_check: None,
431 default: true,
432 r#match: None,
433 routes: Vec::new(),
434 middleware: Vec::new(),
435 },
436 ),
437 ]);
438
439 let req = req_with_host_header("api.example.com", "/support/ticket");
440 let resolved = container.get_backend_for_request(&req, false).unwrap();
441 assert_eq!(resolved.destination.name, "support");
442 }
443
444 #[test]
445 fn falls_back_when_shared_host_paths_do_not_match() {
446 let container = build_container(vec![
447 (
448 "billing",
449 destination_config(
450 "billing",
451 Some(DestinationMatchValue::String("api.example.com".into())),
452 Some(DestinationMatchValue::String("/billing".into())),
453 None,
454 false,
455 ),
456 ),
457 (
458 "fallback",
459 Destination {
460 name: "fallback".into(),
461 url: "https://fallback.internal".into(),
462 health_check: None,
463 default: true,
464 r#match: None,
465 routes: Vec::new(),
466 middleware: Vec::new(),
467 },
468 ),
469 ]);
470
471 let req = req_with_host_header("api.example.com", "/reports");
472 let resolved = container.get_backend_for_request(&req, false).unwrap();
473 assert_eq!(resolved.destination.name, "fallback");
474 }
475
476 #[test]
477 fn host_regex_entries_consider_path_rules() {
478 let container = build_container(vec![
479 (
480 "billing",
481 destination_config(
482 "billing",
483 Some(DestinationMatchValue::Regex {
484 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
485 }),
486 Some(DestinationMatchValue::String("/billing".into())),
487 None,
488 false,
489 ),
490 ),
491 (
492 "support",
493 destination_config(
494 "support",
495 Some(DestinationMatchValue::Regex {
496 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
497 }),
498 Some(DestinationMatchValue::String("/support".into())),
499 None,
500 false,
501 ),
502 ),
503 (
504 "fallback",
505 Destination {
506 name: "fallback".into(),
507 url: "https://fallback.internal".into(),
508 health_check: None,
509 default: true,
510 r#match: None,
511 routes: Vec::new(),
512 middleware: Vec::new(),
513 },
514 ),
515 ]);
516
517 let req = req_with_host_header("api.eu.example.com", "/support/chat");
518 let resolved = container.get_backend_for_request(&req, false).unwrap();
519 assert_eq!(resolved.destination.name, "support");
520 }
521
522 #[test]
523 fn hostless_entries_respect_path_order() {
524 let container = build_container(vec![
525 (
526 "reports",
527 destination_config(
528 "reports",
529 None,
530 Some(DestinationMatchValue::Regex {
531 regex: "^/reports/(daily|weekly)".into(),
532 }),
533 None,
534 false,
535 ),
536 ),
537 (
538 "billing",
539 destination_config(
540 "billing",
541 None,
542 Some(DestinationMatchValue::String("/billing".into())),
543 None,
544 false,
545 ),
546 ),
547 (
548 "fallback",
549 Destination {
550 name: "fallback".into(),
551 url: "https://fallback.internal".into(),
552 health_check: None,
553 default: true,
554 r#match: None,
555 routes: Vec::new(),
556 middleware: Vec::new(),
557 },
558 ),
559 ]);
560
561 let req_reports = req_with_host_header("any.example.com", "/reports/daily/summary");
562 let resolved_reports = container
563 .get_backend_for_request(&req_reports, false)
564 .unwrap();
565 assert_eq!(resolved_reports.destination.name, "reports");
566
567 let req_billing = req_with_host_header("any.example.com", "/billing/invoice");
568 let resolved_billing = container
569 .get_backend_for_request(&req_billing, false)
570 .unwrap();
571 assert_eq!(resolved_billing.destination.name, "billing");
572
573 let req_fallback = req_with_host_header("any.example.com", "/unknown");
574 let resolved_fallback = container
575 .get_backend_for_request(&req_fallback, false)
576 .unwrap();
577 assert_eq!(resolved_fallback.destination.name, "fallback");
578 }
579
580 #[test]
581 fn force_parameter_ignores_match_enabled_destinations() {
582 let container = build_container(vec![
583 (
584 "matched",
585 destination_config(
586 "matched",
587 Some(DestinationMatchValue::String("api.example.com".into())),
588 Some(DestinationMatchValue::String("/matched".into())),
589 None,
590 false,
591 ),
592 ),
593 (
594 "fallback",
595 Destination {
596 name: "fallback".into(),
597 url: "https://fallback.internal".into(),
598 health_check: None,
599 default: true,
600 r#match: None,
601 routes: Vec::new(),
602 middleware: Vec::new(),
603 },
604 ),
605 ]);
606
607 let req = req_with_path("/matched/orders");
608 let resolved = container.get_backend_for_request(&req, true).unwrap();
609 assert_eq!(resolved.destination.name, "fallback");
610 }
611
612 #[test]
613 fn path_exact_precedence_over_prefix() {
614 let container = build_container(vec![
615 (
616 "status_exact",
617 destination_config(
618 "status_exact",
619 Some(DestinationMatchValue::String("status.example.com".into())),
620 None,
621 Some("/status"),
622 false,
623 ),
624 ),
625 (
626 "status_prefix",
627 destination_config(
628 "status_prefix",
629 Some(DestinationMatchValue::String("status.example.com".into())),
630 Some(DestinationMatchValue::String("/status".into())),
631 None,
632 false,
633 ),
634 ),
635 ]);
636
637 let req = req_with_host_header("status.example.com", "/status");
638 let resolved = container.get_backend_for_request(&req, false).unwrap();
639 assert_eq!(resolved.destination.name, "status_exact");
640 }
641
642 #[test]
643 fn regex_host_prefers_matching_path_before_default() {
644 let container = build_container(vec![
645 (
646 "v1",
647 destination_config(
648 "v1",
649 Some(DestinationMatchValue::Regex {
650 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
651 }),
652 Some(DestinationMatchValue::String("/v1".into())),
653 None,
654 false,
655 ),
656 ),
657 (
658 "v2",
659 destination_config(
660 "v2",
661 Some(DestinationMatchValue::Regex {
662 regex: "^api\\.(eu|us)\\.example\\.com$".into(),
663 }),
664 Some(DestinationMatchValue::String("/v2".into())),
665 None,
666 false,
667 ),
668 ),
669 (
670 "fallback",
671 Destination {
672 name: "fallback".into(),
673 url: "https://fallback.internal".into(),
674 health_check: None,
675 default: true,
676 r#match: None,
677 routes: Vec::new(),
678 middleware: Vec::new(),
679 },
680 ),
681 ]);
682
683 let req_v2 = req_with_host_header("api.eu.example.com", "/v2/items");
684 let resolved_v2 = container.get_backend_for_request(&req_v2, false).unwrap();
685 assert_eq!(resolved_v2.destination.name, "v2");
686
687 let req_none = req_with_host_header("api.eu.example.com", "/v3/unknown");
688 let resolved_none = container.get_backend_for_request(&req_none, false).unwrap();
689 assert_eq!(resolved_none.destination.name, "fallback");
690 }
691
692 #[test]
693 fn hostless_entries_prioritize_config_order() {
694 let container = build_container(vec![
695 (
696 "reports_regex",
697 destination_config(
698 "reports_regex",
699 None,
700 Some(DestinationMatchValue::Regex {
701 regex: "^/reports/.*".into(),
702 }),
703 None,
704 false,
705 ),
706 ),
707 (
708 "reports_prefix",
709 destination_config(
710 "reports_prefix",
711 None,
712 Some(DestinationMatchValue::String("/reports".into())),
713 None,
714 false,
715 ),
716 ),
717 ]);
718
719 let req = req_with_host_header("any.example.com", "/reports/daily");
720 let resolved = container.get_backend_for_request(&req, false).unwrap();
721 assert_eq!(resolved.destination.name, "reports_regex");
722 }
723
724 #[test]
725 fn force_parameter_falls_back_to_default_when_unknown() {
726 let container = build_container(vec![(
727 "fallback",
728 Destination {
729 name: "fallback".into(),
730 url: "https://fallback.internal".into(),
731 health_check: None,
732 default: true,
733 r#match: None,
734 routes: Vec::new(),
735 middleware: Vec::new(),
736 },
737 )]);
738
739 let req = req_with_path("/unknown/path");
740 let resolved = container.get_backend_for_request(&req, true).unwrap();
741 assert_eq!(resolved.destination.name, "fallback");
742 }
743
744 #[test]
745 fn returns_none_when_no_match_and_no_default() {
746 let container = build_container(vec![(
747 "matcher_only",
748 destination_config(
749 "matcher_only",
750 Some(DestinationMatchValue::String("api.example.com".into())),
751 Some(DestinationMatchValue::String("/matcher".into())),
752 None,
753 false,
754 ),
755 )]);
756
757 let req = req_with_host_header("api.example.com", "/unknown");
758 let resolved = container.get_backend_for_request(&req, false);
759 assert!(resolved.is_none());
760 }
761
762 #[test]
763 fn multi_match_destination_skips_legacy_map() {
764 let destination = Destination {
765 name: "shared".into(),
766 url: "https://shared.internal".into(),
767 health_check: None,
768 default: false,
769 r#match: Some(vec![
770 DestinationMatch {
771 host: Some(DestinationMatchValue::String("api.example.com".into())),
772 path_prefix: Some(DestinationMatchValue::String("/billing".into())),
773 path_exact: None,
774 },
775 DestinationMatch {
776 host: Some(DestinationMatchValue::Regex {
777 regex: "^api\\..+".into(),
778 }),
779 path_prefix: Some(DestinationMatchValue::String("/regex".into())),
780 path_exact: None,
781 },
782 ]),
783 routes: Vec::new(),
784 middleware: Vec::new(),
785 };
786
787 let container = build_container(vec![("shared", destination)]);
788
789 assert!(container.destinations.get("shared").is_none());
790
791 let exact_req = req_with_host_header("api.example.com", "/billing/invoices");
792 let exact_resolved = container
793 .get_backend_for_request(&exact_req, false)
794 .unwrap();
795 assert_eq!(exact_resolved.destination.name, "shared");
796
797 let regex_req = req_with_host_header("api.example.com", "/regex/search");
798 let regex_resolved = container
799 .get_backend_for_request(®ex_req, false)
800 .unwrap();
801 assert_eq!(regex_resolved.destination.name, "shared");
802 }
803
804 #[test]
805 fn force_parameter_uses_path_segment_lookup() {
806 let destination = Destination {
807 name: "segment".into(),
808 url: "https://segment.internal".into(),
809 health_check: None,
810 default: false,
811 r#match: None,
812 routes: Vec::new(),
813 middleware: Vec::new(),
814 };
815
816 let container = build_container(vec![("segment", destination)]);
817 let req = req_with_path("/segment/orders");
818
819 let resolved = container.get_backend_for_request(&req, true).unwrap();
820 assert_eq!(resolved.destination.name, "segment");
821 }
822
823 #[test]
824 fn falls_back_to_subdomain_key_when_present() {
825 let destination = Destination {
826 name: "api".into(),
827 url: "https://api.internal".into(),
828 health_check: None,
829 default: false,
830 r#match: None,
831 routes: Vec::new(),
832 middleware: Vec::new(),
833 };
834
835 let container = build_container(vec![("api", destination)]);
836 let req = req_with_host_header("api.mygateway.com", "/any");
837
838 let resolved = container.get_backend_for_request(&req, false).unwrap();
839 assert_eq!(resolved.destination.name, "api");
840 }
841}