From 8db4dfee13eb4d189240c1e49eb2ca8a5c801498 Mon Sep 17 00:00:00 2001 From: Mlanawo MBECHEZI Date: Sat, 22 Aug 2026 17:23:24 +0300 Subject: [PATCH] Resolve a middleware route by host and path, not host alone --- src/middleware/mod.rs | 114 ++++++++++++++++++++++++++++++++++++++-- src/middleware/proxy.rs | 2 +- src/proxy/sozu/mod.rs | 6 ++- 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index 0e88f23..6370258 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -50,9 +50,16 @@ pub struct MiddlewareAppState { pub type MiddlewareState = Arc>; /// Route table mapping hostname → middleware config + real backends +/// The routes one hostname carries, each with the path prefix it was declared +/// with. `None` is a route with no path: the catch-all for that hostname. +type HostRoutes = Vec<(Option, Arc)>; + #[derive(Debug, Default)] pub struct MiddlewareRouteTable { - pub(super) routes: std::collections::HashMap>, + /// Keyed by hostname, but a hostname can carry several routes: Sozu routes + /// on host *and* path, so one entry per hostname would drop all but the + /// last one stored. + pub(super) routes: std::collections::HashMap, } /// Middleware configuration for a single entrypoint. @@ -90,14 +97,53 @@ impl std::fmt::Debug for MiddlewareRoute { } } +/// Whether `request_path` falls under `prefix`, on a segment boundary. +/// +/// `/api` covers `/api` and `/api/users`, but not `/apifoo` — a prefix that +/// matched mid-segment would capture paths it was never given. +fn prefix_matches(prefix: &str, request_path: &str) -> bool { + let prefix = prefix.trim_end_matches('/'); + if prefix.is_empty() { + return true; + } + match request_path.strip_prefix(prefix) { + Some("") => true, + Some(rest) => rest.starts_with('/'), + None => false, + } +} + +/// Index of the candidate that should serve `request_path`: the longest prefix +/// that matches, with a pathless route as the catch-all. +/// +/// Sozu already routed the request on host *and* path before handing it over, +/// so this only has to reproduce that choice among the routes sharing a +/// hostname. Resolving on the hostname alone let whichever entrypoint was +/// stored last serve every path under it. +fn best_match(candidates: &[Option], request_path: &str) -> Option { + candidates + .iter() + .enumerate() + .filter(|(_, path)| match path { + Some(prefix) => prefix_matches(prefix, request_path), + None => true, + }) + .max_by_key(|(_, path)| path.as_deref().map(str::len).unwrap_or(0)) + .map(|(index, _)| index) +} + impl MiddlewareRouteTable { pub fn update_routes_for_entrypoint( &mut self, hostnames: &[String], + path: Option, route: Arc, ) { for hostname in hostnames { - self.routes.insert(hostname.clone(), Arc::clone(&route)); + self.routes + .entry(hostname.clone()) + .or_default() + .push((path.clone(), Arc::clone(&route))); } } @@ -105,10 +151,13 @@ impl MiddlewareRouteTable { self.routes.clear(); } - pub fn get_route_by_host(&self, host: &str) -> Option> { + pub fn get_route(&self, host: &str, request_path: &str) -> Option> { // Strip port from host header if present (e.g. "example.com:8080" -> "example.com") let hostname = host.split(':').next().unwrap_or(host); - self.routes.get(hostname).cloned() + let candidates = self.routes.get(hostname)?; + + let paths: Vec> = candidates.iter().map(|(p, _)| p.clone()).collect(); + best_match(&paths, request_path).map(|index| Arc::clone(&candidates[index].1)) } pub fn known_hosts(&self) -> Vec { @@ -378,3 +427,60 @@ pub async fn serve( Ok(()) } + +#[cfg(test)] +mod route_key_tests { + use super::*; + + /// Sozu routes on host *and* path and hands the request to the middleware + /// server, which used to resolve it by host alone. Two entrypoints on one + /// hostname therefore collapsed into whichever was inserted last, decided + /// by cluster-id order: + /// + /// ```text + /// api: app.example.com /api rateLimit=10 + /// web: app.example.com / compress + /// ``` + /// + /// `GET /api/users` was matched by Sozu against the /api frontend, then + /// sent to *web's* backends with web's middleware stack — the rate limit + /// never ran. An ip_allow_list or forward_auth on the losing route was + /// dropped the same way, silently. + #[test] + fn the_longest_matching_prefix_wins() { + let candidates = vec![Some("/api".to_string()), Some("/".to_string())]; + + assert_eq!(best_match(&candidates, "/api/users"), Some(0)); + assert_eq!(best_match(&candidates, "/index.html"), Some(1)); + } + + /// A route with no path serves everything under the hostname, so it is the + /// catch-all and must lose to any prefix that matches. + #[test] + fn a_pathless_route_is_the_catch_all() { + let candidates = vec![Some("/api".to_string()), None]; + + assert_eq!(best_match(&candidates, "/api/users"), Some(0)); + assert_eq!(best_match(&candidates, "/other"), Some(1)); + } + + /// A prefix only matches on a segment boundary: /apifoo is not under /api, + /// or a route would capture hostnames it was never given. + #[test] + fn a_prefix_matches_only_on_a_segment_boundary() { + let candidates = vec![Some("/api".to_string())]; + + assert_eq!(best_match(&candidates, "/api"), Some(0)); + assert_eq!(best_match(&candidates, "/api/users"), Some(0)); + assert_eq!(best_match(&candidates, "/apifoo"), None); + } + + /// Nothing matches: the caller answers 404 rather than picking a route at + /// random, which is what host-only keying amounted to. + #[test] + fn no_candidate_matches() { + let candidates = vec![Some("/api".to_string()), Some("/admin".to_string())]; + + assert_eq!(best_match(&candidates, "/"), None); + } +} diff --git a/src/middleware/proxy.rs b/src/middleware/proxy.rs index 5e19194..2a9c42f 100644 --- a/src/middleware/proxy.rs +++ b/src/middleware/proxy.rs @@ -90,7 +90,7 @@ pub async fn handle_proxy( return diag::internal_error("middleware-routing-corrupted").into_response(); } }; - (table.get_route_by_host(&host), table.known_hosts()) + (table.get_route(&host, &path), table.known_hosts()) }; let route = match route { diff --git a/src/proxy/sozu/mod.rs b/src/proxy/sozu/mod.rs index 5f77858..f3a5c1d 100644 --- a/src/proxy/sozu/mod.rs +++ b/src/proxy/sozu/mod.rs @@ -983,7 +983,11 @@ fn update_middleware_routes( entrypoint.config.hostnames, route.middlewares.len(), ); - table.update_routes_for_entrypoint(&entrypoint.config.hostnames, route); + table.update_routes_for_entrypoint( + &entrypoint.config.hostnames, + entrypoint.config.path.as_ref().map(|p| p.value.clone()), + route, + ); } } }