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