feat(rustproxy): introduce a Rust-powered proxy engine and workspace with core crates for proxy functionality, ACME/TLS support, passthrough and HTTP proxies, metrics, nftables integration, routing/security, management IPC, tests, and README updates
This commit is contained in:
9
rust/crates/rustproxy-routing/src/lib.rs
Normal file
9
rust/crates/rustproxy-routing/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! # rustproxy-routing
|
||||
//!
|
||||
//! Route matching engine for RustProxy.
|
||||
//! Provides domain/path/IP/header matchers and a port-indexed RouteManager.
|
||||
|
||||
pub mod route_manager;
|
||||
pub mod matchers;
|
||||
|
||||
pub use route_manager::*;
|
||||
86
rust/crates/rustproxy-routing/src/matchers/domain.rs
Normal file
86
rust/crates/rustproxy-routing/src/matchers/domain.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Match a domain against a pattern supporting wildcards.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `*` matches any domain
|
||||
/// - `*.example.com` matches any subdomain of example.com
|
||||
/// - `example.com` exact match
|
||||
/// - `**.example.com` matches any depth of subdomain
|
||||
pub fn domain_matches(pattern: &str, domain: &str) -> bool {
|
||||
let pattern = pattern.trim().to_lowercase();
|
||||
let domain = domain.trim().to_lowercase();
|
||||
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if pattern == domain {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard patterns
|
||||
if pattern.starts_with("*.") {
|
||||
let suffix = &pattern[2..]; // e.g., "example.com"
|
||||
// Match exact parent or any single-level subdomain
|
||||
if domain == suffix {
|
||||
return true;
|
||||
}
|
||||
if domain.ends_with(&format!(".{}", suffix)) {
|
||||
// Check it's a single level subdomain for `*.`
|
||||
let prefix = &domain[..domain.len() - suffix.len() - 1];
|
||||
return !prefix.contains('.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if pattern.starts_with("**.") {
|
||||
let suffix = &pattern[3..];
|
||||
// Match exact parent or any depth of subdomain
|
||||
return domain == suffix || domain.ends_with(&format!(".{}", suffix));
|
||||
}
|
||||
|
||||
// Use glob-match for more complex patterns
|
||||
glob_match::glob_match(&pattern, &domain)
|
||||
}
|
||||
|
||||
/// Check if a domain matches any of the given patterns.
|
||||
pub fn domain_matches_any(patterns: &[&str], domain: &str) -> bool {
|
||||
patterns.iter().any(|p| domain_matches(p, domain))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
assert!(domain_matches("example.com", "example.com"));
|
||||
assert!(!domain_matches("example.com", "other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_all() {
|
||||
assert!(domain_matches("*", "anything.com"));
|
||||
assert!(domain_matches("*", "sub.domain.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_subdomain() {
|
||||
assert!(domain_matches("*.example.com", "www.example.com"));
|
||||
assert!(domain_matches("*.example.com", "api.example.com"));
|
||||
assert!(domain_matches("*.example.com", "example.com"));
|
||||
assert!(!domain_matches("*.example.com", "deep.sub.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_wildcard() {
|
||||
assert!(domain_matches("**.example.com", "www.example.com"));
|
||||
assert!(domain_matches("**.example.com", "deep.sub.example.com"));
|
||||
assert!(domain_matches("**.example.com", "example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
assert!(domain_matches("Example.COM", "example.com"));
|
||||
assert!(domain_matches("*.EXAMPLE.com", "WWW.example.COM"));
|
||||
}
|
||||
}
|
||||
98
rust/crates/rustproxy-routing/src/matchers/header.rs
Normal file
98
rust/crates/rustproxy-routing/src/matchers/header.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::collections::HashMap;
|
||||
use regex::Regex;
|
||||
|
||||
/// Match HTTP headers against a set of patterns.
|
||||
///
|
||||
/// Pattern values can be:
|
||||
/// - Exact string: `"application/json"`
|
||||
/// - Regex (surrounded by /): `"/^text\/.*/"`
|
||||
pub fn headers_match(
|
||||
patterns: &HashMap<String, String>,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> bool {
|
||||
for (key, pattern) in patterns {
|
||||
let key_lower = key.to_lowercase();
|
||||
|
||||
// Find the header (case-insensitive)
|
||||
let header_value = headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.to_lowercase() == key_lower)
|
||||
.map(|(_, v)| v.as_str());
|
||||
|
||||
let header_value = match header_value {
|
||||
Some(v) => v,
|
||||
None => return false, // Required header not present
|
||||
};
|
||||
|
||||
// Check if pattern is a regex (surrounded by /)
|
||||
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
|
||||
let regex_str = &pattern[1..pattern.len() - 1];
|
||||
match Regex::new(regex_str) {
|
||||
Ok(re) => {
|
||||
if !re.is_match(header_value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Invalid regex, fall back to exact match
|
||||
if header_value != pattern {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if header_value != pattern {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_header_match() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("Content-Type".to_string(), "application/json".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("content-type".to_string(), "application/json".to_string());
|
||||
m
|
||||
};
|
||||
assert!(headers_match(&patterns, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_header_match() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("Content-Type".to_string(), "/^text\\/.*/".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("content-type".to_string(), "text/html".to_string());
|
||||
m
|
||||
};
|
||||
assert!(headers_match(&patterns, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_header() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("X-Custom".to_string(), "value".to_string());
|
||||
m
|
||||
};
|
||||
let headers: HashMap<String, String> = HashMap::new();
|
||||
assert!(!headers_match(&patterns, &headers));
|
||||
}
|
||||
}
|
||||
126
rust/crates/rustproxy-routing/src/matchers/ip.rs
Normal file
126
rust/crates/rustproxy-routing/src/matchers/ip.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use ipnet::IpNet;
|
||||
|
||||
/// Match an IP address against a pattern.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `*` matches any IP
|
||||
/// - `192.168.1.0/24` CIDR range
|
||||
/// - `192.168.1.100` exact match
|
||||
/// - `192.168.1.*` wildcard (converted to CIDR)
|
||||
/// - `::ffff:192.168.1.100` IPv6-mapped IPv4
|
||||
pub fn ip_matches(pattern: &str, ip: &str) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize IPv4-mapped IPv6
|
||||
let normalized_ip = normalize_ip_str(ip);
|
||||
|
||||
// Try CIDR match
|
||||
if pattern.contains('/') {
|
||||
if let Ok(net) = IpNet::from_str(pattern) {
|
||||
if let Ok(addr) = IpAddr::from_str(&normalized_ip) {
|
||||
return net.contains(&addr);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle wildcard patterns like 192.168.1.*
|
||||
if pattern.contains('*') {
|
||||
let pattern_cidr = wildcard_to_cidr(pattern);
|
||||
if let Some(cidr) = pattern_cidr {
|
||||
if let Ok(net) = IpNet::from_str(&cidr) {
|
||||
if let Ok(addr) = IpAddr::from_str(&normalized_ip) {
|
||||
return net.contains(&addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
let normalized_pattern = normalize_ip_str(pattern);
|
||||
normalized_ip == normalized_pattern
|
||||
}
|
||||
|
||||
/// Check if an IP matches any of the given patterns.
|
||||
pub fn ip_matches_any(patterns: &[String], ip: &str) -> bool {
|
||||
patterns.iter().any(|p| ip_matches(p, ip))
|
||||
}
|
||||
|
||||
/// Normalize IPv4-mapped IPv6 addresses.
|
||||
fn normalize_ip_str(ip: &str) -> String {
|
||||
let ip = ip.trim();
|
||||
if ip.starts_with("::ffff:") {
|
||||
return ip[7..].to_string();
|
||||
}
|
||||
ip.to_string()
|
||||
}
|
||||
|
||||
/// Convert a wildcard IP pattern to CIDR notation.
|
||||
/// e.g., "192.168.1.*" -> "192.168.1.0/24"
|
||||
fn wildcard_to_cidr(pattern: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = pattern.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut octets = [0u8; 4];
|
||||
let mut prefix_len = 0;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if *part == "*" {
|
||||
break;
|
||||
}
|
||||
if let Ok(n) = part.parse::<u8>() {
|
||||
octets[i] = n;
|
||||
prefix_len += 8;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{}.{}.{}.{}/{}", octets[0], octets[1], octets[2], octets[3], prefix_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_all() {
|
||||
assert!(ip_matches("*", "192.168.1.100"));
|
||||
assert!(ip_matches("*", "::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
assert!(ip_matches("192.168.1.100", "192.168.1.100"));
|
||||
assert!(!ip_matches("192.168.1.100", "192.168.1.101"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr() {
|
||||
assert!(ip_matches("192.168.1.0/24", "192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.0/24", "192.168.1.1"));
|
||||
assert!(!ip_matches("192.168.1.0/24", "192.168.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern() {
|
||||
assert!(ip_matches("192.168.1.*", "192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.*", "192.168.1.1"));
|
||||
assert!(!ip_matches("192.168.1.*", "192.168.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_mapped() {
|
||||
assert!(ip_matches("192.168.1.100", "::ffff:192.168.1.100"));
|
||||
assert!(ip_matches("192.168.1.0/24", "::ffff:192.168.1.50"));
|
||||
}
|
||||
}
|
||||
9
rust/crates/rustproxy-routing/src/matchers/mod.rs
Normal file
9
rust/crates/rustproxy-routing/src/matchers/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod domain;
|
||||
pub mod path;
|
||||
pub mod ip;
|
||||
pub mod header;
|
||||
|
||||
pub use domain::*;
|
||||
pub use path::*;
|
||||
pub use ip::*;
|
||||
pub use header::*;
|
||||
65
rust/crates/rustproxy-routing/src/matchers/path.rs
Normal file
65
rust/crates/rustproxy-routing/src/matchers/path.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
/// Match a URL path against a pattern supporting wildcards.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `/api/*` matches `/api/anything` (single level)
|
||||
/// - `/api/**` matches `/api/any/depth/here`
|
||||
/// - `/exact/path` exact match
|
||||
/// - `/prefix*` prefix match
|
||||
pub fn path_matches(pattern: &str, path: &str) -> bool {
|
||||
// Exact match
|
||||
if pattern == path {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Double-star: match any depth
|
||||
if pattern.ends_with("/**") {
|
||||
let prefix = &pattern[..pattern.len() - 3];
|
||||
return path == prefix || path.starts_with(&format!("{}/", prefix));
|
||||
}
|
||||
|
||||
// Single-star at end: match single path segment
|
||||
if pattern.ends_with("/*") {
|
||||
let prefix = &pattern[..pattern.len() - 2];
|
||||
if path == prefix {
|
||||
return true;
|
||||
}
|
||||
if path.starts_with(&format!("{}/", prefix)) {
|
||||
let rest = &path[prefix.len() + 1..];
|
||||
// Single level means no more slashes
|
||||
return !rest.contains('/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Star anywhere: use glob matching
|
||||
if pattern.contains('*') {
|
||||
return glob_match::glob_match(pattern, path);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_path() {
|
||||
assert!(path_matches("/api/users", "/api/users"));
|
||||
assert!(!path_matches("/api/users", "/api/posts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_wildcard() {
|
||||
assert!(path_matches("/api/*", "/api/users"));
|
||||
assert!(path_matches("/api/*", "/api/posts"));
|
||||
assert!(!path_matches("/api/*", "/api/users/123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_wildcard() {
|
||||
assert!(path_matches("/api/**", "/api/users"));
|
||||
assert!(path_matches("/api/**", "/api/users/123"));
|
||||
assert!(path_matches("/api/**", "/api/users/123/posts"));
|
||||
}
|
||||
}
|
||||
545
rust/crates/rustproxy-routing/src/route_manager.rs
Normal file
545
rust/crates/rustproxy-routing/src/route_manager.rs
Normal file
@@ -0,0 +1,545 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rustproxy_config::{RouteConfig, RouteTarget, TlsMode};
|
||||
use crate::matchers;
|
||||
|
||||
/// Context for route matching (subset of connection info).
|
||||
pub struct MatchContext<'a> {
|
||||
pub port: u16,
|
||||
pub domain: Option<&'a str>,
|
||||
pub path: Option<&'a str>,
|
||||
pub client_ip: Option<&'a str>,
|
||||
pub tls_version: Option<&'a str>,
|
||||
pub headers: Option<&'a HashMap<String, String>>,
|
||||
pub is_tls: bool,
|
||||
}
|
||||
|
||||
/// Result of a route match.
|
||||
pub struct RouteMatchResult<'a> {
|
||||
pub route: &'a RouteConfig,
|
||||
pub target: Option<&'a RouteTarget>,
|
||||
}
|
||||
|
||||
/// Port-indexed route lookup with priority-based matching.
|
||||
/// This is the core routing engine.
|
||||
pub struct RouteManager {
|
||||
/// Routes indexed by port for O(1) port lookup.
|
||||
port_index: HashMap<u16, Vec<usize>>,
|
||||
/// All routes, sorted by priority (highest first).
|
||||
routes: Vec<RouteConfig>,
|
||||
}
|
||||
|
||||
impl RouteManager {
|
||||
/// Create a new RouteManager from a list of routes.
|
||||
pub fn new(routes: Vec<RouteConfig>) -> Self {
|
||||
let mut manager = Self {
|
||||
port_index: HashMap::new(),
|
||||
routes: Vec::new(),
|
||||
};
|
||||
|
||||
// Filter enabled routes and sort by priority
|
||||
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.routes = enabled_routes;
|
||||
manager
|
||||
}
|
||||
|
||||
/// Find the best matching route for the given context.
|
||||
pub fn find_route<'a>(&'a self, ctx: &MatchContext<'_>) -> Option<RouteMatchResult<'a>> {
|
||||
// Get routes for this port
|
||||
let route_indices = self.port_index.get(&ctx.port)?;
|
||||
|
||||
for &idx in route_indices {
|
||||
let route = &self.routes[idx];
|
||||
|
||||
if self.matches_route(route, ctx) {
|
||||
// Find the best matching target within the route
|
||||
let target = self.find_target(route, ctx);
|
||||
return Some(RouteMatchResult { route, target });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a route matches the given context.
|
||||
fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool {
|
||||
let rm = &route.route_match;
|
||||
|
||||
// Domain matching
|
||||
if let Some(ref domains) = rm.domains {
|
||||
if let Some(domain) = ctx.domain {
|
||||
let patterns = domains.to_vec();
|
||||
if !matchers::domain_matches_any(&patterns, domain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// If no domain provided but route requires domain, it depends on context
|
||||
// For TLS passthrough, we need SNI; for other cases we may still match
|
||||
}
|
||||
|
||||
// Path matching
|
||||
if let Some(ref pattern) = rm.path {
|
||||
if let Some(path) = ctx.path {
|
||||
if !matchers::path_matches(pattern, path) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Route requires path but none provided
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Client IP matching
|
||||
if let Some(ref client_ips) = rm.client_ip {
|
||||
if let Some(ip) = ctx.client_ip {
|
||||
if !matchers::ip_matches_any(client_ips, ip) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// TLS version matching
|
||||
if let Some(ref tls_versions) = rm.tls_version {
|
||||
if let Some(version) = ctx.tls_version {
|
||||
if !tls_versions.iter().any(|v| v == version) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Header matching
|
||||
if let Some(ref patterns) = rm.headers {
|
||||
if let Some(headers) = ctx.headers {
|
||||
if !matchers::headers_match(patterns, headers) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Find the best matching target within a route.
|
||||
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() {
|
||||
return Some(&targets[0]);
|
||||
}
|
||||
|
||||
// Sort candidates by priority (already in order from config)
|
||||
let mut best: Option<&RouteTarget> = None;
|
||||
let mut best_priority = i32::MIN;
|
||||
|
||||
for target in targets {
|
||||
let priority = target.priority.unwrap_or(0);
|
||||
|
||||
if let Some(ref tm) = target.target_match {
|
||||
if !self.matches_target(tm, ctx) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if priority > best_priority || best.is_none() {
|
||||
best = Some(target);
|
||||
best_priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first target without match criteria
|
||||
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 {
|
||||
// Port matching
|
||||
if let Some(ref ports) = tm.ports {
|
||||
if !ports.contains(&ctx.port) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Path matching
|
||||
if let Some(ref pattern) = tm.path {
|
||||
if let Some(path) = ctx.path {
|
||||
if !matchers::path_matches(pattern, path) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Header matching
|
||||
if let Some(ref patterns) = tm.headers {
|
||||
if let Some(headers) = ctx.headers {
|
||||
if !matchers::headers_match(patterns, headers) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get all unique listening ports.
|
||||
pub fn listening_ports(&self) -> Vec<u16> {
|
||||
let mut ports: Vec<u16> = self.port_index.keys().copied().collect();
|
||||
ports.sort();
|
||||
ports
|
||||
}
|
||||
|
||||
/// Get all routes for a specific port.
|
||||
pub fn routes_for_port(&self, port: u16) -> Vec<&RouteConfig> {
|
||||
self.port_index
|
||||
.get(&port)
|
||||
.map(|indices| indices.iter().map(|&i| &self.routes[i]).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the total number of enabled routes.
|
||||
pub fn route_count(&self) -> usize {
|
||||
self.routes.len()
|
||||
}
|
||||
|
||||
/// Check if any route on the given port requires SNI.
|
||||
pub fn port_requires_sni(&self, port: u16) -> bool {
|
||||
let routes = self.routes_for_port(port);
|
||||
|
||||
// If multiple passthrough routes on same port, SNI is needed
|
||||
let passthrough_routes: Vec<_> = routes
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.tls_mode() == Some(&TlsMode::Passthrough)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if passthrough_routes.len() > 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single passthrough route with specific domain restriction needs SNI
|
||||
if let Some(route) = passthrough_routes.first() {
|
||||
if let Some(ref domains) = route.route_match.domains {
|
||||
let domain_list = domains.to_vec();
|
||||
// If it's not just a wildcard, SNI is needed
|
||||
if !domain_list.iter().all(|d| *d == "*") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustproxy_config::*;
|
||||
|
||||
fn make_route(port: u16, domain: Option<&str>, priority: i32) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(port),
|
||||
domains: domain.map(|d| DomainSpec::Single(d.to_string())),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single("localhost".to_string()),
|
||||
port: PortSpec::Fixed(8080),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: Some(priority),
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_routing() {
|
||||
let routes = vec![
|
||||
make_route(80, Some("example.com"), 0),
|
||||
make_route(80, Some("other.com"), 0),
|
||||
];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("example.com"),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
|
||||
let result = manager.find_route(&ctx);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_ordering() {
|
||||
let routes = vec![
|
||||
make_route(80, Some("*.example.com"), 0),
|
||||
make_route(80, Some("api.example.com"), 10), // Higher priority
|
||||
];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("api.example.com"),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
// Should match the higher-priority specific route
|
||||
assert!(result.route.route_match.domains.as_ref()
|
||||
.map(|d| d.to_vec())
|
||||
.unwrap()
|
||||
.contains(&"api.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match() {
|
||||
let routes = vec![make_route(80, Some("example.com"), 0)];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 443, // Different port
|
||||
domain: Some("example.com"),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_routes_excluded() {
|
||||
let mut route = make_route(80, Some("example.com"), 0);
|
||||
route.enabled = Some(false);
|
||||
let manager = RouteManager::new(vec![route]);
|
||||
assert_eq!(manager.route_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_listening_ports() {
|
||||
let routes = vec![
|
||||
make_route(80, Some("a.com"), 0),
|
||||
make_route(443, Some("b.com"), 0),
|
||||
make_route(80, Some("c.com"), 0), // duplicate port
|
||||
];
|
||||
let manager = RouteManager::new(routes);
|
||||
let ports = manager.listening_ports();
|
||||
assert_eq!(ports, vec![80, 443]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_requires_sni_single_passthrough() {
|
||||
let mut route = make_route(443, Some("example.com"), 0);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
let manager = RouteManager::new(vec![route]);
|
||||
// Single passthrough route with specific domain needs SNI
|
||||
assert!(manager.port_requires_sni(443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_requires_sni_wildcard_only() {
|
||||
let mut route = make_route(443, Some("*"), 0);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
let manager = RouteManager::new(vec![route]);
|
||||
// Single passthrough route with wildcard doesn't need SNI
|
||||
assert!(!manager.port_requires_sni(443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_for_port() {
|
||||
let routes = vec![
|
||||
make_route(80, Some("a.com"), 0),
|
||||
make_route(80, Some("b.com"), 0),
|
||||
make_route(443, Some("c.com"), 0),
|
||||
];
|
||||
let manager = RouteManager::new(routes);
|
||||
assert_eq!(manager.routes_for_port(80).len(), 2);
|
||||
assert_eq!(manager.routes_for_port(443).len(), 1);
|
||||
assert_eq!(manager.routes_for_port(8080).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_domain_matches_any() {
|
||||
let routes = vec![make_route(80, Some("*"), 0)];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("anything.example.com"),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_domain_route_matches_any_domain() {
|
||||
let routes = vec![make_route(80, None, 0)];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("example.com"),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_target_sub_matching() {
|
||||
let mut route = make_route(80, Some("example.com"), 0);
|
||||
route.action.targets = Some(vec![
|
||||
RouteTarget {
|
||||
target_match: Some(rustproxy_config::TargetMatch {
|
||||
ports: None,
|
||||
path: Some("/api/*".to_string()),
|
||||
headers: None,
|
||||
method: None,
|
||||
}),
|
||||
host: HostSpec::Single("api-backend".to_string()),
|
||||
port: PortSpec::Fixed(3000),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
priority: Some(10),
|
||||
},
|
||||
RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single("default-backend".to_string()),
|
||||
port: PortSpec::Fixed(8080),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
priority: None,
|
||||
},
|
||||
]);
|
||||
let manager = RouteManager::new(vec![route]);
|
||||
|
||||
// Should match the API target
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("example.com"),
|
||||
path: Some("/api/users"),
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
assert_eq!(result.target.unwrap().host.first(), "api-backend");
|
||||
|
||||
// Should fall back to default target
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: Some("example.com"),
|
||||
path: Some("/home"),
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
};
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
assert_eq!(result.target.unwrap().host.first(), "default-backend");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user