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:
334
rust/crates/rustproxy-config/src/helpers.rs
Normal file
334
rust/crates/rustproxy-config/src/helpers.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use crate::route_types::*;
|
||||
use crate::tls_types::*;
|
||||
|
||||
/// Create a simple HTTP forwarding route.
|
||||
/// Equivalent to SmartProxy's `createHttpRoute()`.
|
||||
pub fn create_http_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains.into()),
|
||||
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(target_host.into()),
|
||||
port: PortSpec::Fixed(target_port),
|
||||
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: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an HTTPS termination route.
|
||||
/// Equivalent to SmartProxy's `createHttpsTerminateRoute()`.
|
||||
pub fn create_https_terminate_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Terminate,
|
||||
certificate: Some(CertificateSpec::Auto("auto".to_string())),
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create a TLS passthrough route.
|
||||
/// Equivalent to SmartProxy's `createHttpsPassthroughRoute()`.
|
||||
pub fn create_https_passthrough_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create an HTTP-to-HTTPS redirect route.
|
||||
/// Equivalent to SmartProxy's `createHttpToHttpsRedirect()`.
|
||||
pub fn create_http_to_https_redirect(
|
||||
domains: impl Into<DomainSpec>,
|
||||
) -> RouteConfig {
|
||||
let domains = domains.into();
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: None,
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: Some(RouteAdvanced {
|
||||
timeout: None,
|
||||
headers: None,
|
||||
keep_alive: None,
|
||||
static_files: None,
|
||||
test_response: Some(RouteTestResponse {
|
||||
status: 301,
|
||||
headers: {
|
||||
let mut h = std::collections::HashMap::new();
|
||||
h.insert("Location".to_string(), "https://{domain}{path}".to_string());
|
||||
h
|
||||
},
|
||||
body: String::new(),
|
||||
}),
|
||||
url_rewrite: None,
|
||||
}),
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("HTTP to HTTPS Redirect".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a complete HTTPS server with HTTP redirect.
|
||||
/// Equivalent to SmartProxy's `createCompleteHttpsServer()`.
|
||||
pub fn create_complete_https_server(
|
||||
domain: impl Into<String>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> Vec<RouteConfig> {
|
||||
let domain = domain.into();
|
||||
let target_host = target_host.into();
|
||||
|
||||
vec![
|
||||
create_http_to_https_redirect(DomainSpec::Single(domain.clone())),
|
||||
create_https_terminate_route(
|
||||
DomainSpec::Single(domain),
|
||||
target_host,
|
||||
target_port,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Create a load balancer route.
|
||||
/// Equivalent to SmartProxy's `createLoadBalancerRoute()`.
|
||||
pub fn create_load_balancer_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
targets: Vec<(String, u16)>,
|
||||
tls: Option<RouteTls>,
|
||||
) -> RouteConfig {
|
||||
let route_targets: Vec<RouteTarget> = targets
|
||||
.into_iter()
|
||||
.map(|(host, port)| RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
priority: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let port = if tls.is_some() { 443 } else { 80 };
|
||||
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(port),
|
||||
domains: Some(domains.into()),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(route_targets),
|
||||
tls,
|
||||
websocket: None,
|
||||
load_balancing: Some(RouteLoadBalancing {
|
||||
algorithm: LoadBalancingAlgorithm::RoundRobin,
|
||||
health_check: None,
|
||||
}),
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("Load Balancer".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience conversions for DomainSpec
|
||||
impl From<&str> for DomainSpec {
|
||||
fn from(s: &str) -> Self {
|
||||
DomainSpec::Single(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DomainSpec {
|
||||
fn from(s: String) -> Self {
|
||||
DomainSpec::Single(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for DomainSpec {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
DomainSpec::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&str>> for DomainSpec {
|
||||
fn from(v: Vec<&str>) -> Self {
|
||||
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tls_types::TlsMode;
|
||||
|
||||
#[test]
|
||||
fn test_create_http_route() {
|
||||
let route = create_http_route("example.com", "localhost", 8080);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
let domains = route.route_match.domains.as_ref().unwrap().to_vec();
|
||||
assert_eq!(domains, vec!["example.com"]);
|
||||
let target = &route.action.targets.as_ref().unwrap()[0];
|
||||
assert_eq!(target.host.first(), "localhost");
|
||||
assert_eq!(target.port.resolve(80), 8080);
|
||||
assert!(route.action.tls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_terminate_route() {
|
||||
let route = create_https_terminate_route("api.example.com", "backend", 3000);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Terminate);
|
||||
assert!(tls.certificate.as_ref().unwrap().is_auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_passthrough_route() {
|
||||
let route = create_https_passthrough_route("secure.example.com", "backend", 443);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Passthrough);
|
||||
assert!(tls.certificate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_http_to_https_redirect() {
|
||||
let route = create_http_to_https_redirect("example.com");
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert!(route.action.targets.is_none());
|
||||
let test_response = route.action.advanced.as_ref().unwrap().test_response.as_ref().unwrap();
|
||||
assert_eq!(test_response.status, 301);
|
||||
assert!(test_response.headers.contains_key("Location"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_complete_https_server() {
|
||||
let routes = create_complete_https_server("example.com", "backend", 8080);
|
||||
assert_eq!(routes.len(), 2);
|
||||
// First route is HTTP redirect
|
||||
assert_eq!(routes[0].route_match.ports.to_ports(), vec![80]);
|
||||
// Second route is HTTPS terminate
|
||||
assert_eq!(routes[1].route_match.ports.to_ports(), vec![443]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_load_balancer_route() {
|
||||
let targets = vec![
|
||||
("backend1".to_string(), 8080),
|
||||
("backend2".to_string(), 8080),
|
||||
("backend3".to_string(), 8080),
|
||||
];
|
||||
let route = create_load_balancer_route("*.example.com", targets, None);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert_eq!(route.action.targets.as_ref().unwrap().len(), 3);
|
||||
let lb = route.action.load_balancing.as_ref().unwrap();
|
||||
assert_eq!(lb.algorithm, LoadBalancingAlgorithm::RoundRobin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_str() {
|
||||
let spec: DomainSpec = "example.com".into();
|
||||
assert_eq!(spec.to_vec(), vec!["example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_vec() {
|
||||
let spec: DomainSpec = vec!["a.com", "b.com"].into();
|
||||
assert_eq!(spec.to_vec(), vec!["a.com", "b.com"]);
|
||||
}
|
||||
}
|
||||
19
rust/crates/rustproxy-config/src/lib.rs
Normal file
19
rust/crates/rustproxy-config/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! # rustproxy-config
|
||||
//!
|
||||
//! Configuration types for RustProxy, fully compatible with SmartProxy's JSON schema.
|
||||
//! All types use `#[serde(rename_all = "camelCase")]` to match TypeScript field naming.
|
||||
|
||||
pub mod route_types;
|
||||
pub mod proxy_options;
|
||||
pub mod tls_types;
|
||||
pub mod security_types;
|
||||
pub mod validation;
|
||||
pub mod helpers;
|
||||
|
||||
// Re-export all primary types
|
||||
pub use route_types::*;
|
||||
pub use proxy_options::*;
|
||||
pub use tls_types::*;
|
||||
pub use security_types::*;
|
||||
pub use validation::*;
|
||||
pub use helpers::*;
|
||||
439
rust/crates/rustproxy-config/src/proxy_options.rs
Normal file
439
rust/crates/rustproxy-config/src/proxy_options.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::route_types::RouteConfig;
|
||||
|
||||
/// Global ACME configuration options.
|
||||
/// Matches TypeScript: `IAcmeOptions`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcmeOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
/// Required when any route uses certificate: 'auto'
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub environment: Option<AcmeEnvironment>,
|
||||
/// Alias for email
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_email: Option<String>,
|
||||
/// Port for HTTP-01 challenges (default: 80)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub port: Option<u16>,
|
||||
/// Use Let's Encrypt production (default: false)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_production: Option<bool>,
|
||||
/// Days before expiry to renew (default: 30)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub renew_threshold_days: Option<u32>,
|
||||
/// Enable automatic renewal (default: true)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_renew: Option<bool>,
|
||||
/// Directory to store certificates (default: './certs')
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub certificate_store: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skip_configured_certs: Option<bool>,
|
||||
/// How often to check for renewals (default: 24)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub renew_check_interval_hours: Option<u32>,
|
||||
}
|
||||
|
||||
/// ACME environment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AcmeEnvironment {
|
||||
Production,
|
||||
Staging,
|
||||
}
|
||||
|
||||
/// Default target configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultTarget {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Default security configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultSecurity {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_allow_list: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_block_list: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_connections: Option<u64>,
|
||||
}
|
||||
|
||||
/// Default configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<DefaultTarget>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub security: Option<DefaultSecurity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preserve_source_ip: Option<bool>,
|
||||
}
|
||||
|
||||
/// Keep-alive treatment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KeepAliveTreatment {
|
||||
Standard,
|
||||
Extended,
|
||||
Immortal,
|
||||
}
|
||||
|
||||
/// Metrics configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetricsConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sample_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retention_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// RustProxy configuration options.
|
||||
/// Matches TypeScript: `ISmartProxyOptions`
|
||||
///
|
||||
/// This is the top-level configuration that can be loaded from a JSON file
|
||||
/// or constructed programmatically.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RustProxyOptions {
|
||||
/// The unified configuration array (required)
|
||||
pub routes: Vec<RouteConfig>,
|
||||
|
||||
/// Preserve client IP when forwarding
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preserve_source_ip: Option<bool>,
|
||||
|
||||
/// List of trusted proxy IPs that can send PROXY protocol
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_ips: Option<Vec<String>>,
|
||||
|
||||
/// Global option to accept PROXY protocol
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub accept_proxy_protocol: Option<bool>,
|
||||
|
||||
/// Global option to send PROXY protocol to all targets
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub send_proxy_protocol: Option<bool>,
|
||||
|
||||
/// Global/default settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
|
||||
/// Timeout for initial data/SNI (ms), default: 60000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub initial_data_timeout: Option<u64>,
|
||||
|
||||
/// Socket inactivity timeout (ms), default: 3600000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub socket_timeout: Option<u64>,
|
||||
|
||||
/// How often to check for inactive connections (ms), default: 60000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inactivity_check_interval: Option<u64>,
|
||||
|
||||
/// Default max connection lifetime (ms), default: 86400000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_connection_lifetime: Option<u64>,
|
||||
|
||||
/// Inactivity timeout (ms), default: 14400000
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inactivity_timeout: Option<u64>,
|
||||
|
||||
/// Maximum time to wait for connections to close during shutdown (ms)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
|
||||
/// Enable TCP keepalive (default: true)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive: Option<bool>,
|
||||
|
||||
/// Initial delay before sending keepalive probes (ms)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive_initial_delay: Option<u64>,
|
||||
|
||||
/// Maximum bytes to buffer during connection setup
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
|
||||
/// Enable TCP keep-alive probes
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_keep_alive_probes: Option<bool>,
|
||||
|
||||
/// Enable detailed connection logging
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_detailed_logging: Option<bool>,
|
||||
|
||||
/// Enable TLS handshake debug logging
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_tls_debug_logging: Option<bool>,
|
||||
|
||||
/// Randomize timeouts to prevent thundering herd
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
|
||||
/// Max new connections per minute from a single IP
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connection_rate_limit_per_minute: Option<u64>,
|
||||
|
||||
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
||||
|
||||
/// How to treat keep-alive connections
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
||||
|
||||
/// Multiplier for inactivity timeout for keep-alive connections
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive_inactivity_multiplier: Option<f64>,
|
||||
|
||||
/// Extended lifetime for keep-alive connections (ms)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>>,
|
||||
|
||||
/// Port where HttpProxy is listening (default: 8443)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Default for RustProxyOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
routes: Vec::new(),
|
||||
preserve_source_ip: None,
|
||||
proxy_ips: None,
|
||||
accept_proxy_protocol: None,
|
||||
send_proxy_protocol: None,
|
||||
defaults: None,
|
||||
connection_timeout: None,
|
||||
initial_data_timeout: None,
|
||||
socket_timeout: None,
|
||||
inactivity_check_interval: None,
|
||||
max_connection_lifetime: None,
|
||||
inactivity_timeout: None,
|
||||
graceful_shutdown_timeout: None,
|
||||
no_delay: None,
|
||||
keep_alive: None,
|
||||
keep_alive_initial_delay: None,
|
||||
max_pending_data_size: None,
|
||||
disable_inactivity_check: None,
|
||||
enable_keep_alive_probes: None,
|
||||
enable_detailed_logging: None,
|
||||
enable_tls_debug_logging: None,
|
||||
enable_randomized_timeouts: None,
|
||||
max_connections_per_ip: None,
|
||||
connection_rate_limit_per_minute: None,
|
||||
keep_alive_treatment: None,
|
||||
keep_alive_inactivity_multiplier: None,
|
||||
extended_keep_alive_lifetime: None,
|
||||
use_http_proxy: None,
|
||||
http_proxy_port: None,
|
||||
metrics: None,
|
||||
acme: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RustProxyOptions {
|
||||
/// Load configuration from a JSON file.
|
||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let options: Self = serde_json::from_str(&content)?;
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Get the effective connection timeout in milliseconds.
|
||||
pub fn effective_connection_timeout(&self) -> u64 {
|
||||
self.connection_timeout.unwrap_or(30_000)
|
||||
}
|
||||
|
||||
/// Get the effective initial data timeout in milliseconds.
|
||||
pub fn effective_initial_data_timeout(&self) -> u64 {
|
||||
self.initial_data_timeout.unwrap_or(60_000)
|
||||
}
|
||||
|
||||
/// Get the effective socket timeout in milliseconds.
|
||||
pub fn effective_socket_timeout(&self) -> u64 {
|
||||
self.socket_timeout.unwrap_or(3_600_000)
|
||||
}
|
||||
|
||||
/// Get the effective max connection lifetime in milliseconds.
|
||||
pub fn effective_max_connection_lifetime(&self) -> u64 {
|
||||
self.max_connection_lifetime.unwrap_or(86_400_000)
|
||||
}
|
||||
|
||||
/// Get all unique ports that routes listen on.
|
||||
pub fn all_listening_ports(&self) -> Vec<u16> {
|
||||
let mut ports: Vec<u16> = self.routes
|
||||
.iter()
|
||||
.flat_map(|r| r.listening_ports())
|
||||
.collect();
|
||||
ports.sort();
|
||||
ports.dedup();
|
||||
ports
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::helpers::*;
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_minimal() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![create_http_route("example.com", "localhost", 8080)],
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&options).unwrap();
|
||||
let parsed: RustProxyOptions = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.routes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_full() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend1", 8080),
|
||||
create_https_passthrough_route("b.com", "backend2", 443),
|
||||
],
|
||||
connection_timeout: Some(5000),
|
||||
socket_timeout: Some(60000),
|
||||
max_connections_per_ip: Some(100),
|
||||
acme: Some(AcmeOptions {
|
||||
enabled: Some(true),
|
||||
email: Some("admin@example.com".to_string()),
|
||||
environment: Some(AcmeEnvironment::Staging),
|
||||
account_email: None,
|
||||
port: None,
|
||||
use_production: None,
|
||||
renew_threshold_days: None,
|
||||
auto_renew: None,
|
||||
certificate_store: None,
|
||||
skip_configured_certs: None,
|
||||
renew_check_interval_hours: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&options).unwrap();
|
||||
let parsed: RustProxyOptions = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.routes.len(), 2);
|
||||
assert_eq!(parsed.connection_timeout, Some(5000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_timeouts() {
|
||||
let options = RustProxyOptions::default();
|
||||
assert_eq!(options.effective_connection_timeout(), 30_000);
|
||||
assert_eq!(options.effective_initial_data_timeout(), 60_000);
|
||||
assert_eq!(options.effective_socket_timeout(), 3_600_000);
|
||||
assert_eq!(options.effective_max_connection_lifetime(), 86_400_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_timeouts() {
|
||||
let options = RustProxyOptions {
|
||||
connection_timeout: Some(5000),
|
||||
initial_data_timeout: Some(10000),
|
||||
socket_timeout: Some(30000),
|
||||
max_connection_lifetime: Some(60000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(options.effective_connection_timeout(), 5000);
|
||||
assert_eq!(options.effective_initial_data_timeout(), 10000);
|
||||
assert_eq!(options.effective_socket_timeout(), 30000);
|
||||
assert_eq!(options.effective_max_connection_lifetime(), 60000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_listening_ports() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend", 8080), // port 80
|
||||
create_https_passthrough_route("b.com", "backend", 443), // port 443
|
||||
create_http_route("c.com", "backend", 9090), // port 80 (duplicate)
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let ports = options.all_listening_ports();
|
||||
assert_eq!(ports, vec![80, 443]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camel_case_field_names() {
|
||||
let options = RustProxyOptions {
|
||||
connection_timeout: Some(5000),
|
||||
max_connections_per_ip: Some(100),
|
||||
keep_alive_treatment: Some(KeepAliveTreatment::Extended),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&options).unwrap();
|
||||
assert!(json.contains("connectionTimeout"));
|
||||
assert!(json.contains("maxConnectionsPerIp"));
|
||||
assert!(json.contains("keepAliveTreatment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_example_json() {
|
||||
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();
|
||||
assert!(ports.contains(&80));
|
||||
assert!(ports.contains(&443));
|
||||
}
|
||||
}
|
||||
603
rust/crates/rustproxy-config/src/route_types.rs
Normal file
603
rust/crates/rustproxy-config/src/route_types.rs
Normal file
@@ -0,0 +1,603 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::tls_types::RouteTls;
|
||||
use crate::security_types::RouteSecurity;
|
||||
|
||||
// ─── Port Range ──────────────────────────────────────────────────────
|
||||
|
||||
/// Port range specification format.
|
||||
/// Matches TypeScript: `type TPortRange = number | number[] | Array<{ from: number; to: number }>`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PortRange {
|
||||
/// Single port number
|
||||
Single(u16),
|
||||
/// Array of port numbers
|
||||
List(Vec<u16>),
|
||||
/// Array of port ranges
|
||||
Ranges(Vec<PortRangeSpec>),
|
||||
}
|
||||
|
||||
impl PortRange {
|
||||
/// Expand the port range into a flat list of ports.
|
||||
pub fn to_ports(&self) -> Vec<u16> {
|
||||
match self {
|
||||
PortRange::Single(p) => vec![*p],
|
||||
PortRange::List(ports) => ports.clone(),
|
||||
PortRange::Ranges(ranges) => {
|
||||
ranges.iter().flat_map(|r| r.from..=r.to).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A from-to port range.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortRangeSpec {
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
}
|
||||
|
||||
// ─── Route Action Type ───────────────────────────────────────────────
|
||||
|
||||
/// Supported action types for route configurations.
|
||||
/// Matches TypeScript: `type TRouteActionType = 'forward' | 'socket-handler'`
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RouteActionType {
|
||||
Forward,
|
||||
SocketHandler,
|
||||
}
|
||||
|
||||
// ─── Forwarding Engine ───────────────────────────────────────────────
|
||||
|
||||
/// Forwarding engine specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ForwardingEngine {
|
||||
Node,
|
||||
Nftables,
|
||||
}
|
||||
|
||||
// ─── Route Match ─────────────────────────────────────────────────────
|
||||
|
||||
/// Domain specification: single string or array.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum DomainSpec {
|
||||
Single(String),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl DomainSpec {
|
||||
pub fn to_vec(&self) -> Vec<&str> {
|
||||
match self {
|
||||
DomainSpec::Single(s) => vec![s.as_str()],
|
||||
DomainSpec::List(v) => v.iter().map(|s| s.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header match value: either exact string or regex pattern.
|
||||
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum HeaderMatchValue {
|
||||
Exact(String),
|
||||
}
|
||||
|
||||
/// Route match criteria for incoming requests.
|
||||
/// Matches TypeScript: `IRouteMatch`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteMatch {
|
||||
/// Listen on these ports (required)
|
||||
pub ports: PortRange,
|
||||
|
||||
/// Optional domain patterns to match (default: all domains)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domains: Option<DomainSpec>,
|
||||
|
||||
/// Match specific paths
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
|
||||
/// Match specific client IPs
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_ip: Option<Vec<String>>,
|
||||
|
||||
/// Match specific TLS versions
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls_version: Option<Vec<String>>,
|
||||
|
||||
/// Match specific HTTP headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
// ─── Target Match ────────────────────────────────────────────────────
|
||||
|
||||
/// Target-specific match criteria for sub-routing within a route.
|
||||
/// Matches TypeScript: `ITargetMatch`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetMatch {
|
||||
/// Match specific ports from the route
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ports: Option<Vec<u16>>,
|
||||
/// Match specific paths (supports wildcards like /api/*)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Match specific HTTP headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
/// Match specific HTTP methods
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ─── WebSocket Config ────────────────────────────────────────────────
|
||||
|
||||
/// WebSocket configuration.
|
||||
/// Matches TypeScript: `IRouteWebSocket`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteWebSocket {
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ping_interval: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ping_timeout: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_payload_size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom_headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subprotocols: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rewrite_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_origins: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authenticate_request: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Load Balancing ──────────────────────────────────────────────────
|
||||
|
||||
/// Load balancing algorithm.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LoadBalancingAlgorithm {
|
||||
RoundRobin,
|
||||
LeastConnections,
|
||||
IpHash,
|
||||
}
|
||||
|
||||
/// Health check configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthCheck {
|
||||
pub path: String,
|
||||
pub interval: u64,
|
||||
pub timeout: u64,
|
||||
pub unhealthy_threshold: u32,
|
||||
pub healthy_threshold: u32,
|
||||
}
|
||||
|
||||
/// Load balancing configuration.
|
||||
/// Matches TypeScript: `IRouteLoadBalancing`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteLoadBalancing {
|
||||
pub algorithm: LoadBalancingAlgorithm,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub health_check: Option<HealthCheck>,
|
||||
}
|
||||
|
||||
// ─── CORS ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Allowed origin specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum AllowOrigin {
|
||||
Single(String),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
/// CORS configuration for a route.
|
||||
/// Matches TypeScript: `IRouteCors`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteCors {
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_origin: Option<AllowOrigin>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_methods: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_headers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_credentials: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expose_headers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_age: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preflight: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Headers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Headers configuration.
|
||||
/// Matches TypeScript: `IRouteHeaders`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteHeaders {
|
||||
/// Headers to add/modify for requests to backend
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request: Option<HashMap<String, String>>,
|
||||
/// Headers to add/modify for responses to client
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response: Option<HashMap<String, String>>,
|
||||
/// CORS configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cors: Option<RouteCors>,
|
||||
}
|
||||
|
||||
// ─── Static Files ────────────────────────────────────────────────────
|
||||
|
||||
/// Static file server configuration.
|
||||
/// Matches TypeScript: `IRouteStaticFiles`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteStaticFiles {
|
||||
pub root: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub index: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub directory: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub index_files: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub follow_symlinks: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_directory_listing: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Test Response ───────────────────────────────────────────────────
|
||||
|
||||
/// Test route response configuration.
|
||||
/// Matches TypeScript: `IRouteTestResponse`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteTestResponse {
|
||||
pub status: u16,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
// ─── URL Rewriting ───────────────────────────────────────────────────
|
||||
|
||||
/// URL rewriting configuration.
|
||||
/// Matches TypeScript: `IRouteUrlRewrite`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteUrlRewrite {
|
||||
/// RegExp pattern to match in URL
|
||||
pub pattern: String,
|
||||
/// Replacement pattern
|
||||
pub target: String,
|
||||
/// RegExp flags
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<String>,
|
||||
/// Only apply to path, not query string
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub only_rewrite_path: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Advanced Options ────────────────────────────────────────────────
|
||||
|
||||
/// Advanced options for route actions.
|
||||
/// Matches TypeScript: `IRouteAdvanced`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteAdvanced {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_alive: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub static_files: Option<RouteStaticFiles>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub test_response: Option<RouteTestResponse>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url_rewrite: Option<RouteUrlRewrite>,
|
||||
}
|
||||
|
||||
// ─── NFTables Options ────────────────────────────────────────────────
|
||||
|
||||
/// NFTables protocol type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NfTablesProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
All,
|
||||
}
|
||||
|
||||
/// NFTables-specific configuration options.
|
||||
/// Matches TypeScript: `INfTablesOptions`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NfTablesOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preserve_source_ip: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<NfTablesProtocol>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_rate: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_ip_sets: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_advanced_nat: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Backend Protocol ────────────────────────────────────────────────
|
||||
|
||||
/// Backend protocol.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BackendProtocol {
|
||||
Http1,
|
||||
Http2,
|
||||
}
|
||||
|
||||
/// Action options.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_protocol: Option<BackendProtocol>,
|
||||
/// Catch-all for additional options
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// ─── Route Target ────────────────────────────────────────────────────
|
||||
|
||||
/// Host specification: single string or array of strings.
|
||||
/// Note: Dynamic host functions are only available via programmatic API, not JSON.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum HostSpec {
|
||||
Single(String),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl HostSpec {
|
||||
pub fn to_vec(&self) -> Vec<&str> {
|
||||
match self {
|
||||
HostSpec::Single(s) => vec![s.as_str()],
|
||||
HostSpec::List(v) => v.iter().map(|s| s.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &str {
|
||||
match self {
|
||||
HostSpec::Single(s) => s.as_str(),
|
||||
HostSpec::List(v) => v.first().map(|s| s.as_str()).unwrap_or(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Port specification: number or "preserve".
|
||||
/// Note: Dynamic port functions are only available via programmatic API, not JSON.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PortSpec {
|
||||
/// Fixed port number
|
||||
Fixed(u16),
|
||||
/// Special string value like "preserve"
|
||||
Special(String),
|
||||
}
|
||||
|
||||
impl PortSpec {
|
||||
/// Resolve the port, using incoming_port when "preserve" is specified.
|
||||
pub fn resolve(&self, incoming_port: u16) -> u16 {
|
||||
match self {
|
||||
PortSpec::Fixed(p) => *p,
|
||||
PortSpec::Special(s) if s == "preserve" => incoming_port,
|
||||
PortSpec::Special(_) => incoming_port, // fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target configuration for forwarding with sub-matching and overrides.
|
||||
/// Matches TypeScript: `IRouteTarget`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteTarget {
|
||||
/// Optional sub-matching criteria within the route
|
||||
#[serde(rename = "match")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_match: Option<TargetMatch>,
|
||||
|
||||
/// Target host(s)
|
||||
pub host: HostSpec,
|
||||
|
||||
/// Target port
|
||||
pub port: PortSpec,
|
||||
|
||||
/// Override route-level TLS settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls: Option<RouteTls>,
|
||||
|
||||
/// Override route-level WebSocket settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub websocket: Option<RouteWebSocket>,
|
||||
|
||||
/// Override route-level load balancing
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub load_balancing: Option<RouteLoadBalancing>,
|
||||
|
||||
/// Override route-level proxy protocol setting
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub send_proxy_protocol: Option<bool>,
|
||||
|
||||
/// Override route-level headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<RouteHeaders>,
|
||||
|
||||
/// Override route-level advanced settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub advanced: Option<RouteAdvanced>,
|
||||
|
||||
/// Priority for matching (higher values checked first, default: 0)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i32>,
|
||||
}
|
||||
|
||||
// ─── Route Action ────────────────────────────────────────────────────
|
||||
|
||||
/// Action configuration for route handling.
|
||||
/// Matches TypeScript: `IRouteAction`
|
||||
///
|
||||
/// Note: `socketHandler` is not serializable in JSON. Use the programmatic API
|
||||
/// for socket handler routes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteAction {
|
||||
/// Basic routing type
|
||||
#[serde(rename = "type")]
|
||||
pub action_type: RouteActionType,
|
||||
|
||||
/// Targets for forwarding (array supports multiple targets with sub-matching)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub targets: Option<Vec<RouteTarget>>,
|
||||
|
||||
/// TLS handling (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls: Option<RouteTls>,
|
||||
|
||||
/// WebSocket support (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub websocket: Option<RouteWebSocket>,
|
||||
|
||||
/// Load balancing options (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub load_balancing: Option<RouteLoadBalancing>,
|
||||
|
||||
/// Advanced options (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub advanced: Option<RouteAdvanced>,
|
||||
|
||||
/// Additional options
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<ActionOptions>,
|
||||
|
||||
/// Forwarding engine specification
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forwarding_engine: Option<ForwardingEngine>,
|
||||
|
||||
/// NFTables-specific options
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nftables: Option<NfTablesOptions>,
|
||||
|
||||
/// PROXY protocol support (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub send_proxy_protocol: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Route Config ────────────────────────────────────────────────────
|
||||
|
||||
/// The core unified configuration interface.
|
||||
/// Matches TypeScript: `IRouteConfig`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteConfig {
|
||||
/// Unique identifier
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
|
||||
/// What to match
|
||||
#[serde(rename = "match")]
|
||||
pub route_match: RouteMatch,
|
||||
|
||||
/// What to do with matched traffic
|
||||
pub action: RouteAction,
|
||||
|
||||
/// Custom headers
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<RouteHeaders>,
|
||||
|
||||
/// Security features
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub security: Option<RouteSecurity>,
|
||||
|
||||
/// Human-readable name for this route
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Description of the route's purpose
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Controls matching order (higher = matched first)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i32>,
|
||||
|
||||
/// Arbitrary tags for categorization
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
|
||||
/// Whether the route is active (default: true)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl RouteConfig {
|
||||
/// Check if this route is enabled (defaults to true).
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Get the effective priority (defaults to 0).
|
||||
pub fn effective_priority(&self) -> i32 {
|
||||
self.priority.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get all ports this route listens on.
|
||||
pub fn listening_ports(&self) -> Vec<u16> {
|
||||
self.route_match.ports.to_ports()
|
||||
}
|
||||
|
||||
/// Get the TLS mode for this route (from action-level or first target).
|
||||
pub fn tls_mode(&self) -> Option<&crate::tls_types::TlsMode> {
|
||||
// Check action-level TLS first
|
||||
if let Some(tls) = &self.action.tls {
|
||||
return Some(&tls.mode);
|
||||
}
|
||||
// Check first target's TLS
|
||||
if let Some(targets) = &self.action.targets {
|
||||
if let Some(first) = targets.first() {
|
||||
if let Some(tls) = &first.tls {
|
||||
return Some(&tls.mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
132
rust/crates/rustproxy-config/src/security_types.rs
Normal file
132
rust/crates/rustproxy-config/src/security_types.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Rate limiting configuration.
|
||||
/// Matches TypeScript: `IRouteRateLimit`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteRateLimit {
|
||||
pub enabled: bool,
|
||||
pub max_requests: u64,
|
||||
/// Time window in seconds
|
||||
pub window: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key_by: Option<RateLimitKeyBy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub header_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Rate limit key selection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RateLimitKeyBy {
|
||||
Ip,
|
||||
Path,
|
||||
Header,
|
||||
}
|
||||
|
||||
/// Authentication type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthenticationType {
|
||||
Basic,
|
||||
Digest,
|
||||
Oauth,
|
||||
Jwt,
|
||||
}
|
||||
|
||||
/// Authentication credentials.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthCredentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Authentication options.
|
||||
/// Matches TypeScript: `IRouteAuthentication`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteAuthentication {
|
||||
#[serde(rename = "type")]
|
||||
pub auth_type: AuthenticationType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credentials: Option<Vec<AuthCredentials>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub realm: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_secret: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_issuer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_client_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_client_secret: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_redirect_uri: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Basic auth configuration.
|
||||
/// Matches TypeScript: `IRouteSecurity.basicAuth`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BasicAuthConfig {
|
||||
pub enabled: bool,
|
||||
pub users: Vec<AuthCredentials>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub realm: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exclude_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// JWT auth configuration.
|
||||
/// Matches TypeScript: `IRouteSecurity.jwtAuth`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JwtAuthConfig {
|
||||
pub enabled: bool,
|
||||
pub secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub algorithm: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub issuer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audience: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_in: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exclude_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Security options for routes.
|
||||
/// Matches TypeScript: `IRouteSecurity`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSecurity {
|
||||
/// IP addresses that are allowed to connect
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_allow_list: Option<Vec<String>>,
|
||||
/// IP addresses that are blocked from connecting
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_block_list: Option<Vec<String>>,
|
||||
/// Maximum concurrent connections
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_connections: Option<u64>,
|
||||
/// Authentication configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authentication: Option<RouteAuthentication>,
|
||||
/// Rate limiting
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rate_limit: Option<RouteRateLimit>,
|
||||
/// Basic auth
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub basic_auth: Option<BasicAuthConfig>,
|
||||
/// JWT auth
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_auth: Option<JwtAuthConfig>,
|
||||
}
|
||||
93
rust/crates/rustproxy-config/src/tls_types.rs
Normal file
93
rust/crates/rustproxy-config/src/tls_types.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// TLS handling modes for route configurations.
|
||||
/// Matches TypeScript: `type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt'`
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TlsMode {
|
||||
Passthrough,
|
||||
Terminate,
|
||||
TerminateAndReencrypt,
|
||||
}
|
||||
|
||||
/// Static certificate configuration (PEM-encoded).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CertificateConfig {
|
||||
/// PEM-encoded private key
|
||||
pub key: String,
|
||||
/// PEM-encoded certificate
|
||||
pub cert: String,
|
||||
/// PEM-encoded CA chain
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ca: Option<String>,
|
||||
/// Path to key file (overrides key)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key_file: Option<String>,
|
||||
/// Path to cert file (overrides cert)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cert_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Certificate specification: either automatic (ACME) or static.
|
||||
/// Matches TypeScript: `certificate?: 'auto' | { key, cert, ca?, keyFile?, certFile? }`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CertificateSpec {
|
||||
/// Use ACME (Let's Encrypt) for automatic provisioning
|
||||
Auto(String), // "auto"
|
||||
/// Static certificate configuration
|
||||
Static(CertificateConfig),
|
||||
}
|
||||
|
||||
impl CertificateSpec {
|
||||
/// Check if this is an auto (ACME) certificate
|
||||
pub fn is_auto(&self) -> bool {
|
||||
matches!(self, CertificateSpec::Auto(s) if s == "auto")
|
||||
}
|
||||
}
|
||||
|
||||
/// ACME configuration for automatic certificate provisioning.
|
||||
/// Matches TypeScript: `IRouteAcme`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteAcme {
|
||||
/// Contact email for ACME account
|
||||
pub email: String,
|
||||
/// Use production ACME servers (default: false)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_production: Option<bool>,
|
||||
/// Port for HTTP-01 challenges (default: 80)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub challenge_port: Option<u16>,
|
||||
/// Days before expiry to renew (default: 30)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub renew_before_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// TLS configuration for route actions.
|
||||
/// Matches TypeScript: `IRouteTls`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteTls {
|
||||
/// TLS mode (passthrough, terminate, terminate-and-reencrypt)
|
||||
pub mode: TlsMode,
|
||||
/// Certificate configuration (auto or static)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<CertificateSpec>,
|
||||
/// ACME options when certificate is 'auto'
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub acme: Option<RouteAcme>,
|
||||
/// Allowed TLS versions
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub versions: Option<Vec<String>>,
|
||||
/// OpenSSL cipher string
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ciphers: Option<String>,
|
||||
/// Use server's cipher preferences
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub honor_cipher_order: Option<bool>,
|
||||
/// TLS session timeout in seconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_timeout: Option<u64>,
|
||||
}
|
||||
158
rust/crates/rustproxy-config/src/validation.rs
Normal file
158
rust/crates/rustproxy-config/src/validation.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::route_types::{RouteConfig, RouteActionType};
|
||||
|
||||
/// Validation errors for route configurations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidationError {
|
||||
#[error("Route '{name}' has no targets but action type is 'forward'")]
|
||||
MissingTargets { name: String },
|
||||
|
||||
#[error("Route '{name}' has empty targets list")]
|
||||
EmptyTargets { name: String },
|
||||
|
||||
#[error("Route '{name}' has no ports specified")]
|
||||
NoPorts { name: String },
|
||||
|
||||
#[error("Route '{name}' port {port} is invalid (must be 1-65535)")]
|
||||
InvalidPort { name: String, port: u16 },
|
||||
|
||||
#[error("Route '{name}': socket-handler action type is not supported in JSON config")]
|
||||
SocketHandlerInJson { name: String },
|
||||
|
||||
#[error("Route '{name}': duplicate route ID '{id}'")]
|
||||
DuplicateId { name: String, id: String },
|
||||
|
||||
#[error("Route '{name}': {message}")]
|
||||
Custom { name: String, message: String },
|
||||
}
|
||||
|
||||
/// Validate a single route configuration.
|
||||
pub fn validate_route(route: &RouteConfig) -> Result<(), Vec<ValidationError>> {
|
||||
let mut errors = Vec::new();
|
||||
let name = route.name.clone().unwrap_or_else(|| {
|
||||
route.id.clone().unwrap_or_else(|| "unnamed".to_string())
|
||||
});
|
||||
|
||||
// Check ports
|
||||
let ports = route.listening_ports();
|
||||
if ports.is_empty() {
|
||||
errors.push(ValidationError::NoPorts { name: name.clone() });
|
||||
}
|
||||
for &port in &ports {
|
||||
if port == 0 {
|
||||
errors.push(ValidationError::InvalidPort {
|
||||
name: name.clone(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check forward action has targets
|
||||
if route.action.action_type == RouteActionType::Forward {
|
||||
match &route.action.targets {
|
||||
None => {
|
||||
errors.push(ValidationError::MissingTargets { name: name.clone() });
|
||||
}
|
||||
Some(targets) if targets.is_empty() => {
|
||||
errors.push(ValidationError::EmptyTargets { name: name.clone() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an entire list of routes.
|
||||
pub fn validate_routes(routes: &[RouteConfig]) -> Result<(), Vec<ValidationError>> {
|
||||
let mut all_errors = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
|
||||
for route in routes {
|
||||
// Check for duplicate IDs
|
||||
if let Some(id) = &route.id {
|
||||
if !seen_ids.insert(id.clone()) {
|
||||
let name = route.name.clone().unwrap_or_else(|| id.clone());
|
||||
all_errors.push(ValidationError::DuplicateId {
|
||||
name,
|
||||
id: id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate individual route
|
||||
if let Err(errors) = validate_route(route) {
|
||||
all_errors.extend(errors);
|
||||
}
|
||||
}
|
||||
|
||||
if all_errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(all_errors)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::route_types::*;
|
||||
|
||||
fn make_valid_route() -> RouteConfig {
|
||||
crate::helpers::create_http_route("example.com", "localhost", 8080)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_route_passes() {
|
||||
let route = make_valid_route();
|
||||
assert!(validate_route(&route).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_targets() {
|
||||
let mut route = make_valid_route();
|
||||
route.action.targets = None;
|
||||
let errors = validate_route(&route).unwrap_err();
|
||||
assert!(errors.iter().any(|e| matches!(e, ValidationError::MissingTargets { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_targets() {
|
||||
let mut route = make_valid_route();
|
||||
route.action.targets = Some(vec![]);
|
||||
let errors = validate_route(&route).unwrap_err();
|
||||
assert!(errors.iter().any(|e| matches!(e, ValidationError::EmptyTargets { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_port_zero() {
|
||||
let mut route = make_valid_route();
|
||||
route.route_match.ports = PortRange::Single(0);
|
||||
let errors = validate_route(&route).unwrap_err();
|
||||
assert!(errors.iter().any(|e| matches!(e, ValidationError::InvalidPort { port: 0, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_ids() {
|
||||
let mut r1 = make_valid_route();
|
||||
r1.id = Some("route-1".to_string());
|
||||
let mut r2 = make_valid_route();
|
||||
r2.id = Some("route-1".to_string());
|
||||
let errors = validate_routes(&[r1, r2]).unwrap_err();
|
||||
assert!(errors.iter().any(|e| matches!(e, ValidationError::DuplicateId { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_errors_collected() {
|
||||
let mut r1 = make_valid_route();
|
||||
r1.action.targets = None; // MissingTargets
|
||||
r1.route_match.ports = PortRange::Single(0); // InvalidPort
|
||||
let errors = validate_route(&r1).unwrap_err();
|
||||
assert!(errors.len() >= 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user