feat(smart-proxy): add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers

This commit is contained in:
2026-04-26 15:11:10 +00:00
parent 8fa3a51b03
commit af4908b63f
53 changed files with 2350 additions and 1196 deletions
+4 -4
View File
@@ -3,15 +3,15 @@
//! 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 route_types;
pub mod security_types;
pub mod tls_types;
pub mod validation;
// Re-export all primary types
pub use route_types::*;
pub use proxy_options::*;
pub use tls_types::*;
pub use route_types::*;
pub use security_types::*;
pub use tls_types::*;
pub use validation::*;
@@ -97,6 +97,16 @@ pub struct MetricsConfig {
pub retention_seconds: Option<u64>,
}
/// Global ingress security policy.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SecurityPolicy {
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_ips: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_cidrs: Option<Vec<String>>,
}
/// RustProxy configuration options.
/// Matches TypeScript: `ISmartProxyOptions`
///
@@ -235,6 +245,10 @@ pub struct RustProxyOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics: Option<MetricsConfig>,
/// Global ingress security policy, enforced before route selection.
#[serde(skip_serializing_if = "Option::is_none")]
pub security_policy: Option<SecurityPolicy>,
// ─── ACME ────────────────────────────────────────────────────────
/// Global ACME configuration
#[serde(skip_serializing_if = "Option::is_none")]
@@ -275,6 +289,7 @@ impl Default for RustProxyOptions {
use_http_proxy: None,
http_proxy_port: None,
metrics: None,
security_policy: None,
acme: None,
}
}
@@ -111,10 +111,7 @@ pub enum IpAllowEntry {
/// Plain IP/CIDR — allowed for all domains on this route
Plain(String),
/// Domain-scoped — allowed only when the requested domain matches
DomainScoped {
ip: String,
domains: Vec<String>,
},
DomainScoped { ip: String, domains: Vec<String> },
}
/// Security options for routes.
+17 -8
View File
@@ -1,6 +1,6 @@
use thiserror::Error;
use crate::route_types::{RouteConfig, RouteActionType};
use crate::route_types::{RouteActionType, RouteConfig};
/// Validation errors for route configurations.
#[derive(Debug, Error)]
@@ -30,9 +30,10 @@ pub enum ValidationError {
/// 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())
});
let name = route
.name
.clone()
.unwrap_or_else(|| route.id.clone().unwrap_or_else(|| "unnamed".to_string()));
// Check ports
let ports = route.listening_ports();
@@ -160,7 +161,9 @@ mod tests {
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 { .. })));
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::MissingTargets { .. })));
}
#[test]
@@ -168,7 +171,9 @@ mod tests {
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 { .. })));
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::EmptyTargets { .. })));
}
#[test]
@@ -176,7 +181,9 @@ mod tests {
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, .. })));
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidPort { port: 0, .. })));
}
#[test]
@@ -186,7 +193,9 @@ mod tests {
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 { .. })));
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::DuplicateId { .. })));
}
#[test]