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