440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
|
|
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));
|
||
|
|
}
|
||
|
|
}
|