feat(smart-proxy): add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
//! Route matching engine for RustProxy.
|
||||
//! Provides domain/path/IP/header matchers and a port-indexed RouteManager.
|
||||
|
||||
pub mod route_manager;
|
||||
pub mod matchers;
|
||||
pub mod route_manager;
|
||||
|
||||
pub use route_manager::*;
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn domain_matches(pattern: &str, domain: &str) -> bool {
|
||||
// Wildcard patterns
|
||||
if pattern.starts_with("*.") || pattern.starts_with("*.") {
|
||||
let suffix = &pattern[2..]; // e.g., "example.com"
|
||||
// Match exact parent or any single-level subdomain
|
||||
// Match exact parent or any single-level subdomain
|
||||
if domain.eq_ignore_ascii_case(suffix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ipnet::IpNet;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use ipnet::IpNet;
|
||||
|
||||
/// Match an IP address against a pattern.
|
||||
///
|
||||
@@ -85,7 +85,10 @@ fn wildcard_to_cidr(pattern: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{}.{}.{}.{}/{}", octets[0], octets[1], octets[2], octets[3], prefix_len))
|
||||
Some(format!(
|
||||
"{}.{}.{}.{}/{}",
|
||||
octets[0], octets[1], octets[2], octets[3], prefix_len
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod domain;
|
||||
pub mod path;
|
||||
pub mod ip;
|
||||
pub mod header;
|
||||
pub mod ip;
|
||||
pub mod path;
|
||||
|
||||
pub use domain::*;
|
||||
pub use path::*;
|
||||
pub use ip::*;
|
||||
pub use header::*;
|
||||
pub use ip::*;
|
||||
pub use path::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rustproxy_config::{RouteConfig, RouteTarget, TransportProtocol, TlsMode};
|
||||
use crate::matchers;
|
||||
use rustproxy_config::{RouteConfig, RouteTarget, TlsMode, TransportProtocol};
|
||||
|
||||
/// Context for route matching (subset of connection info).
|
||||
pub struct MatchContext<'a> {
|
||||
@@ -42,19 +42,14 @@ impl RouteManager {
|
||||
};
|
||||
|
||||
// Filter enabled routes and sort by priority
|
||||
let mut enabled_routes: Vec<RouteConfig> = routes
|
||||
.into_iter()
|
||||
.filter(|r| r.is_enabled())
|
||||
.collect();
|
||||
let mut enabled_routes: Vec<RouteConfig> =
|
||||
routes.into_iter().filter(|r| r.is_enabled()).collect();
|
||||
enabled_routes.sort_by(|a, b| b.effective_priority().cmp(&a.effective_priority()));
|
||||
|
||||
// Build port index
|
||||
for (idx, route) in enabled_routes.iter().enumerate() {
|
||||
for port in route.listening_ports() {
|
||||
manager.port_index
|
||||
.entry(port)
|
||||
.or_default()
|
||||
.push(idx);
|
||||
manager.port_index.entry(port).or_default().push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +61,9 @@ impl RouteManager {
|
||||
/// Used to skip expensive header HashMap construction when no route needs it.
|
||||
pub fn any_route_has_headers(&self, port: u16) -> bool {
|
||||
if let Some(indices) = self.port_index.get(&port) {
|
||||
indices.iter().any(|&idx| self.routes[idx].route_match.headers.is_some())
|
||||
indices
|
||||
.iter()
|
||||
.any(|&idx| self.routes[idx].route_match.headers.is_some())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -99,8 +96,8 @@ impl RouteManager {
|
||||
let ctx_transport = ctx.transport.as_ref();
|
||||
match (route_transport, ctx_transport) {
|
||||
// Route requires UDP only — reject non-UDP contexts
|
||||
(Some(TransportProtocol::Udp), None) |
|
||||
(Some(TransportProtocol::Udp), Some(TransportProtocol::Tcp)) => return false,
|
||||
(Some(TransportProtocol::Udp), None)
|
||||
| (Some(TransportProtocol::Udp), Some(TransportProtocol::Tcp)) => return false,
|
||||
// Route requires TCP only — reject UDP contexts
|
||||
(Some(TransportProtocol::Tcp), Some(TransportProtocol::Udp)) => return false,
|
||||
// Route has no transport (default = TCP) — reject UDP contexts
|
||||
@@ -196,7 +193,11 @@ impl RouteManager {
|
||||
}
|
||||
|
||||
/// Find the best matching target within a route.
|
||||
fn find_target<'a>(&self, route: &'a RouteConfig, ctx: &MatchContext<'_>) -> Option<&'a RouteTarget> {
|
||||
fn find_target<'a>(
|
||||
&self,
|
||||
route: &'a RouteConfig,
|
||||
ctx: &MatchContext<'_>,
|
||||
) -> Option<&'a RouteTarget> {
|
||||
let targets = route.action.targets.as_ref()?;
|
||||
|
||||
if targets.len() == 1 && targets[0].target_match.is_none() {
|
||||
@@ -223,17 +224,11 @@ impl RouteManager {
|
||||
}
|
||||
|
||||
// Fall back to first target without match criteria
|
||||
best.or_else(|| {
|
||||
targets.iter().find(|t| t.target_match.is_none())
|
||||
})
|
||||
best.or_else(|| targets.iter().find(|t| t.target_match.is_none()))
|
||||
}
|
||||
|
||||
/// Check if a target match criteria matches the context.
|
||||
fn matches_target(
|
||||
&self,
|
||||
tm: &rustproxy_config::TargetMatch,
|
||||
ctx: &MatchContext<'_>,
|
||||
) -> bool {
|
||||
fn matches_target(&self, tm: &rustproxy_config::TargetMatch, ctx: &MatchContext<'_>) -> bool {
|
||||
// Port matching
|
||||
if let Some(ref ports) = tm.ports {
|
||||
if !ports.contains(&ctx.port) {
|
||||
@@ -298,9 +293,7 @@ impl RouteManager {
|
||||
// If multiple passthrough routes on same port, SNI is needed
|
||||
let passthrough_routes: Vec<_> = routes
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.tls_mode() == Some(&TlsMode::Passthrough)
|
||||
})
|
||||
.filter(|r| r.tls_mode() == Some(&TlsMode::Passthrough))
|
||||
.collect();
|
||||
|
||||
if passthrough_routes.len() > 1 {
|
||||
@@ -419,7 +412,11 @@ mod tests {
|
||||
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
// Should match the higher-priority specific route
|
||||
assert!(result.route.route_match.domains.as_ref()
|
||||
assert!(result
|
||||
.route
|
||||
.route_match
|
||||
.domains
|
||||
.as_ref()
|
||||
.map(|d| d.to_vec())
|
||||
.unwrap()
|
||||
.contains(&"api.example.com"));
|
||||
@@ -619,8 +616,14 @@ mod tests {
|
||||
|
||||
let result = manager.find_route(&ctx);
|
||||
assert!(result.is_some());
|
||||
let matched_domains = result.unwrap().route.route_match.domains.as_ref()
|
||||
.map(|d| d.to_vec()).unwrap();
|
||||
let matched_domains = result
|
||||
.unwrap()
|
||||
.route
|
||||
.route_match
|
||||
.domains
|
||||
.as_ref()
|
||||
.map(|d| d.to_vec())
|
||||
.unwrap();
|
||||
assert!(matched_domains.contains(&"*"));
|
||||
}
|
||||
|
||||
@@ -735,7 +738,11 @@ mod tests {
|
||||
assert_eq!(result.target.unwrap().host.first(), "default-backend");
|
||||
}
|
||||
|
||||
fn make_route_with_protocol(port: u16, domain: Option<&str>, protocol: Option<&str>) -> RouteConfig {
|
||||
fn make_route_with_protocol(
|
||||
port: u16,
|
||||
domain: Option<&str>,
|
||||
protocol: Option<&str>,
|
||||
) -> RouteConfig {
|
||||
let mut route = make_route(port, domain, 0);
|
||||
route.route_match.protocol = protocol.map(|s| s.to_string());
|
||||
route
|
||||
@@ -1029,8 +1036,10 @@ mod tests {
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some(),
|
||||
"QUIC (UDP) with is_tls=true and domain=None should match domain-restricted routes");
|
||||
assert!(
|
||||
manager.find_route(&ctx).is_some(),
|
||||
"QUIC (UDP) with is_tls=true and domain=None should match domain-restricted routes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1051,7 +1060,9 @@ mod tests {
|
||||
transport: None, // TCP (default)
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_none(),
|
||||
"TCP TLS without SNI should NOT match domain-restricted routes");
|
||||
assert!(
|
||||
manager.find_route(&ctx).is_none(),
|
||||
"TCP TLS without SNI should NOT match domain-restricted routes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user