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:
2026-02-09 10:55:46 +00:00
parent a31fee41df
commit 1df3b7af4a
151 changed files with 16927 additions and 19432 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "rustproxy-config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Configuration types for RustProxy, compatible with SmartProxy JSON schema"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ipnet = { workspace = true }

View 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"]);
}
}

View 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::*;

View 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));
}
}

View 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
}
}

View 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>,
}

View 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>,
}

View 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);
}
}