feat(smart-proxy): add typed Rust config serialization and regex header contract coverage
This commit is contained in:
@@ -129,7 +129,6 @@ pub struct RustProxyOptions {
|
||||
pub defaults: Option<DefaultConfig>,
|
||||
|
||||
// ─── Timeout Settings ────────────────────────────────────────────
|
||||
|
||||
/// Timeout for establishing connection to backend (ms), default: 30000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connection_timeout: Option<u64>,
|
||||
@@ -159,7 +158,6 @@ pub struct RustProxyOptions {
|
||||
pub graceful_shutdown_timeout: Option<u64>,
|
||||
|
||||
// ─── Socket Optimization ─────────────────────────────────────────
|
||||
|
||||
/// Disable Nagle's algorithm (default: true)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub no_delay: Option<bool>,
|
||||
@@ -177,7 +175,6 @@ pub struct RustProxyOptions {
|
||||
pub max_pending_data_size: Option<u64>,
|
||||
|
||||
// ─── Enhanced Features ───────────────────────────────────────────
|
||||
|
||||
/// Disable inactivity checking entirely
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_inactivity_check: Option<bool>,
|
||||
@@ -199,7 +196,6 @@ pub struct RustProxyOptions {
|
||||
pub enable_randomized_timeouts: Option<bool>,
|
||||
|
||||
// ─── Rate Limiting ───────────────────────────────────────────────
|
||||
|
||||
/// Maximum simultaneous connections from a single IP
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_connections_per_ip: Option<u64>,
|
||||
@@ -213,7 +209,6 @@ pub struct RustProxyOptions {
|
||||
pub max_connections: Option<u64>,
|
||||
|
||||
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
||||
|
||||
/// How to treat keep-alive connections
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
||||
@@ -227,7 +222,6 @@ pub struct RustProxyOptions {
|
||||
pub extended_keep_alive_lifetime: Option<u64>,
|
||||
|
||||
// ─── HttpProxy Integration ───────────────────────────────────────
|
||||
|
||||
/// Array of ports to forward to HttpProxy
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_http_proxy: Option<Vec<u16>>,
|
||||
@@ -237,13 +231,11 @@ pub struct RustProxyOptions {
|
||||
pub http_proxy_port: Option<u16>,
|
||||
|
||||
// ─── Metrics ─────────────────────────────────────────────────────
|
||||
|
||||
/// Metrics configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metrics: Option<MetricsConfig>,
|
||||
|
||||
// ─── ACME ────────────────────────────────────────────────────────
|
||||
|
||||
/// Global ACME configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub acme: Option<AcmeOptions>,
|
||||
@@ -318,7 +310,8 @@ impl RustProxyOptions {
|
||||
|
||||
/// Get all unique ports that routes listen on.
|
||||
pub fn all_listening_ports(&self) -> Vec<u16> {
|
||||
let mut ports: Vec<u16> = self.routes
|
||||
let mut ports: Vec<u16> = self
|
||||
.routes
|
||||
.iter()
|
||||
.flat_map(|r| r.listening_ports())
|
||||
.collect();
|
||||
@@ -340,7 +333,12 @@ mod tests {
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(listen_port),
|
||||
domains: Some(DomainSpec::Single(domain.to_string())),
|
||||
path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None,
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
@@ -348,14 +346,30 @@ mod tests {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host.to_string()),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None,
|
||||
headers: None, advanced: None, backend_transport: None, priority: None,
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None, websocket: None, load_balancing: None, advanced: None,
|
||||
options: None, send_proxy_protocol: None, udp: None,
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None, security: None, name: None, description: None,
|
||||
priority: None, tags: None, enabled: None,
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,8 +377,12 @@ mod tests {
|
||||
let mut route = make_route(domain, host, port, 443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None, acme: None, versions: None, ciphers: None,
|
||||
honor_cipher_order: None, session_timeout: None,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
@@ -410,6 +428,209 @@ mod tests {
|
||||
assert_eq!(parsed.connection_timeout, Some(5000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_ts_contract_route_shapes() {
|
||||
let value = serde_json::json!({
|
||||
"routes": [{
|
||||
"name": "contract-route",
|
||||
"match": {
|
||||
"ports": [443, { "from": 8443, "to": 8444 }],
|
||||
"domains": ["api.example.com", "*.example.com"],
|
||||
"transport": "udp",
|
||||
"protocol": "http3",
|
||||
"headers": {
|
||||
"content-type": "/^application\\/json$/i"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"type": "forward",
|
||||
"targets": [{
|
||||
"match": {
|
||||
"ports": [443],
|
||||
"path": "/api/*",
|
||||
"method": ["GET"],
|
||||
"headers": {
|
||||
"x-env": "/^(prod|stage)$/"
|
||||
}
|
||||
},
|
||||
"host": ["backend-a", "backend-b"],
|
||||
"port": "preserve",
|
||||
"sendProxyProtocol": true,
|
||||
"backendTransport": "tcp"
|
||||
}],
|
||||
"tls": {
|
||||
"mode": "terminate",
|
||||
"certificate": "auto"
|
||||
},
|
||||
"sendProxyProtocol": true,
|
||||
"udp": {
|
||||
"maxSessionsPerIp": 321,
|
||||
"quic": {
|
||||
"enableHttp3": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"ipAllowList": [{
|
||||
"ip": "10.0.0.0/8",
|
||||
"domains": ["api.example.com"]
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"preserveSourceIp": true,
|
||||
"proxyIps": ["10.0.0.1"],
|
||||
"acceptProxyProtocol": true,
|
||||
"sendProxyProtocol": true,
|
||||
"noDelay": true,
|
||||
"keepAlive": true,
|
||||
"keepAliveInitialDelay": 1500,
|
||||
"maxPendingDataSize": 4096,
|
||||
"disableInactivityCheck": true,
|
||||
"enableKeepAliveProbes": true,
|
||||
"enableDetailedLogging": true,
|
||||
"enableTlsDebugLogging": true,
|
||||
"enableRandomizedTimeouts": true,
|
||||
"connectionTimeout": 5000,
|
||||
"initialDataTimeout": 7000,
|
||||
"socketTimeout": 9000,
|
||||
"inactivityCheckInterval": 1100,
|
||||
"maxConnectionLifetime": 13000,
|
||||
"inactivityTimeout": 15000,
|
||||
"gracefulShutdownTimeout": 17000,
|
||||
"maxConnectionsPerIp": 20,
|
||||
"connectionRateLimitPerMinute": 30,
|
||||
"keepAliveTreatment": "extended",
|
||||
"keepAliveInactivityMultiplier": 2.0,
|
||||
"extendedKeepAliveLifetime": 19000,
|
||||
"metrics": {
|
||||
"enabled": true,
|
||||
"sampleIntervalMs": 250,
|
||||
"retentionSeconds": 60
|
||||
},
|
||||
"acme": {
|
||||
"enabled": true,
|
||||
"email": "ops@example.com",
|
||||
"environment": "staging",
|
||||
"useProduction": false,
|
||||
"skipConfiguredCerts": true,
|
||||
"renewThresholdDays": 14,
|
||||
"renewCheckIntervalHours": 12,
|
||||
"autoRenew": true,
|
||||
"port": 80
|
||||
}
|
||||
});
|
||||
|
||||
let options: RustProxyOptions = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(options.routes.len(), 1);
|
||||
assert_eq!(options.preserve_source_ip, Some(true));
|
||||
assert_eq!(options.proxy_ips, Some(vec!["10.0.0.1".to_string()]));
|
||||
assert_eq!(options.accept_proxy_protocol, Some(true));
|
||||
assert_eq!(options.send_proxy_protocol, Some(true));
|
||||
assert_eq!(options.no_delay, Some(true));
|
||||
assert_eq!(options.keep_alive, Some(true));
|
||||
assert_eq!(options.keep_alive_initial_delay, Some(1500));
|
||||
assert_eq!(options.max_pending_data_size, Some(4096));
|
||||
assert_eq!(options.disable_inactivity_check, Some(true));
|
||||
assert_eq!(options.enable_keep_alive_probes, Some(true));
|
||||
assert_eq!(options.enable_detailed_logging, Some(true));
|
||||
assert_eq!(options.enable_tls_debug_logging, Some(true));
|
||||
assert_eq!(options.enable_randomized_timeouts, Some(true));
|
||||
assert_eq!(options.connection_timeout, Some(5000));
|
||||
assert_eq!(options.initial_data_timeout, Some(7000));
|
||||
assert_eq!(options.socket_timeout, Some(9000));
|
||||
assert_eq!(options.inactivity_check_interval, Some(1100));
|
||||
assert_eq!(options.max_connection_lifetime, Some(13000));
|
||||
assert_eq!(options.inactivity_timeout, Some(15000));
|
||||
assert_eq!(options.graceful_shutdown_timeout, Some(17000));
|
||||
assert_eq!(options.max_connections_per_ip, Some(20));
|
||||
assert_eq!(options.connection_rate_limit_per_minute, Some(30));
|
||||
assert_eq!(
|
||||
options.keep_alive_treatment,
|
||||
Some(KeepAliveTreatment::Extended)
|
||||
);
|
||||
assert_eq!(options.keep_alive_inactivity_multiplier, Some(2.0));
|
||||
assert_eq!(options.extended_keep_alive_lifetime, Some(19000));
|
||||
|
||||
let route = &options.routes[0];
|
||||
assert_eq!(route.route_match.transport, Some(TransportProtocol::Udp));
|
||||
assert_eq!(route.route_match.protocol.as_deref(), Some("http3"));
|
||||
assert_eq!(
|
||||
route
|
||||
.route_match
|
||||
.headers
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("content-type")
|
||||
.unwrap(),
|
||||
"/^application\\/json$/i"
|
||||
);
|
||||
|
||||
let target = &route.action.targets.as_ref().unwrap()[0];
|
||||
assert!(matches!(target.host, HostSpec::List(_)));
|
||||
assert!(matches!(target.port, PortSpec::Special(ref p) if p == "preserve"));
|
||||
assert_eq!(target.backend_transport, Some(TransportProtocol::Tcp));
|
||||
assert_eq!(target.send_proxy_protocol, Some(true));
|
||||
assert_eq!(
|
||||
target
|
||||
.target_match
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.headers
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("x-env")
|
||||
.unwrap(),
|
||||
"/^(prod|stage)$/"
|
||||
);
|
||||
assert_eq!(route.action.send_proxy_protocol, Some(true));
|
||||
assert_eq!(
|
||||
route.action.udp.as_ref().unwrap().max_sessions_per_ip,
|
||||
Some(321)
|
||||
);
|
||||
assert_eq!(
|
||||
route
|
||||
.action
|
||||
.udp
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.quic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enable_http3,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let allow_list = route
|
||||
.security
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.ip_allow_list
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&allow_list[0],
|
||||
crate::security_types::IpAllowEntry::DomainScoped { ip, domains }
|
||||
if ip == "10.0.0.0/8" && domains == &vec!["api.example.com".to_string()]
|
||||
));
|
||||
|
||||
let metrics = options.metrics.as_ref().unwrap();
|
||||
assert_eq!(metrics.enabled, Some(true));
|
||||
assert_eq!(metrics.sample_interval_ms, Some(250));
|
||||
assert_eq!(metrics.retention_seconds, Some(60));
|
||||
|
||||
let acme = options.acme.as_ref().unwrap();
|
||||
assert_eq!(acme.enabled, Some(true));
|
||||
assert_eq!(acme.email.as_deref(), Some("ops@example.com"));
|
||||
assert_eq!(acme.environment, Some(AcmeEnvironment::Staging));
|
||||
assert_eq!(acme.use_production, Some(false));
|
||||
assert_eq!(acme.skip_configured_certs, Some(true));
|
||||
assert_eq!(acme.renew_threshold_days, Some(14));
|
||||
assert_eq!(acme.renew_check_interval_hours, Some(12));
|
||||
assert_eq!(acme.auto_renew, Some(true));
|
||||
assert_eq!(acme.port, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_timeouts() {
|
||||
let options = RustProxyOptions::default();
|
||||
@@ -438,9 +659,9 @@ mod tests {
|
||||
fn test_all_listening_ports() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
make_route("a.com", "backend", 8080, 80), // port 80
|
||||
make_route("a.com", "backend", 8080, 80), // port 80
|
||||
make_passthrough_route("b.com", "backend", 443), // port 443
|
||||
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -464,9 +685,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_example_json() {
|
||||
let content = std::fs::read_to_string(
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/../../config/example.json")
|
||||
).unwrap();
|
||||
let content = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../config/example.json"
|
||||
))
|
||||
.unwrap();
|
||||
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(options.routes.len(), 4);
|
||||
let ports = options.all_listening_ports();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::tls_types::RouteTls;
|
||||
use crate::security_types::RouteSecurity;
|
||||
use crate::tls_types::RouteTls;
|
||||
|
||||
// ─── Port Range ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,12 +32,13 @@ impl PortRange {
|
||||
pub fn to_ports(&self) -> Vec<u16> {
|
||||
match self {
|
||||
PortRange::Single(p) => vec![*p],
|
||||
PortRange::List(items) => {
|
||||
items.iter().flat_map(|item| match item {
|
||||
PortRange::List(items) => items
|
||||
.iter()
|
||||
.flat_map(|item| match item {
|
||||
PortRangeItem::Port(p) => vec![*p],
|
||||
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
|
||||
}).collect()
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,8 @@ impl From<Vec<&str>> for DomainSpec {
|
||||
}
|
||||
|
||||
/// Header match value: either exact string or regex pattern.
|
||||
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
|
||||
/// In JSON, all values come as strings. Regex patterns use JS-style literal syntax,
|
||||
/// e.g. `/^application\/json$/` or `/^application\/json$/i`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum HeaderMatchValue {
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
use std::collections::HashMap;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn compile_regex_pattern(pattern: &str) -> Option<Regex> {
|
||||
if !pattern.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_slash = pattern.rfind('/')?;
|
||||
if last_slash == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let regex_body = &pattern[1..last_slash];
|
||||
let flags = &pattern[last_slash + 1..];
|
||||
|
||||
let mut inline_flags = String::new();
|
||||
for flag in flags.chars() {
|
||||
match flag {
|
||||
'i' | 'm' | 's' | 'u' => {
|
||||
if !inline_flags.contains(flag) {
|
||||
inline_flags.push(flag);
|
||||
}
|
||||
}
|
||||
'g' => {
|
||||
// Global has no effect for single header matching.
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
let compiled = if inline_flags.is_empty() {
|
||||
regex_body.to_string()
|
||||
} else {
|
||||
format!("(?{}){}", inline_flags, regex_body)
|
||||
};
|
||||
|
||||
Regex::new(&compiled).ok()
|
||||
}
|
||||
|
||||
/// Match HTTP headers against a set of patterns.
|
||||
///
|
||||
@@ -24,16 +61,15 @@ pub fn headers_match(
|
||||
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) => {
|
||||
// Check if pattern is a regex literal (/pattern/ or /pattern/flags)
|
||||
if pattern.starts_with('/') && pattern.len() > 2 {
|
||||
match compile_regex_pattern(pattern) {
|
||||
Some(re) => {
|
||||
if !re.is_match(header_value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
None => {
|
||||
// Invalid regex, fall back to exact match
|
||||
if header_value != pattern {
|
||||
return false;
|
||||
@@ -85,6 +121,24 @@ mod tests {
|
||||
assert!(headers_match(&patterns, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_header_match_with_flags() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"Content-Type".to_string(),
|
||||
"/^application\\/json$/i".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_missing_header() {
|
||||
let patterns: HashMap<String, String> = {
|
||||
|
||||
Reference in New Issue
Block a user