cardinal_base/destinations/
matcher.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use cardinal_config::{DestinationMatch, DestinationMatchValue};
5use cardinal_errors::CardinalError;
6use pingora::http::RequestHeader;
7use regex::Regex;
8
9use crate::destinations::container::DestinationWrapper;
10
11pub struct DestinationMatcherIndex {
12    exact_host: HashMap<String, Vec<CompiledDestination>>,
13    regex_host: Vec<RegexHostEntry>,
14    hostless: Vec<CompiledDestination>,
15}
16
17impl DestinationMatcherIndex {
18    pub fn new(
19        destinations: impl Iterator<Item = Arc<DestinationWrapper>>,
20    ) -> Result<Self, CardinalError> {
21        let mut exact_host: HashMap<String, Vec<CompiledDestination>> = HashMap::new();
22        let mut regex_host: Vec<RegexHostEntry> = Vec::new();
23        let mut hostless: Vec<CompiledDestination> = Vec::new();
24
25        for wrapper in destinations {
26            let Some(matchers) = wrapper.destination.r#match.as_ref() else {
27                continue;
28            };
29
30            if matchers.is_empty() {
31                continue;
32            }
33
34            for matcher in matchers {
35                let compiled = CompiledEntry::try_from(wrapper.clone(), matcher)?;
36                match compiled.host_matcher {
37                    Some(CompiledHostMatcher::Exact(host)) => {
38                        exact_host
39                            .entry(host)
40                            .or_default()
41                            .push(compiled.destination);
42                    }
43                    Some(CompiledHostMatcher::Regex(regex)) => {
44                        regex_host.push(RegexHostEntry {
45                            matcher: regex,
46                            destination: compiled.destination,
47                        });
48                    }
49                    None => hostless.push(compiled.destination),
50                }
51            }
52        }
53
54        Ok(Self {
55            exact_host,
56            regex_host,
57            hostless,
58        })
59    }
60
61    pub fn resolve(&self, req: &RequestHeader) -> Option<Arc<DestinationWrapper>> {
62        let host = request_host(req);
63        let path = req.uri.path();
64
65        if let Some(host) = host.as_deref() {
66            if let Some(entries) = self.exact_host.get(host) {
67                // Exact host matches can still vary by path (e.g. /billing vs /support).
68                // Walk the candidates and keep the first whose path rules apply.
69                if let Some(wrapper) = entries
70                    .iter()
71                    .find_map(|destination| destination.matches(path))
72                {
73                    return Some(wrapper);
74                }
75            }
76
77            for entry in &self.regex_host {
78                if entry.matcher.is_match(host) {
79                    if let Some(wrapper) = entry.destination.matches(path) {
80                        return Some(wrapper);
81                    }
82                }
83            }
84        }
85
86        for destination in &self.hostless {
87            if let Some(wrapper) = destination.matches(path) {
88                return Some(wrapper);
89            }
90        }
91
92        None
93    }
94}
95
96struct RegexHostEntry {
97    matcher: Regex,
98    destination: CompiledDestination,
99}
100
101struct CompiledEntry {
102    host_matcher: Option<CompiledHostMatcher>,
103    destination: CompiledDestination,
104}
105
106impl CompiledEntry {
107    fn try_from(
108        wrapper: Arc<DestinationWrapper>,
109        matcher: &DestinationMatch,
110    ) -> Result<Self, CardinalError> {
111        let host_matcher = compile_host_matcher(matcher.host.as_ref())?;
112        let path_prefix = compile_path_prefix(matcher.path_prefix.as_ref())?;
113        let path_exact = matcher.path_exact.clone();
114
115        let destination = CompiledDestination {
116            wrapper,
117            path_prefix,
118            path_exact,
119        };
120
121        Ok(Self {
122            host_matcher,
123            destination,
124        })
125    }
126}
127
128enum CompiledHostMatcher {
129    Exact(String),
130    Regex(Regex),
131}
132
133struct CompiledDestination {
134    wrapper: Arc<DestinationWrapper>,
135    path_prefix: Option<CompiledPathMatcher>,
136    path_exact: Option<String>,
137}
138
139impl CompiledDestination {
140    fn matches(&self, path: &str) -> Option<Arc<DestinationWrapper>> {
141        if self.matches_path(path) {
142            Some(self.wrapper.clone())
143        } else {
144            None
145        }
146    }
147
148    fn matches_path(&self, path: &str) -> bool {
149        if let Some(exact) = &self.path_exact {
150            if path != exact {
151                return false;
152            }
153        }
154
155        if let Some(prefix) = &self.path_prefix {
156            return prefix.matches(path);
157        }
158
159        true
160    }
161}
162
163enum CompiledPathMatcher {
164    Prefix(String),
165    Regex(Regex),
166}
167
168impl CompiledPathMatcher {
169    fn matches(&self, path: &str) -> bool {
170        match self {
171            CompiledPathMatcher::Prefix(prefix) => path.starts_with(prefix),
172            CompiledPathMatcher::Regex(regex) => regex.is_match(path),
173        }
174    }
175}
176
177fn compile_host_matcher(
178    value: Option<&DestinationMatchValue>,
179) -> Result<Option<CompiledHostMatcher>, CardinalError> {
180    match value {
181        Some(DestinationMatchValue::String(host)) => {
182            Ok(Some(CompiledHostMatcher::Exact(host.to_ascii_lowercase())))
183        }
184        Some(DestinationMatchValue::Regex { regex }) => {
185            let compiled = Regex::new(regex).map_err(|err| {
186                CardinalError::Other(format!("invalid host regex '{regex}': {err}"))
187            })?;
188            Ok(Some(CompiledHostMatcher::Regex(compiled)))
189        }
190        None => Ok(None),
191    }
192}
193
194fn compile_path_prefix(
195    value: Option<&DestinationMatchValue>,
196) -> Result<Option<CompiledPathMatcher>, CardinalError> {
197    match value {
198        Some(DestinationMatchValue::String(prefix)) => {
199            Ok(Some(CompiledPathMatcher::Prefix(prefix.clone())))
200        }
201        Some(DestinationMatchValue::Regex { regex }) => {
202            let compiled = Regex::new(regex).map_err(|err| {
203                CardinalError::Other(format!("invalid path regex '{regex}': {err}"))
204            })?;
205            Ok(Some(CompiledPathMatcher::Regex(compiled)))
206        }
207        None => Ok(None),
208    }
209}
210
211fn request_host(req: &RequestHeader) -> Option<String> {
212    let host = req.uri.host().map(|h| h.to_string()).or_else(|| {
213        req.headers
214            .get("host")
215            .and_then(|v| v.to_str().ok())
216            .map(|s| s.to_string())
217    })?;
218
219    let host_no_port = host.split(':').next()?.to_ascii_lowercase();
220    Some(host_no_port)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use cardinal_config::{Destination, DestinationMatch};
227    use http::Method;
228    use pingora::http::RequestHeader;
229
230    fn build_destination(
231        name: &str,
232        host: Option<DestinationMatchValue>,
233        path_prefix: Option<DestinationMatchValue>,
234        path_exact: Option<&str>,
235    ) -> Arc<DestinationWrapper> {
236        build_destination_with_matchers(
237            name,
238            Some(vec![DestinationMatch {
239                host,
240                path_prefix,
241                path_exact: path_exact.map(|s| s.to_string()),
242            }]),
243        )
244    }
245
246    fn build_destination_with_matchers(
247        name: &str,
248        matchers: Option<Vec<DestinationMatch>>,
249    ) -> Arc<DestinationWrapper> {
250        let destination = Destination {
251            name: name.to_string(),
252            url: "https://example.com".to_string(),
253            health_check: None,
254            default: false,
255            r#match: matchers,
256            routes: Vec::new(),
257            middleware: Vec::new(),
258            timeout: None,
259            retry: None,
260        };
261
262        Arc::new(DestinationWrapper::new(destination, None))
263    }
264
265    fn build_request(host: &str, path: &str) -> RequestHeader {
266        let mut req = RequestHeader::build(Method::GET, path.as_bytes(), None).unwrap();
267        req.insert_header("host", host).unwrap();
268        req
269    }
270
271    #[test]
272    fn matches_exact_host() {
273        let destination = build_destination(
274            "customer_service",
275            Some(DestinationMatchValue::String("api.example.com".into())),
276            None,
277            None,
278        );
279
280        let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
281        let req = build_request("API.EXAMPLE.com", "/v1/customers");
282
283        let resolved = matcher.resolve(&req).unwrap();
284        assert_eq!(resolved.destination.name, "customer_service");
285    }
286
287    #[test]
288    fn matches_host_regex() {
289        let destination = build_destination(
290            "billing",
291            Some(DestinationMatchValue::Regex {
292                regex: "^api\\.(eu|us)\\.example\\.com$".into(),
293            }),
294            None,
295            None,
296        );
297
298        let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
299        let req = build_request("api.eu.example.com", "/billing");
300
301        let resolved = matcher.resolve(&req).unwrap();
302        assert_eq!(resolved.destination.name, "billing");
303    }
304
305    #[test]
306    fn supports_multiple_match_entries_per_destination() {
307        let destination = build_destination_with_matchers(
308            "api",
309            Some(vec![
310                DestinationMatch {
311                    host: Some(DestinationMatchValue::String("api.example.com".into())),
312                    path_prefix: Some(DestinationMatchValue::String("/billing".into())),
313                    path_exact: None,
314                },
315                DestinationMatch {
316                    host: Some(DestinationMatchValue::String("api.example.com".into())),
317                    path_prefix: Some(DestinationMatchValue::String("/support".into())),
318                    path_exact: None,
319                },
320            ]),
321        );
322
323        let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
324
325        let billing_req = build_request("api.example.com", "/billing/payments");
326        let billing_destination = matcher.resolve(&billing_req).unwrap();
327        assert_eq!(billing_destination.destination.name, "api");
328
329        let support_req = build_request("api.example.com", "/support/chat");
330        let support_destination = matcher.resolve(&support_req).unwrap();
331        assert_eq!(support_destination.destination.name, "api");
332
333        let missing_req = build_request("api.example.com", "/reports");
334        assert!(matcher.resolve(&missing_req).is_none());
335    }
336
337    #[test]
338    fn exact_host_entries_are_prioritized_before_regex() {
339        let destination = build_destination_with_matchers(
340            "api",
341            Some(vec![
342                DestinationMatch {
343                    host: Some(DestinationMatchValue::String("api.example.com".into())),
344                    path_prefix: Some(DestinationMatchValue::String("/billing".into())),
345                    path_exact: None,
346                },
347                DestinationMatch {
348                    host: Some(DestinationMatchValue::Regex {
349                        regex: "^api\\..+".into(),
350                    }),
351                    path_prefix: Some(DestinationMatchValue::String("/regex".into())),
352                    path_exact: None,
353                },
354            ]),
355        );
356
357        let matcher = DestinationMatcherIndex::new(vec![destination.clone()].into_iter()).unwrap();
358
359        let exact_req = build_request("api.example.com", "/billing/invoices");
360        let exact_destination = matcher.resolve(&exact_req).unwrap();
361        assert_eq!(exact_destination.destination.name, "api");
362
363        let regex_req = build_request("api.example.com", "/regex/search");
364        let regex_destination = matcher.resolve(&regex_req).unwrap();
365        assert_eq!(regex_destination.destination.name, "api");
366    }
367
368    #[test]
369    fn matches_path_prefix() {
370        let hostless = build_destination(
371            "helpdesk",
372            None,
373            Some(DestinationMatchValue::String("/helpdesk".into())),
374            None,
375        );
376
377        let matcher = DestinationMatcherIndex::new(vec![hostless.clone()].into_iter()).unwrap();
378        let req = build_request("any.example.com", "/helpdesk/ticket");
379
380        let resolved = matcher.resolve(&req).unwrap();
381        assert_eq!(resolved.destination.name, "helpdesk");
382    }
383
384    #[test]
385    fn matches_path_regex() {
386        let hostless = build_destination(
387            "reports",
388            None,
389            Some(DestinationMatchValue::Regex {
390                regex: "^/reports/(daily|weekly)".into(),
391            }),
392            None,
393        );
394
395        let matcher = DestinationMatcherIndex::new(vec![hostless.clone()].into_iter()).unwrap();
396        let req = build_request("other.example.com", "/reports/daily/summary");
397
398        let resolved = matcher.resolve(&req).unwrap();
399        assert_eq!(resolved.destination.name, "reports");
400    }
401
402    #[test]
403    fn respects_path_exact() {
404        let host = build_destination(
405            "status",
406            Some(DestinationMatchValue::String("status.example.com".into())),
407            None,
408            Some("/healthz"),
409        );
410
411        let matcher = DestinationMatcherIndex::new(vec![host.clone()].into_iter()).unwrap();
412        let req = build_request("status.example.com", "/healthz");
413
414        assert!(matcher.resolve(&req).is_some());
415
416        let req_non_matching = build_request("status.example.com", "/healthz/extra");
417        assert!(matcher.resolve(&req_non_matching).is_none());
418    }
419
420    #[test]
421    fn host_priority_before_hostless() {
422        let host_destination = build_destination(
423            "api",
424            Some(DestinationMatchValue::String("api.example.com".into())),
425            None,
426            None,
427        );
428        let hostless = build_destination("fallback", None, None, None);
429
430        let matcher = DestinationMatcherIndex::new(
431            vec![hostless.clone(), host_destination.clone()].into_iter(),
432        )
433        .unwrap();
434        let req = build_request("api.example.com", "/anything");
435
436        let resolved = matcher.resolve(&req).unwrap();
437        assert_eq!(resolved.destination.name, "api");
438    }
439}