sentinel_proxy/
routing.rs

1//! Route matching and selection module for Sentinel proxy
2//!
3//! This module implements the routing logic for matching incoming requests
4//! to configured routes based on various criteria (path, host, headers, etc.)
5//! with support for priority-based evaluation.
6
7use dashmap::DashMap;
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
11use std::sync::Arc;
12use tracing::{debug, info, trace, warn};
13
14use sentinel_common::types::Priority;
15use sentinel_common::RouteId;
16use sentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
17
18/// Route matcher for efficient route selection
19pub struct RouteMatcher {
20    /// Routes sorted by priority (highest first)
21    routes: Vec<CompiledRoute>,
22    /// Default route ID if no match found
23    default_route: Option<RouteId>,
24    /// Cache for frequently matched routes (lock-free concurrent access)
25    cache: Arc<RouteCache>,
26    /// Whether any route requires header matching (optimization flag)
27    needs_headers: bool,
28    /// Whether any route requires query param matching (optimization flag)
29    needs_query_params: bool,
30}
31
32/// Compiled route with pre-processed match conditions
33struct CompiledRoute {
34    /// Route configuration
35    config: Arc<RouteConfig>,
36    /// Route ID for quick lookup
37    id: RouteId,
38    /// Priority for ordering
39    priority: Priority,
40    /// Compiled match conditions
41    matchers: Vec<CompiledMatcher>,
42}
43
44/// Compiled match condition for efficient evaluation
45enum CompiledMatcher {
46    /// Exact path match
47    Path(String),
48    /// Path prefix match
49    PathPrefix(String),
50    /// Regex path match
51    PathRegex(Regex),
52    /// Host match (exact or wildcard)
53    Host(HostMatcher),
54    /// Header presence or value match
55    Header { name: String, value: Option<String> },
56    /// HTTP method match
57    Method(Vec<String>),
58    /// Query parameter match
59    QueryParam { name: String, value: Option<String> },
60}
61
62/// Host matching logic
63enum HostMatcher {
64    /// Exact host match
65    Exact(String),
66    /// Wildcard match (*.example.com)
67    Wildcard { suffix: String },
68    /// Regex match
69    Regex(Regex),
70}
71
72/// Route cache for performance (lock-free concurrent access)
73struct RouteCache {
74    /// Cache entries (cache key -> route ID) - lock-free concurrent map
75    entries: DashMap<String, RouteId>,
76    /// Maximum cache size
77    max_size: usize,
78    /// Current entry count (approximate, for eviction decisions)
79    entry_count: AtomicUsize,
80    /// Cache hits counter
81    hits: AtomicU64,
82    /// Cache misses counter
83    misses: AtomicU64,
84}
85
86impl RouteMatcher {
87    /// Create a new route matcher from configuration
88    pub fn new(
89        routes: Vec<RouteConfig>,
90        default_route: Option<String>,
91    ) -> Result<Self, RouteError> {
92        info!(
93            route_count = routes.len(),
94            default_route = ?default_route,
95            "Initializing route matcher"
96        );
97
98        let mut compiled_routes = Vec::new();
99
100        for route in routes {
101            trace!(
102                route_id = %route.id,
103                priority = ?route.priority,
104                match_count = route.matches.len(),
105                "Compiling route"
106            );
107            let compiled = CompiledRoute::compile(route)?;
108            compiled_routes.push(compiled);
109        }
110
111        // Sort by priority (highest first), then by specificity
112        compiled_routes.sort_by(|a, b| {
113            b.priority
114                .cmp(&a.priority)
115                .then_with(|| b.specificity().cmp(&a.specificity()))
116        });
117
118        // Log final route order
119        for (index, route) in compiled_routes.iter().enumerate() {
120            debug!(
121                route_id = %route.id,
122                order = index,
123                priority = ?route.priority,
124                specificity = route.specificity(),
125                "Route compiled and ordered"
126            );
127        }
128
129        // Determine if any routes need headers or query params (optimization)
130        let needs_headers = compiled_routes.iter().any(|r| {
131            r.matchers
132                .iter()
133                .any(|m| matches!(m, CompiledMatcher::Header { .. }))
134        });
135        let needs_query_params = compiled_routes.iter().any(|r| {
136            r.matchers
137                .iter()
138                .any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
139        });
140
141        info!(
142            compiled_routes = compiled_routes.len(),
143            needs_headers, needs_query_params, "Route matcher initialized"
144        );
145
146        Ok(Self {
147            routes: compiled_routes,
148            default_route: default_route.map(RouteId::new),
149            cache: Arc::new(RouteCache::new(1000)),
150            needs_headers,
151            needs_query_params,
152        })
153    }
154
155    /// Check if any route requires header matching
156    #[inline]
157    pub fn needs_headers(&self) -> bool {
158        self.needs_headers
159    }
160
161    /// Check if any route requires query param matching
162    #[inline]
163    pub fn needs_query_params(&self) -> bool {
164        self.needs_query_params
165    }
166
167    /// Match a request to a route
168    pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
169        trace!(
170            method = %req.method,
171            path = %req.path,
172            host = %req.host,
173            "Starting route matching"
174        );
175
176        // Check cache first (lock-free read)
177        let cache_key = req.cache_key();
178        if let Some(route_id_ref) = self.cache.get(&cache_key) {
179            let route_id = route_id_ref.clone();
180            drop(route_id_ref); // Release the ref before further processing
181            trace!(
182                route_id = %route_id,
183                cache_key = %cache_key,
184                "Route cache hit"
185            );
186            if let Some(route) = self.find_route_by_id(&route_id) {
187                debug!(
188                    route_id = %route_id,
189                    method = %req.method,
190                    path = %req.path,
191                    source = "cache",
192                    "Route matched from cache"
193                );
194                return Some(RouteMatch {
195                    route_id,
196                    config: route.config.clone(),
197                });
198            }
199        }
200
201        // Record cache miss
202        self.cache.record_miss();
203
204        trace!(
205            cache_key = %cache_key,
206            route_count = self.routes.len(),
207            "Cache miss, evaluating routes"
208        );
209
210        // Evaluate routes in priority order
211        for (index, route) in self.routes.iter().enumerate() {
212            trace!(
213                route_id = %route.id,
214                route_index = index,
215                priority = ?route.priority,
216                matcher_count = route.matchers.len(),
217                "Evaluating route"
218            );
219
220            if route.matches(req) {
221                debug!(
222                    route_id = %route.id,
223                    method = %req.method,
224                    path = %req.path,
225                    host = %req.host,
226                    priority = ?route.priority,
227                    route_index = index,
228                    "Route matched"
229                );
230
231                // Update cache (lock-free insert)
232                self.cache.insert(cache_key.clone(), route.id.clone());
233
234                trace!(
235                    route_id = %route.id,
236                    cache_key = %cache_key,
237                    "Route added to cache"
238                );
239
240                return Some(RouteMatch {
241                    route_id: route.id.clone(),
242                    config: route.config.clone(),
243                });
244            }
245        }
246
247        // Use default route if configured
248        if let Some(ref default_id) = self.default_route {
249            debug!(
250                route_id = %default_id,
251                method = %req.method,
252                path = %req.path,
253                "Using default route (no explicit match)"
254            );
255            if let Some(route) = self.find_route_by_id(default_id) {
256                return Some(RouteMatch {
257                    route_id: default_id.clone(),
258                    config: route.config.clone(),
259                });
260            }
261        }
262
263        debug!(
264            method = %req.method,
265            path = %req.path,
266            host = %req.host,
267            routes_evaluated = self.routes.len(),
268            "No route matched"
269        );
270        None
271    }
272
273    /// Find a route by ID
274    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
275        self.routes.iter().find(|r| r.id == *id)
276    }
277
278    /// Clear the route cache
279    pub fn clear_cache(&self) {
280        self.cache.clear();
281    }
282
283    /// Get cache statistics
284    pub fn cache_stats(&self) -> CacheStats {
285        CacheStats {
286            entries: self.cache.len(),
287            max_size: self.cache.max_size,
288            hit_rate: self.cache.hit_rate(),
289        }
290    }
291}
292
293impl CompiledRoute {
294    /// Compile a route configuration into an optimized matcher
295    fn compile(config: RouteConfig) -> Result<Self, RouteError> {
296        let mut matchers = Vec::new();
297
298        for condition in &config.matches {
299            let compiled = match condition {
300                MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
301                MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
302                MatchCondition::PathRegex(pattern) => {
303                    let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
304                        pattern: pattern.clone(),
305                        error: e.to_string(),
306                    })?;
307                    CompiledMatcher::PathRegex(regex)
308                }
309                MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
310                MatchCondition::Header { name, value } => CompiledMatcher::Header {
311                    name: name.to_lowercase(),
312                    value: value.clone(),
313                },
314                MatchCondition::Method(methods) => {
315                    CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
316                }
317                MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
318                    name: name.clone(),
319                    value: value.clone(),
320                },
321            };
322            matchers.push(compiled);
323        }
324
325        Ok(Self {
326            id: RouteId::new(&config.id),
327            priority: config.priority,
328            config: Arc::new(config),
329            matchers,
330        })
331    }
332
333    /// Check if this route matches the request
334    fn matches(&self, req: &RequestInfo<'_>) -> bool {
335        // All matchers must pass (AND logic)
336        for (index, matcher) in self.matchers.iter().enumerate() {
337            let result = matcher.matches(req);
338            if !result {
339                trace!(
340                    route_id = %self.id,
341                    matcher_index = index,
342                    matcher_type = ?matcher,
343                    path = %req.path,
344                    "Matcher did not match"
345                );
346                return false;
347            }
348            trace!(
349                route_id = %self.id,
350                matcher_index = index,
351                matcher_type = ?matcher,
352                "Matcher passed"
353            );
354        }
355        true
356    }
357
358    /// Calculate route specificity for tie-breaking
359    fn specificity(&self) -> u32 {
360        let mut score = 0;
361        for matcher in &self.matchers {
362            score += match matcher {
363                CompiledMatcher::Path(_) => 1000,     // Exact path is most specific
364                CompiledMatcher::PathRegex(_) => 500, // Regex is moderately specific
365                CompiledMatcher::PathPrefix(_) => 100, // Prefix is least specific
366                CompiledMatcher::Host(_) => 50,
367                CompiledMatcher::Header { value, .. } => {
368                    if value.is_some() {
369                        30
370                    } else {
371                        20
372                    }
373                }
374                CompiledMatcher::Method(_) => 10,
375                CompiledMatcher::QueryParam { value, .. } => {
376                    if value.is_some() {
377                        25
378                    } else {
379                        15
380                    }
381                }
382            };
383        }
384        score
385    }
386}
387
388impl CompiledMatcher {
389    /// Check if this matcher matches the request
390    fn matches(&self, req: &RequestInfo<'_>) -> bool {
391        match self {
392            Self::Path(path) => req.path == *path,
393            Self::PathPrefix(prefix) => req.path.starts_with(prefix),
394            Self::PathRegex(regex) => regex.is_match(req.path),
395            Self::Host(host_matcher) => host_matcher.matches(req.host),
396            Self::Header { name, value } => {
397                if let Some(header_value) = req.headers().get(name) {
398                    value.as_ref().is_none_or(|v| header_value == v)
399                } else {
400                    false
401                }
402            }
403            Self::Method(methods) => methods.iter().any(|m| m == req.method),
404            Self::QueryParam { name, value } => {
405                if let Some(param_value) = req.query_params().get(name) {
406                    value.as_ref().is_none_or(|v| param_value == v)
407                } else {
408                    false
409                }
410            }
411        }
412    }
413}
414
415impl HostMatcher {
416    /// Parse a host pattern into a matcher
417    fn parse(pattern: &str) -> Self {
418        if pattern.starts_with("*.") {
419            // Wildcard pattern
420            Self::Wildcard {
421                suffix: pattern[2..].to_string(),
422            }
423        } else if pattern.contains('*') || pattern.contains('[') {
424            // Treat as regex if it contains other special characters
425            if let Ok(regex) = Regex::new(pattern) {
426                Self::Regex(regex)
427            } else {
428                // Fall back to exact match if regex compilation fails
429                warn!("Invalid host regex pattern: {}, using exact match", pattern);
430                Self::Exact(pattern.to_string())
431            }
432        } else {
433            // Exact match
434            Self::Exact(pattern.to_string())
435        }
436    }
437
438    /// Check if this matcher matches the host
439    fn matches(&self, host: &str) -> bool {
440        match self {
441            Self::Exact(pattern) => host == pattern,
442            Self::Wildcard { suffix } => {
443                host.ends_with(suffix)
444                    && host.len() > suffix.len()
445                    && host[..host.len() - suffix.len()].ends_with('.')
446            }
447            Self::Regex(regex) => regex.is_match(host),
448        }
449    }
450}
451
452impl RouteCache {
453    /// Create a new route cache
454    fn new(max_size: usize) -> Self {
455        Self {
456            entries: DashMap::with_capacity(max_size),
457            max_size,
458            entry_count: AtomicUsize::new(0),
459            hits: AtomicU64::new(0),
460            misses: AtomicU64::new(0),
461        }
462    }
463
464    /// Get a route from cache (lock-free)
465    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
466        let result = self.entries.get(key);
467        if result.is_some() {
468            self.hits.fetch_add(1, Ordering::Relaxed);
469        }
470        result
471    }
472
473    /// Record a cache miss
474    fn record_miss(&self) {
475        self.misses.fetch_add(1, Ordering::Relaxed);
476    }
477
478    /// Get the hit rate (0.0 to 1.0)
479    fn hit_rate(&self) -> f64 {
480        let hits = self.hits.load(Ordering::Relaxed);
481        let misses = self.misses.load(Ordering::Relaxed);
482        let total = hits + misses;
483        if total == 0 {
484            0.0
485        } else {
486            hits as f64 / total as f64
487        }
488    }
489
490    /// Insert a route into cache (lock-free)
491    fn insert(&self, key: String, route_id: RouteId) {
492        // Check if we need to evict (approximate check to avoid overhead)
493        let current_count = self.entry_count.load(Ordering::Relaxed);
494        if current_count >= self.max_size {
495            // Evict ~10% of entries randomly for simplicity
496            // This is faster than true LRU and good enough for a cache
497            self.evict_random();
498        }
499
500        if self.entries.insert(key, route_id).is_none() {
501            // Only increment if this was a new entry
502            self.entry_count.fetch_add(1, Ordering::Relaxed);
503        }
504    }
505
506    /// Evict random entries when cache is full
507    fn evict_random(&self) {
508        let to_evict = self.max_size / 10; // Evict ~10%
509        let mut evicted = 0;
510
511        // Iterate and remove some entries
512        self.entries.retain(|_, _| {
513            if evicted < to_evict {
514                evicted += 1;
515                false // Remove this entry
516            } else {
517                true // Keep this entry
518            }
519        });
520
521        // Update count (approximate)
522        self.entry_count
523            .store(self.entries.len(), Ordering::Relaxed);
524    }
525
526    /// Get current cache size
527    fn len(&self) -> usize {
528        self.entries.len()
529    }
530
531    /// Clear all cache entries
532    fn clear(&self) {
533        self.entries.clear();
534        self.entry_count.store(0, Ordering::Relaxed);
535    }
536}
537
538/// Request information for route matching (zero-copy where possible)
539#[derive(Debug)]
540pub struct RequestInfo<'a> {
541    /// HTTP method (borrowed from request header)
542    pub method: &'a str,
543    /// Request path (borrowed from request header)
544    pub path: &'a str,
545    /// Host header value (borrowed from request header)
546    pub host: &'a str,
547    /// Headers for matching (lazy-initialized, only if needed)
548    headers: Option<HashMap<String, String>>,
549    /// Query parameters (lazy-initialized, only if needed)
550    query_params: Option<HashMap<String, String>>,
551}
552
553impl<'a> RequestInfo<'a> {
554    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
555    #[inline]
556    pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
557        Self {
558            method,
559            path,
560            host,
561            headers: None,
562            query_params: None,
563        }
564    }
565
566    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
567    #[inline]
568    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
569        self.headers = Some(headers);
570        self
571    }
572
573    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
574    #[inline]
575    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
576        self.query_params = Some(params);
577        self
578    }
579
580    /// Get headers (returns empty map if not set)
581    #[inline]
582    pub fn headers(&self) -> &HashMap<String, String> {
583        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
584        self.headers
585            .as_ref()
586            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
587    }
588
589    /// Get query params (returns empty map if not set)
590    #[inline]
591    pub fn query_params(&self) -> &HashMap<String, String> {
592        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
593        self.query_params
594            .as_ref()
595            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
596    }
597
598    /// Generate a cache key for this request
599    fn cache_key(&self) -> String {
600        format!("{}:{}:{}", self.method, self.host, self.path)
601    }
602
603    /// Parse query parameters from path (only call when needed)
604    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
605        let mut params = HashMap::new();
606        if let Some(query_start) = path.find('?') {
607            let query = &path[query_start + 1..];
608            for pair in query.split('&') {
609                if let Some(eq_pos) = pair.find('=') {
610                    let key = &pair[..eq_pos];
611                    let value = &pair[eq_pos + 1..];
612                    params.insert(
613                        urlencoding::decode(key)
614                            .unwrap_or_else(|_| key.into())
615                            .into_owned(),
616                        urlencoding::decode(value)
617                            .unwrap_or_else(|_| value.into())
618                            .into_owned(),
619                    );
620                } else {
621                    params.insert(
622                        urlencoding::decode(pair)
623                            .unwrap_or_else(|_| pair.into())
624                            .into_owned(),
625                        String::new(),
626                    );
627                }
628            }
629        }
630        params
631    }
632
633    /// Build headers map from request header iterator (only call when needed)
634    pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
635    where
636        I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
637    {
638        let mut headers = HashMap::new();
639        for (name, value) in iter {
640            if let Ok(value_str) = value.to_str() {
641                headers.insert(name.as_str().to_lowercase(), value_str.to_string());
642            }
643        }
644        headers
645    }
646}
647
648/// Route match result
649#[derive(Debug, Clone)]
650pub struct RouteMatch {
651    pub route_id: RouteId,
652    pub config: Arc<RouteConfig>,
653}
654
655impl RouteMatch {
656    /// Access route policies (convenience accessor to avoid repeated .config.policies)
657    #[inline]
658    pub fn policies(&self) -> &RoutePolicies {
659        &self.config.policies
660    }
661}
662
663/// Cache statistics
664#[derive(Debug, Clone)]
665pub struct CacheStats {
666    pub entries: usize,
667    pub max_size: usize,
668    pub hit_rate: f64,
669}
670
671/// Route matching errors
672#[derive(Debug, thiserror::Error)]
673pub enum RouteError {
674    #[error("Invalid regex pattern '{pattern}': {error}")]
675    InvalidRegex { pattern: String, error: String },
676
677    #[error("Invalid route configuration: {0}")]
678    InvalidConfig(String),
679
680    #[error("Duplicate route ID: {0}")]
681    DuplicateRouteId(String),
682}
683
684impl std::fmt::Debug for CompiledMatcher {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        match self {
687            Self::Path(p) => write!(f, "Path({})", p),
688            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
689            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
690            Self::Host(_) => write!(f, "Host(...)"),
691            Self::Header { name, .. } => write!(f, "Header({})", name),
692            Self::Method(m) => write!(f, "Method({:?})", m),
693            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
694        }
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use sentinel_common::types::Priority;
702    use sentinel_config::{MatchCondition, RouteConfig};
703
704    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
705        RouteConfig {
706            id: id.to_string(),
707            priority: Priority::Normal,
708            matches,
709            upstream: Some("test_upstream".to_string()),
710            service_type: sentinel_config::ServiceType::Web,
711            policies: Default::default(),
712            filters: vec![],
713            builtin_handler: None,
714            waf_enabled: false,
715            circuit_breaker: None,
716            retry_policy: None,
717            static_files: None,
718            api_schema: None,
719            error_pages: None,
720            websocket: false,
721            websocket_inspection: false,
722        }
723    }
724
725    #[test]
726    fn test_path_matching() {
727        let routes = vec![
728            create_test_route(
729                "exact",
730                vec![MatchCondition::Path("/api/v1/users".to_string())],
731            ),
732            create_test_route(
733                "prefix",
734                vec![MatchCondition::PathPrefix("/api/".to_string())],
735            ),
736        ];
737
738        let matcher = RouteMatcher::new(routes, None).unwrap();
739
740        let req = RequestInfo {
741            method: "GET",
742            path: "/api/v1/users",
743            host: "example.com",
744            headers: None,
745            query_params: None,
746        };
747
748        let result = matcher.match_request(&req).unwrap();
749        assert_eq!(result.route_id.as_str(), "exact");
750    }
751
752    #[test]
753    fn test_host_wildcard_matching() {
754        let routes = vec![create_test_route(
755            "wildcard",
756            vec![MatchCondition::Host("*.example.com".to_string())],
757        )];
758
759        let matcher = RouteMatcher::new(routes, None).unwrap();
760
761        let req = RequestInfo {
762            method: "GET",
763            path: "/",
764            host: "api.example.com",
765            headers: None,
766            query_params: None,
767        };
768
769        let result = matcher.match_request(&req).unwrap();
770        assert_eq!(result.route_id.as_str(), "wildcard");
771    }
772
773    #[test]
774    fn test_priority_ordering() {
775        let mut route1 =
776            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
777        route1.priority = Priority::Low;
778
779        let mut route2 =
780            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
781        route2.priority = Priority::High;
782
783        let routes = vec![route1, route2];
784        let matcher = RouteMatcher::new(routes, None).unwrap();
785
786        let req = RequestInfo {
787            method: "GET",
788            path: "/test",
789            host: "example.com",
790            headers: None,
791            query_params: None,
792        };
793
794        let result = matcher.match_request(&req).unwrap();
795        assert_eq!(result.route_id.as_str(), "high");
796    }
797
798    #[test]
799    fn test_query_param_parsing() {
800        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
801        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
802        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
803        assert_eq!(params.get("empty"), Some(&"".to_string()));
804    }
805}