feat(smart-proxy): add UDP transport support with QUIC/HTTP3 routing and datagram handler relay
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rustproxy_config::{RouteConfig, RouteTarget, TlsMode};
|
||||
use rustproxy_config::{RouteConfig, RouteTarget, TransportProtocol, TlsMode};
|
||||
use crate::matchers;
|
||||
|
||||
/// Context for route matching (subset of connection info).
|
||||
@@ -12,8 +12,10 @@ pub struct MatchContext<'a> {
|
||||
pub tls_version: Option<&'a str>,
|
||||
pub headers: Option<&'a HashMap<String, String>>,
|
||||
pub is_tls: bool,
|
||||
/// Detected protocol: "http" or "tcp". None when unknown (e.g. pre-TLS-termination).
|
||||
/// Detected protocol: "http", "tcp", "udp", "quic". None when unknown.
|
||||
pub protocol: Option<&'a str>,
|
||||
/// Transport protocol of the listener: None = TCP (backward compat), Some(Udp), Some(All).
|
||||
pub transport: Option<TransportProtocol>,
|
||||
}
|
||||
|
||||
/// Result of a route match.
|
||||
@@ -92,6 +94,22 @@ impl RouteManager {
|
||||
fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool {
|
||||
let rm = &route.route_match;
|
||||
|
||||
// Transport filtering: ensure route transport matches context transport
|
||||
let route_transport = rm.transport.as_ref();
|
||||
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,
|
||||
// Route requires TCP only — reject UDP contexts
|
||||
(Some(TransportProtocol::Tcp), Some(TransportProtocol::Udp)) => return false,
|
||||
// Route has no transport (default = TCP) — reject UDP contexts
|
||||
(None, Some(TransportProtocol::Udp)) => return false,
|
||||
// All other combinations match: All matches everything, same transport matches,
|
||||
// None + None/Tcp matches (backward compat)
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Domain matching
|
||||
if let Some(ref domains) = rm.domains {
|
||||
if let Some(domain) = ctx.domain {
|
||||
@@ -303,6 +321,7 @@ mod tests {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(port),
|
||||
transport: None,
|
||||
domains: domain.map(|d| DomainSpec::Single(d.to_string())),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
@@ -322,6 +341,7 @@ mod tests {
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
@@ -332,6 +352,7 @@ mod tests {
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
@@ -360,6 +381,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
let result = manager.find_route(&ctx);
|
||||
@@ -383,6 +405,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
@@ -407,6 +430,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
@@ -493,6 +517,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
@@ -513,6 +538,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: true,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
@@ -533,6 +559,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: true,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
@@ -553,6 +580,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: true,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
@@ -577,6 +605,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: true,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
let result = manager.find_route(&ctx);
|
||||
@@ -602,6 +631,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
@@ -621,6 +651,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
@@ -645,6 +676,7 @@ mod tests {
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: Some(10),
|
||||
},
|
||||
RouteTarget {
|
||||
@@ -657,6 +689,7 @@ mod tests {
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
},
|
||||
]);
|
||||
@@ -672,6 +705,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
assert_eq!(result.target.unwrap().host.first(), "api-backend");
|
||||
@@ -686,6 +720,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
let result = manager.find_route(&ctx).unwrap();
|
||||
assert_eq!(result.target.unwrap().host.first(), "default-backend");
|
||||
@@ -711,6 +746,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("http"),
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
}
|
||||
@@ -729,6 +765,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("tcp"),
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
}
|
||||
@@ -748,6 +785,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("http"),
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx_http).is_some());
|
||||
|
||||
@@ -760,6 +798,7 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("tcp"),
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx_tcp).is_some());
|
||||
}
|
||||
@@ -780,7 +819,182 @@ mod tests {
|
||||
headers: None,
|
||||
is_tls: true,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
}
|
||||
|
||||
// ===== Transport filtering tests =====
|
||||
|
||||
fn make_route_with_transport(port: u16, transport: Option<TransportProtocol>) -> RouteConfig {
|
||||
let mut route = make_route(port, None, 0);
|
||||
route.route_match.transport = transport;
|
||||
route
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_udp_route_matches_udp_context() {
|
||||
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 53,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("udp"),
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_udp_route_rejects_tcp_context() {
|
||||
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
// TCP context (transport: None = TCP)
|
||||
let ctx = MatchContext {
|
||||
port: 53,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_tcp_route_rejects_udp_context() {
|
||||
let routes = vec![make_route_with_transport(80, Some(TransportProtocol::Tcp))];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
let ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("udp"),
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
assert!(manager.find_route(&ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_all_matches_both() {
|
||||
let routes = vec![make_route_with_transport(443, Some(TransportProtocol::All))];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
// TCP context
|
||||
let tcp_ctx = MatchContext {
|
||||
port: 443,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&tcp_ctx).is_some());
|
||||
|
||||
// UDP context
|
||||
let udp_ctx = MatchContext {
|
||||
port: 443,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("udp"),
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
assert!(manager.find_route(&udp_ctx).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_none_default_matches_tcp_only() {
|
||||
// Route with no transport field = TCP only (backward compat)
|
||||
let routes = vec![make_route_with_transport(80, None)];
|
||||
let manager = RouteManager::new(routes);
|
||||
|
||||
// TCP context should match
|
||||
let tcp_ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
assert!(manager.find_route(&tcp_ctx).is_some());
|
||||
|
||||
// UDP context should NOT match
|
||||
let udp_ctx = MatchContext {
|
||||
port: 80,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("udp"),
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
assert!(manager.find_route(&udp_ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_mixed_routes_same_port() {
|
||||
// TCP and UDP routes on the same port — each matches only its transport
|
||||
let mut tcp_route = make_route_with_transport(443, Some(TransportProtocol::Tcp));
|
||||
tcp_route.name = Some("tcp-route".to_string());
|
||||
let mut udp_route = make_route_with_transport(443, Some(TransportProtocol::Udp));
|
||||
udp_route.name = Some("udp-route".to_string());
|
||||
|
||||
let manager = RouteManager::new(vec![tcp_route, udp_route]);
|
||||
|
||||
let tcp_ctx = MatchContext {
|
||||
port: 443,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: None,
|
||||
transport: None,
|
||||
};
|
||||
let result = manager.find_route(&tcp_ctx).unwrap();
|
||||
assert_eq!(result.route.name.as_deref(), Some("tcp-route"));
|
||||
|
||||
let udp_ctx = MatchContext {
|
||||
port: 443,
|
||||
domain: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
is_tls: false,
|
||||
protocol: Some("udp"),
|
||||
transport: Some(TransportProtocol::Udp),
|
||||
};
|
||||
let result = manager.find_route(&udp_ctx).unwrap();
|
||||
assert_eq!(result.route.name.as_deref(), Some("udp-route"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user