cardinal_base/destinations/
container.rs

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            // Every destination participates in the matcher, even if it also lives in the
116            // legacy map (for matcher-less configs).
117            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    // Only treat as valid when there is a true subdomain: at least sub.domain.tld
149    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            // The matcher should see every destination regardless of legacy eligibility.
283            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(&regex_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}