diff --git a/changelog.md b/changelog.md index 9847109..8d1d618 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-13 - 27.7.0 - feat(smart-proxy) +add typed Rust config serialization and regex header contract coverage + +- serialize SmartProxy routes and top-level options into explicit Rust-safe types, including header regex literals, UDP field normalization, ACME, defaults, and proxy settings +- support JS-style regex header literals with flags in Rust header matching and add cross-contract tests for route preprocessing and config deserialization +- improve TypeScript safety for Rust bridge and metrics integration by replacing loose any-based payloads with dedicated Rust type definitions + ## 2026-04-13 - 27.6.0 - feat(metrics) track per-IP domain request metrics across HTTP and TCP passthrough traffic diff --git a/rust/crates/rustproxy-config/src/proxy_options.rs b/rust/crates/rustproxy-config/src/proxy_options.rs index c4fa57f..6e9db05 100644 --- a/rust/crates/rustproxy-config/src/proxy_options.rs +++ b/rust/crates/rustproxy-config/src/proxy_options.rs @@ -129,7 +129,6 @@ pub struct RustProxyOptions { pub defaults: Option, // ─── Timeout Settings ──────────────────────────────────────────── - /// Timeout for establishing connection to backend (ms), default: 30000 #[serde(skip_serializing_if = "Option::is_none")] pub connection_timeout: Option, @@ -159,7 +158,6 @@ pub struct RustProxyOptions { pub graceful_shutdown_timeout: Option, // ─── Socket Optimization ───────────────────────────────────────── - /// Disable Nagle's algorithm (default: true) #[serde(skip_serializing_if = "Option::is_none")] pub no_delay: Option, @@ -177,7 +175,6 @@ pub struct RustProxyOptions { pub max_pending_data_size: Option, // ─── Enhanced Features ─────────────────────────────────────────── - /// Disable inactivity checking entirely #[serde(skip_serializing_if = "Option::is_none")] pub disable_inactivity_check: Option, @@ -199,7 +196,6 @@ pub struct RustProxyOptions { pub enable_randomized_timeouts: Option, // ─── Rate Limiting ─────────────────────────────────────────────── - /// Maximum simultaneous connections from a single IP #[serde(skip_serializing_if = "Option::is_none")] pub max_connections_per_ip: Option, @@ -213,7 +209,6 @@ pub struct RustProxyOptions { pub max_connections: Option, // ─── Keep-Alive Settings ───────────────────────────────────────── - /// How to treat keep-alive connections #[serde(skip_serializing_if = "Option::is_none")] pub keep_alive_treatment: Option, @@ -227,7 +222,6 @@ pub struct RustProxyOptions { pub extended_keep_alive_lifetime: Option, // ─── HttpProxy Integration ─────────────────────────────────────── - /// Array of ports to forward to HttpProxy #[serde(skip_serializing_if = "Option::is_none")] pub use_http_proxy: Option>, @@ -237,13 +231,11 @@ pub struct RustProxyOptions { pub http_proxy_port: Option, // ─── Metrics ───────────────────────────────────────────────────── - /// Metrics configuration #[serde(skip_serializing_if = "Option::is_none")] pub metrics: Option, // ─── ACME ──────────────────────────────────────────────────────── - /// Global ACME configuration #[serde(skip_serializing_if = "Option::is_none")] pub acme: Option, @@ -318,7 +310,8 @@ impl RustProxyOptions { /// Get all unique ports that routes listen on. pub fn all_listening_ports(&self) -> Vec { - let mut ports: Vec = self.routes + let mut ports: Vec = self + .routes .iter() .flat_map(|r| r.listening_ports()) .collect(); @@ -340,7 +333,12 @@ mod tests { route_match: RouteMatch { ports: PortRange::Single(listen_port), domains: Some(DomainSpec::Single(domain.to_string())), - path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None, + path: None, + client_ip: None, + transport: None, + tls_version: None, + headers: None, + protocol: None, }, action: RouteAction { action_type: RouteActionType::Forward, @@ -348,14 +346,30 @@ mod tests { target_match: None, host: HostSpec::Single(host.to_string()), port: PortSpec::Fixed(port), - tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None, - headers: None, advanced: None, backend_transport: None, priority: None, + tls: None, + websocket: None, + load_balancing: None, + send_proxy_protocol: None, + headers: None, + advanced: None, + backend_transport: None, + priority: None, }]), - tls: None, websocket: None, load_balancing: None, advanced: None, - options: None, send_proxy_protocol: None, udp: None, + tls: None, + websocket: None, + load_balancing: None, + advanced: None, + options: None, + send_proxy_protocol: None, + udp: None, }, - headers: None, security: None, name: None, description: None, - priority: None, tags: None, enabled: None, + headers: None, + security: None, + name: None, + description: None, + priority: None, + tags: None, + enabled: None, } } @@ -363,8 +377,12 @@ mod tests { let mut route = make_route(domain, host, port, 443); route.action.tls = Some(RouteTls { mode: TlsMode::Passthrough, - certificate: None, acme: None, versions: None, ciphers: None, - honor_cipher_order: None, session_timeout: None, + certificate: None, + acme: None, + versions: None, + ciphers: None, + honor_cipher_order: None, + session_timeout: None, }); route } @@ -410,6 +428,209 @@ mod tests { assert_eq!(parsed.connection_timeout, Some(5000)); } + #[test] + fn test_deserialize_ts_contract_route_shapes() { + let value = serde_json::json!({ + "routes": [{ + "name": "contract-route", + "match": { + "ports": [443, { "from": 8443, "to": 8444 }], + "domains": ["api.example.com", "*.example.com"], + "transport": "udp", + "protocol": "http3", + "headers": { + "content-type": "/^application\\/json$/i" + } + }, + "action": { + "type": "forward", + "targets": [{ + "match": { + "ports": [443], + "path": "/api/*", + "method": ["GET"], + "headers": { + "x-env": "/^(prod|stage)$/" + } + }, + "host": ["backend-a", "backend-b"], + "port": "preserve", + "sendProxyProtocol": true, + "backendTransport": "tcp" + }], + "tls": { + "mode": "terminate", + "certificate": "auto" + }, + "sendProxyProtocol": true, + "udp": { + "maxSessionsPerIp": 321, + "quic": { + "enableHttp3": true + } + } + }, + "security": { + "ipAllowList": [{ + "ip": "10.0.0.0/8", + "domains": ["api.example.com"] + }] + } + }], + "preserveSourceIp": true, + "proxyIps": ["10.0.0.1"], + "acceptProxyProtocol": true, + "sendProxyProtocol": true, + "noDelay": true, + "keepAlive": true, + "keepAliveInitialDelay": 1500, + "maxPendingDataSize": 4096, + "disableInactivityCheck": true, + "enableKeepAliveProbes": true, + "enableDetailedLogging": true, + "enableTlsDebugLogging": true, + "enableRandomizedTimeouts": true, + "connectionTimeout": 5000, + "initialDataTimeout": 7000, + "socketTimeout": 9000, + "inactivityCheckInterval": 1100, + "maxConnectionLifetime": 13000, + "inactivityTimeout": 15000, + "gracefulShutdownTimeout": 17000, + "maxConnectionsPerIp": 20, + "connectionRateLimitPerMinute": 30, + "keepAliveTreatment": "extended", + "keepAliveInactivityMultiplier": 2.0, + "extendedKeepAliveLifetime": 19000, + "metrics": { + "enabled": true, + "sampleIntervalMs": 250, + "retentionSeconds": 60 + }, + "acme": { + "enabled": true, + "email": "ops@example.com", + "environment": "staging", + "useProduction": false, + "skipConfiguredCerts": true, + "renewThresholdDays": 14, + "renewCheckIntervalHours": 12, + "autoRenew": true, + "port": 80 + } + }); + + let options: RustProxyOptions = serde_json::from_value(value).unwrap(); + + assert_eq!(options.routes.len(), 1); + assert_eq!(options.preserve_source_ip, Some(true)); + assert_eq!(options.proxy_ips, Some(vec!["10.0.0.1".to_string()])); + assert_eq!(options.accept_proxy_protocol, Some(true)); + assert_eq!(options.send_proxy_protocol, Some(true)); + assert_eq!(options.no_delay, Some(true)); + assert_eq!(options.keep_alive, Some(true)); + assert_eq!(options.keep_alive_initial_delay, Some(1500)); + assert_eq!(options.max_pending_data_size, Some(4096)); + assert_eq!(options.disable_inactivity_check, Some(true)); + assert_eq!(options.enable_keep_alive_probes, Some(true)); + assert_eq!(options.enable_detailed_logging, Some(true)); + assert_eq!(options.enable_tls_debug_logging, Some(true)); + assert_eq!(options.enable_randomized_timeouts, Some(true)); + assert_eq!(options.connection_timeout, Some(5000)); + assert_eq!(options.initial_data_timeout, Some(7000)); + assert_eq!(options.socket_timeout, Some(9000)); + assert_eq!(options.inactivity_check_interval, Some(1100)); + assert_eq!(options.max_connection_lifetime, Some(13000)); + assert_eq!(options.inactivity_timeout, Some(15000)); + assert_eq!(options.graceful_shutdown_timeout, Some(17000)); + assert_eq!(options.max_connections_per_ip, Some(20)); + assert_eq!(options.connection_rate_limit_per_minute, Some(30)); + assert_eq!( + options.keep_alive_treatment, + Some(KeepAliveTreatment::Extended) + ); + assert_eq!(options.keep_alive_inactivity_multiplier, Some(2.0)); + assert_eq!(options.extended_keep_alive_lifetime, Some(19000)); + + let route = &options.routes[0]; + assert_eq!(route.route_match.transport, Some(TransportProtocol::Udp)); + assert_eq!(route.route_match.protocol.as_deref(), Some("http3")); + assert_eq!( + route + .route_match + .headers + .as_ref() + .unwrap() + .get("content-type") + .unwrap(), + "/^application\\/json$/i" + ); + + let target = &route.action.targets.as_ref().unwrap()[0]; + assert!(matches!(target.host, HostSpec::List(_))); + assert!(matches!(target.port, PortSpec::Special(ref p) if p == "preserve")); + assert_eq!(target.backend_transport, Some(TransportProtocol::Tcp)); + assert_eq!(target.send_proxy_protocol, Some(true)); + assert_eq!( + target + .target_match + .as_ref() + .unwrap() + .headers + .as_ref() + .unwrap() + .get("x-env") + .unwrap(), + "/^(prod|stage)$/" + ); + assert_eq!(route.action.send_proxy_protocol, Some(true)); + assert_eq!( + route.action.udp.as_ref().unwrap().max_sessions_per_ip, + Some(321) + ); + assert_eq!( + route + .action + .udp + .as_ref() + .unwrap() + .quic + .as_ref() + .unwrap() + .enable_http3, + Some(true) + ); + + let allow_list = route + .security + .as_ref() + .unwrap() + .ip_allow_list + .as_ref() + .unwrap(); + assert!(matches!( + &allow_list[0], + crate::security_types::IpAllowEntry::DomainScoped { ip, domains } + if ip == "10.0.0.0/8" && domains == &vec!["api.example.com".to_string()] + )); + + let metrics = options.metrics.as_ref().unwrap(); + assert_eq!(metrics.enabled, Some(true)); + assert_eq!(metrics.sample_interval_ms, Some(250)); + assert_eq!(metrics.retention_seconds, Some(60)); + + let acme = options.acme.as_ref().unwrap(); + assert_eq!(acme.enabled, Some(true)); + assert_eq!(acme.email.as_deref(), Some("ops@example.com")); + assert_eq!(acme.environment, Some(AcmeEnvironment::Staging)); + assert_eq!(acme.use_production, Some(false)); + assert_eq!(acme.skip_configured_certs, Some(true)); + assert_eq!(acme.renew_threshold_days, Some(14)); + assert_eq!(acme.renew_check_interval_hours, Some(12)); + assert_eq!(acme.auto_renew, Some(true)); + assert_eq!(acme.port, Some(80)); + } + #[test] fn test_default_timeouts() { let options = RustProxyOptions::default(); @@ -438,9 +659,9 @@ mod tests { fn test_all_listening_ports() { let options = RustProxyOptions { routes: vec![ - make_route("a.com", "backend", 8080, 80), // port 80 + make_route("a.com", "backend", 8080, 80), // port 80 make_passthrough_route("b.com", "backend", 443), // port 443 - make_route("c.com", "backend", 9090, 80), // port 80 (duplicate) + make_route("c.com", "backend", 9090, 80), // port 80 (duplicate) ], ..Default::default() }; @@ -464,9 +685,11 @@ mod tests { #[test] fn test_deserialize_example_json() { - let content = std::fs::read_to_string( - concat!(env!("CARGO_MANIFEST_DIR"), "/../../config/example.json") - ).unwrap(); + let content = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../config/example.json" + )) + .unwrap(); let options: RustProxyOptions = serde_json::from_str(&content).unwrap(); assert_eq!(options.routes.len(), 4); let ports = options.all_listening_ports(); diff --git a/rust/crates/rustproxy-config/src/route_types.rs b/rust/crates/rustproxy-config/src/route_types.rs index 83d9611..e827229 100644 --- a/rust/crates/rustproxy-config/src/route_types.rs +++ b/rust/crates/rustproxy-config/src/route_types.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::tls_types::RouteTls; use crate::security_types::RouteSecurity; +use crate::tls_types::RouteTls; // ─── Port Range ────────────────────────────────────────────────────── @@ -32,12 +32,13 @@ impl PortRange { pub fn to_ports(&self) -> Vec { match self { PortRange::Single(p) => vec![*p], - PortRange::List(items) => { - items.iter().flat_map(|item| match item { + PortRange::List(items) => items + .iter() + .flat_map(|item| match item { PortRangeItem::Port(p) => vec![*p], PortRangeItem::Range(r) => (r.from..=r.to).collect(), - }).collect() - } + }) + .collect(), } } } @@ -105,7 +106,8 @@ impl From> for DomainSpec { } /// Header match value: either exact string or regex pattern. -/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`. +/// In JSON, all values come as strings. Regex patterns use JS-style literal syntax, +/// e.g. `/^application\/json$/` or `/^application\/json$/i`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum HeaderMatchValue { diff --git a/rust/crates/rustproxy-routing/src/matchers/header.rs b/rust/crates/rustproxy-routing/src/matchers/header.rs index a2cf656..89e8dcb 100644 --- a/rust/crates/rustproxy-routing/src/matchers/header.rs +++ b/rust/crates/rustproxy-routing/src/matchers/header.rs @@ -1,5 +1,42 @@ -use std::collections::HashMap; use regex::Regex; +use std::collections::HashMap; + +fn compile_regex_pattern(pattern: &str) -> Option { + if !pattern.starts_with('/') { + return None; + } + + let last_slash = pattern.rfind('/')?; + if last_slash == 0 { + return None; + } + + let regex_body = &pattern[1..last_slash]; + let flags = &pattern[last_slash + 1..]; + + let mut inline_flags = String::new(); + for flag in flags.chars() { + match flag { + 'i' | 'm' | 's' | 'u' => { + if !inline_flags.contains(flag) { + inline_flags.push(flag); + } + } + 'g' => { + // Global has no effect for single header matching. + } + _ => return None, + } + } + + let compiled = if inline_flags.is_empty() { + regex_body.to_string() + } else { + format!("(?{}){}", inline_flags, regex_body) + }; + + Regex::new(&compiled).ok() +} /// Match HTTP headers against a set of patterns. /// @@ -24,16 +61,15 @@ pub fn headers_match( None => return false, // Required header not present }; - // Check if pattern is a regex (surrounded by /) - if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 { - let regex_str = &pattern[1..pattern.len() - 1]; - match Regex::new(regex_str) { - Ok(re) => { + // Check if pattern is a regex literal (/pattern/ or /pattern/flags) + if pattern.starts_with('/') && pattern.len() > 2 { + match compile_regex_pattern(pattern) { + Some(re) => { if !re.is_match(header_value) { return false; } } - Err(_) => { + None => { // Invalid regex, fall back to exact match if header_value != pattern { return false; @@ -85,6 +121,24 @@ mod tests { assert!(headers_match(&patterns, &headers)); } + #[test] + fn test_regex_header_match_with_flags() { + let patterns: HashMap = { + let mut m = HashMap::new(); + m.insert( + "Content-Type".to_string(), + "/^application\\/json$/i".to_string(), + ); + m + }; + let headers: HashMap = { + let mut m = HashMap::new(); + m.insert("content-type".to_string(), "Application/JSON".to_string()); + m + }; + assert!(headers_match(&patterns, &headers)); + } + #[test] fn test_missing_header() { let patterns: HashMap = { diff --git a/test/test.route-utils.ts b/test/test.route-utils.ts index 6eefebf..4c4665b 100644 --- a/test/test.route-utils.ts +++ b/test/test.route-utils.ts @@ -537,6 +537,31 @@ tap.test('Route Matching - routeMatchesHeaders', async () => { 'X-Custom-Header': 'value' })).toBeFalse(); + const regexHeaderRoute: IRouteConfig = { + match: { + domains: 'example.com', + ports: 80, + headers: { + 'Content-Type': /^application\/(json|problem\+json)$/i, + } + }, + action: { + type: 'forward', + targets: [{ + host: 'localhost', + port: 3000 + }] + } + }; + + expect(routeMatchesHeaders(regexHeaderRoute, { + 'Content-Type': 'Application/Problem+Json', + })).toBeTrue(); + + expect(routeMatchesHeaders(regexHeaderRoute, { + 'Content-Type': 'text/html', + })).toBeFalse(); + // Route without header matching should match any headers const noHeaderRoute: IRouteConfig = { match: { ports: 80, domains: 'example.com' }, diff --git a/test/test.rust-contract.ts b/test/test.rust-contract.ts new file mode 100644 index 0000000..ab6702f --- /dev/null +++ b/test/test.rust-contract.ts @@ -0,0 +1,192 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; + +import type { ISmartProxyOptions } from '../ts/proxies/smart-proxy/models/interfaces.js'; +import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; +import { RoutePreprocessor } from '../ts/proxies/smart-proxy/route-preprocessor.js'; +import { buildRustProxyOptions } from '../ts/proxies/smart-proxy/utils/rust-config.js'; + +tap.test('Rust contract - preprocessor serializes regex headers for Rust', async () => { + const route: IRouteConfig = { + name: 'contract-route', + match: { + ports: [443, { from: 8443, to: 8444 }], + domains: ['api.example.com', '*.example.com'], + transport: 'udp', + protocol: 'http3', + headers: { + 'Content-Type': /^application\/json$/i, + }, + }, + action: { + type: 'forward', + targets: [{ + match: { + ports: [443], + path: '/api/*', + method: ['GET'], + headers: { + 'X-Env': /^(prod|stage)$/, + }, + }, + host: ['backend-a', 'backend-b'], + port: 'preserve', + sendProxyProtocol: true, + backendTransport: 'tcp', + }], + tls: { + mode: 'terminate', + certificate: 'auto', + }, + sendProxyProtocol: true, + udp: { + maxSessionsPerIP: 321, + quic: { + enableHttp3: true, + }, + }, + }, + security: { + ipAllowList: [{ + ip: '10.0.0.0/8', + domains: ['api.example.com'], + }], + }, + }; + + const preprocessor = new RoutePreprocessor(); + const [rustRoute] = preprocessor.preprocessForRust([route]); + + expect(rustRoute.match.headers?.['Content-Type']).toEqual('/^application\\/json$/i'); + expect(rustRoute.match.transport).toEqual('udp'); + expect(rustRoute.match.protocol).toEqual('http3'); + expect(rustRoute.action.targets?.[0].match?.headers?.['X-Env']).toEqual('/^(prod|stage)$/'); + expect(rustRoute.action.targets?.[0].port).toEqual('preserve'); + expect(rustRoute.action.targets?.[0].backendTransport).toEqual('tcp'); + expect(rustRoute.action.sendProxyProtocol).toBeTrue(); + expect(rustRoute.action.udp?.maxSessionsPerIp).toEqual(321); +}); + +tap.test('Rust contract - preprocessor converts dynamic targets to relay-safe payloads', async () => { + const route: IRouteConfig = { + name: 'dynamic-contract-route', + match: { + ports: 8080, + }, + action: { + type: 'forward', + targets: [{ + host: () => 'dynamic-backend.internal', + port: () => 9443, + }], + }, + }; + + const preprocessor = new RoutePreprocessor(); + const [rustRoute] = preprocessor.preprocessForRust([route]); + + expect(rustRoute.action.type).toEqual('socket-handler'); + expect(rustRoute.action.targets?.[0].host).toEqual('localhost'); + expect(rustRoute.action.targets?.[0].port).toEqual(0); + expect(preprocessor.getOriginalRoute('dynamic-contract-route')).toEqual(route); +}); + +tap.test('Rust contract - top-level config keeps shared SmartProxy settings', async () => { + const settings: ISmartProxyOptions = { + routes: [{ + name: 'top-level-contract-route', + match: { + ports: 443, + domains: 'api.example.com', + }, + action: { + type: 'forward', + targets: [{ + host: 'backend.internal', + port: 8443, + }], + tls: { + mode: 'terminate', + certificate: 'auto', + }, + }, + }], + preserveSourceIP: true, + proxyIPs: ['10.0.0.1'], + acceptProxyProtocol: true, + sendProxyProtocol: true, + noDelay: true, + keepAlive: true, + keepAliveInitialDelay: 1500, + maxPendingDataSize: 4096, + disableInactivityCheck: true, + enableKeepAliveProbes: true, + enableDetailedLogging: true, + enableTlsDebugLogging: true, + enableRandomizedTimeouts: true, + connectionTimeout: 5000, + initialDataTimeout: 7000, + socketTimeout: 9000, + inactivityCheckInterval: 1100, + maxConnectionLifetime: 13000, + inactivityTimeout: 15000, + gracefulShutdownTimeout: 17000, + maxConnectionsPerIP: 20, + connectionRateLimitPerMinute: 30, + keepAliveTreatment: 'extended', + keepAliveInactivityMultiplier: 2, + extendedKeepAliveLifetime: 19000, + metrics: { + enabled: true, + sampleIntervalMs: 250, + retentionSeconds: 60, + }, + acme: { + enabled: true, + email: 'ops@example.com', + environment: 'staging', + useProduction: false, + skipConfiguredCerts: true, + renewThresholdDays: 14, + renewCheckIntervalHours: 12, + autoRenew: true, + port: 80, + }, + }; + + const preprocessor = new RoutePreprocessor(); + const routes = preprocessor.preprocessForRust(settings.routes); + const config = buildRustProxyOptions(settings, routes); + + expect(config.preserveSourceIp).toBeTrue(); + expect(config.proxyIps).toEqual(['10.0.0.1']); + expect(config.acceptProxyProtocol).toBeTrue(); + expect(config.sendProxyProtocol).toBeTrue(); + expect(config.noDelay).toBeTrue(); + expect(config.keepAlive).toBeTrue(); + expect(config.keepAliveInitialDelay).toEqual(1500); + expect(config.maxPendingDataSize).toEqual(4096); + expect(config.disableInactivityCheck).toBeTrue(); + expect(config.enableKeepAliveProbes).toBeTrue(); + expect(config.enableDetailedLogging).toBeTrue(); + expect(config.enableTlsDebugLogging).toBeTrue(); + expect(config.enableRandomizedTimeouts).toBeTrue(); + expect(config.connectionTimeout).toEqual(5000); + expect(config.initialDataTimeout).toEqual(7000); + expect(config.socketTimeout).toEqual(9000); + expect(config.inactivityCheckInterval).toEqual(1100); + expect(config.maxConnectionLifetime).toEqual(13000); + expect(config.inactivityTimeout).toEqual(15000); + expect(config.gracefulShutdownTimeout).toEqual(17000); + expect(config.maxConnectionsPerIp).toEqual(20); + expect(config.connectionRateLimitPerMinute).toEqual(30); + expect(config.keepAliveTreatment).toEqual('extended'); + expect(config.keepAliveInactivityMultiplier).toEqual(2); + expect(config.extendedKeepAliveLifetime).toEqual(19000); + expect(config.metrics?.sampleIntervalMs).toEqual(250); + expect(config.acme?.email).toEqual('ops@example.com'); + expect(config.acme?.environment).toEqual('staging'); + expect(config.acme?.skipConfiguredCerts).toBeTrue(); + expect(config.acme?.renewThresholdDays).toEqual(14); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4e7cfcc..d6b362a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '27.6.0', + version: '27.7.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/proxies/smart-proxy/models/rust-types.ts b/ts/proxies/smart-proxy/models/rust-types.ts new file mode 100644 index 0000000..774a5c9 --- /dev/null +++ b/ts/proxies/smart-proxy/models/rust-types.ts @@ -0,0 +1,160 @@ +import type { IProtocolCacheEntry, IProtocolDistribution } from './metrics-types.js'; +import type { IAcmeOptions, ISmartProxyOptions } from './interfaces.js'; +import type { + IRouteAction, + IRouteConfig, + IRouteMatch, + IRouteTarget, + ITargetMatch, + IRouteUdp, +} from './route-types.js'; + +export type TRustHeaderMatchers = Record; + +export interface IRustRouteMatch extends Omit { + headers?: TRustHeaderMatchers; +} + +export interface IRustTargetMatch extends Omit { + headers?: TRustHeaderMatchers; +} + +export interface IRustRouteTarget extends Omit { + host: string | string[]; + port: number | 'preserve'; + match?: IRustTargetMatch; +} + +export interface IRustRouteUdp extends Omit { + maxSessionsPerIp?: number; +} + +export interface IRustDefaultConfig extends Omit, 'preserveSourceIP'> { + preserveSourceIp?: boolean; +} + +export interface IRustRouteAction + extends Omit { + targets?: IRustRouteTarget[]; + udp?: IRustRouteUdp; +} + +export interface IRustRouteConfig extends Omit { + match: IRustRouteMatch; + action: IRustRouteAction; +} + +export interface IRustAcmeOptions extends Omit {} + +export interface IRustProxyOptions { + routes: IRustRouteConfig[]; + preserveSourceIp?: boolean; + proxyIps?: string[]; + acceptProxyProtocol?: boolean; + sendProxyProtocol?: boolean; + defaults?: IRustDefaultConfig; + connectionTimeout?: number; + initialDataTimeout?: number; + socketTimeout?: number; + inactivityCheckInterval?: number; + maxConnectionLifetime?: number; + inactivityTimeout?: number; + gracefulShutdownTimeout?: number; + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + maxPendingDataSize?: number; + disableInactivityCheck?: boolean; + enableKeepAliveProbes?: boolean; + enableDetailedLogging?: boolean; + enableTlsDebugLogging?: boolean; + enableRandomizedTimeouts?: boolean; + maxConnectionsPerIp?: number; + connectionRateLimitPerMinute?: number; + keepAliveTreatment?: ISmartProxyOptions['keepAliveTreatment']; + keepAliveInactivityMultiplier?: number; + extendedKeepAliveLifetime?: number; + metrics?: ISmartProxyOptions['metrics']; + acme?: IRustAcmeOptions; +} + +export interface IRustStatistics { + activeConnections: number; + totalConnections: number; + routesCount: number; + listeningPorts: number[]; + uptimeSeconds: number; +} + +export interface IRustCertificateStatus { + domain: string; + source: string; + expiresAt: number; + isValid: boolean; +} + +export interface IRustThroughputSample { + timestampMs: number; + bytesIn: number; + bytesOut: number; +} + +export interface IRustRouteMetrics { + activeConnections: number; + totalConnections: number; + bytesIn: number; + bytesOut: number; + throughputInBytesPerSec: number; + throughputOutBytesPerSec: number; + throughputRecentInBytesPerSec: number; + throughputRecentOutBytesPerSec: number; +} + +export interface IRustIpMetrics { + activeConnections: number; + totalConnections: number; + bytesIn: number; + bytesOut: number; + throughputInBytesPerSec: number; + throughputOutBytesPerSec: number; + domainRequests: Record; +} + +export interface IRustBackendMetrics { + activeConnections: number; + totalConnections: number; + protocol: string; + connectErrors: number; + handshakeErrors: number; + requestErrors: number; + totalConnectTimeUs: number; + connectCount: number; + poolHits: number; + poolMisses: number; + h2Failures: number; +} + +export interface IRustMetricsSnapshot { + activeConnections: number; + totalConnections: number; + bytesIn: number; + bytesOut: number; + throughputInBytesPerSec: number; + throughputOutBytesPerSec: number; + throughputRecentInBytesPerSec: number; + throughputRecentOutBytesPerSec: number; + routes: Record; + ips: Record; + backends: Record; + throughputHistory: IRustThroughputSample[]; + totalHttpRequests: number; + httpRequestsPerSec: number; + httpRequestsPerSecRecent: number; + activeUdpSessions: number; + totalUdpSessions: number; + totalDatagramsIn: number; + totalDatagramsOut: number; + detectedProtocols: IProtocolCacheEntry[]; + frontendProtocols: IProtocolDistribution; + backendProtocols: IProtocolDistribution; +} diff --git a/ts/proxies/smart-proxy/route-preprocessor.ts b/ts/proxies/smart-proxy/route-preprocessor.ts index b64ea43..cb7957f 100644 --- a/ts/proxies/smart-proxy/route-preprocessor.ts +++ b/ts/proxies/smart-proxy/route-preprocessor.ts @@ -1,5 +1,6 @@ import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js'; -import { logger } from '../../core/utils/logger.js'; +import type { IRustRouteConfig } from './models/rust-types.js'; +import { serializeRouteForRust } from './utils/rust-config.js'; /** * Preprocesses routes before sending them to Rust. @@ -24,7 +25,7 @@ export class RoutePreprocessor { * - Non-serializable fields are stripped * - Original routes are preserved in the local map for handler lookup */ - public preprocessForRust(routes: IRouteConfig[]): IRouteConfig[] { + public preprocessForRust(routes: IRouteConfig[]): IRustRouteConfig[] { this.originalRoutes.clear(); return routes.map((route, index) => this.preprocessRoute(route, index)); } @@ -43,7 +44,7 @@ export class RoutePreprocessor { return new Map(this.originalRoutes); } - private preprocessRoute(route: IRouteConfig, index: number): IRouteConfig { + private preprocessRoute(route: IRouteConfig, index: number): IRustRouteConfig { const routeKey = route.name || route.id || `route_${index}`; // Check if this route needs TS-side handling @@ -57,7 +58,7 @@ export class RoutePreprocessor { // Create a clean copy for Rust const cleanRoute: IRouteConfig = { ...route, - action: this.cleanAction(route.action, routeKey, needsTsHandling), + action: this.cleanAction(route.action, needsTsHandling), }; // Ensure we have a name for handler lookup @@ -65,7 +66,7 @@ export class RoutePreprocessor { cleanRoute.name = routeKey; } - return cleanRoute; + return serializeRouteForRust(cleanRoute); } private routeNeedsTsHandling(route: IRouteConfig): boolean { @@ -91,15 +92,16 @@ export class RoutePreprocessor { return false; } - private cleanAction(action: IRouteAction, routeKey: string, needsTsHandling: boolean): IRouteAction { - const cleanAction: IRouteAction = { ...action }; + private cleanAction(action: IRouteAction, needsTsHandling: boolean): IRouteAction { + let cleanAction: IRouteAction = { ...action }; if (needsTsHandling) { // Convert to socket-handler type for Rust (Rust will relay back to TS) - cleanAction.type = 'socket-handler'; - // Remove the JS handlers (not serializable) - delete (cleanAction as any).socketHandler; - delete (cleanAction as any).datagramHandler; + const { socketHandler: _socketHandler, datagramHandler: _datagramHandler, ...serializableAction } = cleanAction; + cleanAction = { + ...serializableAction, + type: 'socket-handler', + }; } // Clean targets - replace functions with static values diff --git a/ts/proxies/smart-proxy/rust-metrics-adapter.ts b/ts/proxies/smart-proxy/rust-metrics-adapter.ts index 16be275..ca726e2 100644 --- a/ts/proxies/smart-proxy/rust-metrics-adapter.ts +++ b/ts/proxies/smart-proxy/rust-metrics-adapter.ts @@ -1,5 +1,6 @@ import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js'; import type { RustProxyBridge } from './rust-proxy-bridge.js'; +import type { IRustBackendMetrics, IRustIpMetrics, IRustMetricsSnapshot, IRustRouteMetrics } from './models/rust-types.js'; /** * Adapts Rust JSON metrics to the IMetrics interface. @@ -14,7 +15,7 @@ import type { RustProxyBridge } from './rust-proxy-bridge.js'; */ export class RustMetricsAdapter implements IMetrics { private bridge: RustProxyBridge; - private cache: any = null; + private cache: IRustMetricsSnapshot | null = null; private pollTimer: ReturnType | null = null; private pollIntervalMs: number; @@ -65,8 +66,8 @@ export class RustMetricsAdapter implements IMetrics { byRoute: (): Map => { const result = new Map(); if (this.cache?.routes) { - for (const [name, rm] of Object.entries(this.cache.routes)) { - result.set(name, (rm as any).activeConnections ?? 0); + for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) { + result.set(name, rm.activeConnections ?? 0); } } return result; @@ -74,8 +75,8 @@ export class RustMetricsAdapter implements IMetrics { byIP: (): Map => { const result = new Map(); if (this.cache?.ips) { - for (const [ip, im] of Object.entries(this.cache.ips)) { - result.set(ip, (im as any).activeConnections ?? 0); + for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) { + result.set(ip, im.activeConnections ?? 0); } } return result; @@ -83,8 +84,8 @@ export class RustMetricsAdapter implements IMetrics { topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => { const result: Array<{ ip: string; count: number }> = []; if (this.cache?.ips) { - for (const [ip, im] of Object.entries(this.cache.ips)) { - result.push({ ip, count: (im as any).activeConnections ?? 0 }); + for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) { + result.push({ ip, count: im.activeConnections ?? 0 }); } } result.sort((a, b) => b.count - a.count); @@ -93,8 +94,8 @@ export class RustMetricsAdapter implements IMetrics { domainRequestsByIP: (): Map> => { const result = new Map>(); if (this.cache?.ips) { - for (const [ip, im] of Object.entries(this.cache.ips)) { - const dr = (im as any).domainRequests; + for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) { + const dr = im.domainRequests; if (dr && typeof dr === 'object') { const domainMap = new Map(); for (const [domain, count] of Object.entries(dr)) { @@ -111,8 +112,8 @@ export class RustMetricsAdapter implements IMetrics { topDomainRequests: (limit: number = 20): Array<{ ip: string; domain: string; count: number }> => { const result: Array<{ ip: string; domain: string; count: number }> = []; if (this.cache?.ips) { - for (const [ip, im] of Object.entries(this.cache.ips)) { - const dr = (im as any).domainRequests; + for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) { + const dr = im.domainRequests; if (dr && typeof dr === 'object') { for (const [domain, count] of Object.entries(dr)) { result.push({ ip, domain, count: count as number }); @@ -176,7 +177,7 @@ export class RustMetricsAdapter implements IMetrics { }, history: (seconds: number): Array => { if (!this.cache?.throughputHistory) return []; - return this.cache.throughputHistory.slice(-seconds).map((p: any) => ({ + return this.cache.throughputHistory.slice(-seconds).map((p) => ({ timestamp: p.timestampMs, in: p.bytesIn, out: p.bytesOut, @@ -185,10 +186,10 @@ export class RustMetricsAdapter implements IMetrics { byRoute: (_windowSeconds?: number): Map => { const result = new Map(); if (this.cache?.routes) { - for (const [name, rm] of Object.entries(this.cache.routes)) { + for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) { result.set(name, { - in: (rm as any).throughputInBytesPerSec ?? 0, - out: (rm as any).throughputOutBytesPerSec ?? 0, + in: rm.throughputInBytesPerSec ?? 0, + out: rm.throughputOutBytesPerSec ?? 0, }); } } @@ -197,10 +198,10 @@ export class RustMetricsAdapter implements IMetrics { byIP: (_windowSeconds?: number): Map => { const result = new Map(); if (this.cache?.ips) { - for (const [ip, im] of Object.entries(this.cache.ips)) { + for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) { result.set(ip, { - in: (im as any).throughputInBytesPerSec ?? 0, - out: (im as any).throughputOutBytesPerSec ?? 0, + in: im.throughputInBytesPerSec ?? 0, + out: im.throughputOutBytesPerSec ?? 0, }); } } @@ -236,23 +237,22 @@ export class RustMetricsAdapter implements IMetrics { byBackend: (): Map => { const result = new Map(); if (this.cache?.backends) { - for (const [key, bm] of Object.entries(this.cache.backends)) { - const m = bm as any; - const totalTimeUs = m.totalConnectTimeUs ?? 0; - const count = m.connectCount ?? 0; - const poolHits = m.poolHits ?? 0; - const poolMisses = m.poolMisses ?? 0; + for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) { + const totalTimeUs = bm.totalConnectTimeUs ?? 0; + const count = bm.connectCount ?? 0; + const poolHits = bm.poolHits ?? 0; + const poolMisses = bm.poolMisses ?? 0; const poolTotal = poolHits + poolMisses; result.set(key, { - protocol: m.protocol ?? 'unknown', - activeConnections: m.activeConnections ?? 0, - totalConnections: m.totalConnections ?? 0, - connectErrors: m.connectErrors ?? 0, - handshakeErrors: m.handshakeErrors ?? 0, - requestErrors: m.requestErrors ?? 0, + protocol: bm.protocol ?? 'unknown', + activeConnections: bm.activeConnections ?? 0, + totalConnections: bm.totalConnections ?? 0, + connectErrors: bm.connectErrors ?? 0, + handshakeErrors: bm.handshakeErrors ?? 0, + requestErrors: bm.requestErrors ?? 0, avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0, poolHitRate: poolTotal > 0 ? poolHits / poolTotal : 0, - h2Failures: m.h2Failures ?? 0, + h2Failures: bm.h2Failures ?? 0, }); } } @@ -261,8 +261,8 @@ export class RustMetricsAdapter implements IMetrics { protocols: (): Map => { const result = new Map(); if (this.cache?.backends) { - for (const [key, bm] of Object.entries(this.cache.backends)) { - result.set(key, (bm as any).protocol ?? 'unknown'); + for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) { + result.set(key, bm.protocol ?? 'unknown'); } } return result; @@ -270,9 +270,8 @@ export class RustMetricsAdapter implements IMetrics { topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => { const result: Array<{ backend: string; errors: number }> = []; if (this.cache?.backends) { - for (const [key, bm] of Object.entries(this.cache.backends)) { - const m = bm as any; - const errors = (m.connectErrors ?? 0) + (m.handshakeErrors ?? 0) + (m.requestErrors ?? 0); + for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) { + const errors = (bm.connectErrors ?? 0) + (bm.handshakeErrors ?? 0) + (bm.requestErrors ?? 0); if (errors > 0) result.push({ backend: key, errors }); } } diff --git a/ts/proxies/smart-proxy/rust-proxy-bridge.ts b/ts/proxies/smart-proxy/rust-proxy-bridge.ts index ffb9d4c..7df1224 100644 --- a/ts/proxies/smart-proxy/rust-proxy-bridge.ts +++ b/ts/proxies/smart-proxy/rust-proxy-bridge.ts @@ -1,23 +1,29 @@ import * as plugins from '../../plugins.js'; import { logger } from '../../core/utils/logger.js'; -import type { IRouteConfig } from './models/route-types.js'; +import type { + IRustCertificateStatus, + IRustMetricsSnapshot, + IRustProxyOptions, + IRustRouteConfig, + IRustStatistics, +} from './models/rust-types.js'; /** * Type-safe command definitions for the Rust proxy IPC protocol. */ type TSmartProxyCommands = { - start: { params: { config: any }; result: void }; - stop: { params: Record; result: void }; - updateRoutes: { params: { routes: IRouteConfig[] }; result: void }; - getMetrics: { params: Record; result: any }; - getStatistics: { params: Record; result: any }; - provisionCertificate: { params: { routeName: string }; result: void }; - renewCertificate: { params: { routeName: string }; result: void }; - getCertificateStatus: { params: { routeName: string }; result: any }; - getListeningPorts: { params: Record; result: { ports: number[] } }; - setSocketHandlerRelay: { params: { socketPath: string }; result: void }; - addListeningPort: { params: { port: number }; result: void }; - removeListeningPort: { params: { port: number }; result: void }; + start: { params: { config: IRustProxyOptions }; result: void }; + stop: { params: Record; result: void }; + updateRoutes: { params: { routes: IRustRouteConfig[] }; result: void }; + getMetrics: { params: Record; result: IRustMetricsSnapshot }; + getStatistics: { params: Record; result: IRustStatistics }; + provisionCertificate: { params: { routeName: string }; result: void }; + renewCertificate: { params: { routeName: string }; result: void }; + getCertificateStatus: { params: { routeName: string }; result: IRustCertificateStatus | null }; + getListeningPorts: { params: Record; result: { ports: number[] } }; + setSocketHandlerRelay: { params: { socketPath: string }; result: void }; + addListeningPort: { params: { port: number }; result: void }; + removeListeningPort: { params: { port: number }; result: void }; loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void }; setDatagramHandlerRelay: { params: { socketPath: string }; result: void }; }; @@ -121,7 +127,7 @@ export class RustProxyBridge extends plugins.EventEmitter { // --- Convenience methods for each management command --- - public async startProxy(config: any): Promise { + public async startProxy(config: IRustProxyOptions): Promise { await this.bridge.sendCommand('start', { config }); } @@ -129,15 +135,15 @@ export class RustProxyBridge extends plugins.EventEmitter { await this.bridge.sendCommand('stop', {} as Record); } - public async updateRoutes(routes: IRouteConfig[]): Promise { + public async updateRoutes(routes: IRustRouteConfig[]): Promise { await this.bridge.sendCommand('updateRoutes', { routes }); } - public async getMetrics(): Promise { + public async getMetrics(): Promise { return this.bridge.sendCommand('getMetrics', {} as Record); } - public async getStatistics(): Promise { + public async getStatistics(): Promise { return this.bridge.sendCommand('getStatistics', {} as Record); } @@ -149,7 +155,7 @@ export class RustProxyBridge extends plugins.EventEmitter { await this.bridge.sendCommand('renewCertificate', { routeName }); } - public async getCertificateStatus(routeName: string): Promise { + public async getCertificateStatus(routeName: string): Promise { return this.bridge.sendCommand('getCertificateStatus', { routeName }); } diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index 634cd33..3b15e5c 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -11,6 +11,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js'; // Route management import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { RouteValidator } from './utils/route-validator.js'; +import { buildRustProxyOptions } from './utils/rust-config.js'; import { generateDefaultCertificate } from './utils/default-cert-generator.js'; import { Mutex } from './utils/mutex.js'; import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js'; @@ -19,6 +20,7 @@ import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js'; import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js'; import type { IRouteConfig } from './models/route-types.js'; import type { IMetrics } from './models/metrics-types.js'; +import type { IRustCertificateStatus, IRustProxyOptions, IRustStatistics } from './models/rust-types.js'; /** * SmartProxy - Rust-backed proxy engine with TypeScript configuration API. @@ -365,7 +367,7 @@ export class SmartProxy extends plugins.EventEmitter { /** * Get certificate status for a route (async - calls Rust). */ - public async getCertificateStatus(routeName: string): Promise { + public async getCertificateStatus(routeName: string): Promise { return this.bridge.getCertificateStatus(routeName); } @@ -379,7 +381,7 @@ export class SmartProxy extends plugins.EventEmitter { /** * Get statistics (async - calls Rust). */ - public async getStatistics(): Promise { + public async getStatistics(): Promise { return this.bridge.getStatistics(); } @@ -484,37 +486,8 @@ export class SmartProxy extends plugins.EventEmitter { /** * Build the Rust configuration object from TS settings. */ - private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any { - const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme; - return { - routes, - defaults: this.settings.defaults, - acme: acme - ? { - enabled: acme.enabled, - email: acme.email, - useProduction: acme.useProduction, - port: acme.port, - renewThresholdDays: acme.renewThresholdDays, - autoRenew: acme.autoRenew, - renewCheckIntervalHours: acme.renewCheckIntervalHours, - } - : undefined, - connectionTimeout: this.settings.connectionTimeout, - initialDataTimeout: this.settings.initialDataTimeout, - socketTimeout: this.settings.socketTimeout, - maxConnectionLifetime: this.settings.maxConnectionLifetime, - gracefulShutdownTimeout: this.settings.gracefulShutdownTimeout, - maxConnectionsPerIp: this.settings.maxConnectionsPerIP, - connectionRateLimitPerMinute: this.settings.connectionRateLimitPerMinute, - keepAliveTreatment: this.settings.keepAliveTreatment, - keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier, - extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime, - proxyIps: this.settings.proxyIPs, - acceptProxyProtocol: this.settings.acceptProxyProtocol, - sendProxyProtocol: this.settings.sendProxyProtocol, - metrics: this.settings.metrics, - }; + private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions { + return buildRustProxyOptions(this.settings, routes, acmeOverride); } /** diff --git a/ts/proxies/smart-proxy/utils/route-utils.ts b/ts/proxies/smart-proxy/utils/route-utils.ts index 53ce7f3..a6e1dda 100644 --- a/ts/proxies/smart-proxy/utils/route-utils.ts +++ b/ts/proxies/smart-proxy/utils/route-utils.ts @@ -168,14 +168,28 @@ export function routeMatchesHeaders( if (!route.match?.headers || Object.keys(route.match.headers).length === 0) { return true; // No headers specified means it matches any headers } - - // Convert RegExp patterns to strings for HeaderMatcher - const stringHeaders: Record = {}; - for (const [key, value] of Object.entries(route.match.headers)) { - stringHeaders[key] = value instanceof RegExp ? value.source : value; + + for (const [headerName, expectedValue] of Object.entries(route.match.headers)) { + const actualKey = Object.keys(headers).find((key) => key.toLowerCase() === headerName.toLowerCase()); + const actualValue = actualKey ? headers[actualKey] : undefined; + + if (actualValue === undefined) { + return false; + } + + if (expectedValue instanceof RegExp) { + if (!expectedValue.test(actualValue)) { + return false; + } + continue; + } + + if (!HeaderMatcher.match(expectedValue, actualValue)) { + return false; + } } - - return HeaderMatcher.matchAll(stringHeaders, headers); + + return true; } /** @@ -283,4 +297,4 @@ export function generateRouteId(route: IRouteConfig): string { */ export function cloneRoute(route: IRouteConfig): IRouteConfig { return JSON.parse(JSON.stringify(route)); -} \ No newline at end of file +} diff --git a/ts/proxies/smart-proxy/utils/rust-config.ts b/ts/proxies/smart-proxy/utils/rust-config.ts new file mode 100644 index 0000000..d641be9 --- /dev/null +++ b/ts/proxies/smart-proxy/utils/rust-config.ts @@ -0,0 +1,187 @@ +import type { IAcmeOptions, ISmartProxyOptions } from '../models/interfaces.js'; +import type { IRouteAction, IRouteConfig, IRouteMatch, IRouteTarget, ITargetMatch } from '../models/route-types.js'; +import type { + IRustAcmeOptions, + IRustDefaultConfig, + IRustProxyOptions, + IRustRouteAction, + IRustRouteConfig, + IRustRouteMatch, + IRustRouteTarget, + IRustTargetMatch, + IRustRouteUdp, + TRustHeaderMatchers, +} from '../models/rust-types.js'; + +const SUPPORTED_REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g']); + +export function serializeHeaderMatchValue(value: string | RegExp): string { + if (typeof value === 'string') { + return value; + } + + const unsupportedFlags = Array.from(new Set(value.flags)).filter((flag) => !SUPPORTED_REGEX_FLAGS.has(flag)); + if (unsupportedFlags.length > 0) { + throw new Error( + `Header RegExp uses unsupported flags for Rust serialization: ${unsupportedFlags.join(', ')}` + ); + } + + return `/${value.source}/${value.flags}`; +} + +export function serializeHeaderMatchers(headers?: Record): TRustHeaderMatchers | undefined { + if (!headers) { + return undefined; + } + + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, serializeHeaderMatchValue(value)]) + ); +} + +export function serializeTargetMatchForRust(match?: ITargetMatch): IRustTargetMatch | undefined { + if (!match) { + return undefined; + } + + return { + ...match, + headers: serializeHeaderMatchers(match.headers), + }; +} + +export function serializeRouteMatchForRust(match: IRouteMatch): IRustRouteMatch { + return { + ...match, + headers: serializeHeaderMatchers(match.headers), + }; +} + +export function serializeRouteTargetForRust(target: IRouteTarget): IRustRouteTarget { + if (typeof target.host !== 'string' && !Array.isArray(target.host)) { + throw new Error('Route target host must be serialized before sending to Rust'); + } + + if (typeof target.port !== 'number' && target.port !== 'preserve') { + throw new Error('Route target port must be serialized before sending to Rust'); + } + + return { + ...target, + host: target.host, + port: target.port, + match: serializeTargetMatchForRust(target.match), + }; +} + +function serializeUdpForRust(udp?: IRouteAction['udp']): IRustRouteUdp | undefined { + if (!udp) { + return undefined; + } + + const { maxSessionsPerIP, ...rest } = udp; + + return { + ...rest, + maxSessionsPerIp: maxSessionsPerIP, + }; +} + +export function serializeRouteActionForRust(action: IRouteAction): IRustRouteAction { + const { + socketHandler: _socketHandler, + datagramHandler: _datagramHandler, + forwardingEngine: _forwardingEngine, + nftables: _nftables, + targets, + udp, + ...rest + } = action; + + return { + ...rest, + targets: targets?.map((target) => serializeRouteTargetForRust(target)), + udp: serializeUdpForRust(udp), + }; +} + +export function serializeRouteForRust(route: IRouteConfig): IRustRouteConfig { + return { + ...route, + match: serializeRouteMatchForRust(route.match), + action: serializeRouteActionForRust(route.action), + }; +} + +function serializeAcmeForRust(acme?: IAcmeOptions): IRustAcmeOptions | undefined { + if (!acme) { + return undefined; + } + + return { + enabled: acme.enabled, + email: acme.email, + environment: acme.environment, + accountEmail: acme.accountEmail, + port: acme.port, + useProduction: acme.useProduction, + renewThresholdDays: acme.renewThresholdDays, + autoRenew: acme.autoRenew, + skipConfiguredCerts: acme.skipConfiguredCerts, + renewCheckIntervalHours: acme.renewCheckIntervalHours, + }; +} + +function serializeDefaultsForRust(defaults?: ISmartProxyOptions['defaults']): IRustDefaultConfig | undefined { + if (!defaults) { + return undefined; + } + + const { preserveSourceIP, ...rest } = defaults; + + return { + ...rest, + preserveSourceIp: preserveSourceIP, + }; +} + +export function buildRustProxyOptions( + settings: ISmartProxyOptions, + routes: IRustRouteConfig[], + acmeOverride?: IAcmeOptions, +): IRustProxyOptions { + const acme = acmeOverride !== undefined ? acmeOverride : settings.acme; + + return { + routes, + preserveSourceIp: settings.preserveSourceIP, + proxyIps: settings.proxyIPs, + acceptProxyProtocol: settings.acceptProxyProtocol, + sendProxyProtocol: settings.sendProxyProtocol, + defaults: serializeDefaultsForRust(settings.defaults), + connectionTimeout: settings.connectionTimeout, + initialDataTimeout: settings.initialDataTimeout, + socketTimeout: settings.socketTimeout, + inactivityCheckInterval: settings.inactivityCheckInterval, + maxConnectionLifetime: settings.maxConnectionLifetime, + inactivityTimeout: settings.inactivityTimeout, + gracefulShutdownTimeout: settings.gracefulShutdownTimeout, + noDelay: settings.noDelay, + keepAlive: settings.keepAlive, + keepAliveInitialDelay: settings.keepAliveInitialDelay, + maxPendingDataSize: settings.maxPendingDataSize, + disableInactivityCheck: settings.disableInactivityCheck, + enableKeepAliveProbes: settings.enableKeepAliveProbes, + enableDetailedLogging: settings.enableDetailedLogging, + enableTlsDebugLogging: settings.enableTlsDebugLogging, + enableRandomizedTimeouts: settings.enableRandomizedTimeouts, + maxConnectionsPerIp: settings.maxConnectionsPerIP, + connectionRateLimitPerMinute: settings.connectionRateLimitPerMinute, + keepAliveTreatment: settings.keepAliveTreatment, + keepAliveInactivityMultiplier: settings.keepAliveInactivityMultiplier, + extendedKeepAliveLifetime: settings.extendedKeepAliveLifetime, + metrics: settings.metrics, + acme: serializeAcmeForRust(acme), + }; +}