diff --git a/changelog.md b/changelog.md index 137d656..52c3024 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-16 - 25.11.9 - fix(rustproxy-routing) +reduce hot-path allocations in routing, metrics, and proxy protocol handling + +- skip HTTP header map construction unless a route on the current port uses header matching +- reuse computed client IP strings during HTTP route matching to avoid redundant allocations +- optimize per-route and per-IP metric updates with get-first lookups to avoid unnecessary String creation on existing entries +- replace heap-allocated PROXY protocol peek and discard buffers with stack-allocated buffers in the TCP listener +- improve domain matcher case-insensitive wildcard checks while preserving glob fallback behavior + ## 2026-03-16 - 25.11.8 - fix(rustproxy-http) prevent premature idle timeouts during streamed HTTP responses and ensure TLS close_notify is sent on dropped connections diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 1256216..2d0bac0 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -399,11 +399,19 @@ impl HttpProxyService { let path = req.uri().path().to_string(); let method = req.method().clone(); - // Extract headers for matching - let headers: HashMap = req.headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); + // Extract headers for matching — only allocate the HashMap if any route + // on this port actually uses header matching. Most deployments don't, + // so this saves ~20-30 String allocations per request. + let current_rm = self.route_manager.load(); + let needs_headers = current_rm.any_route_has_headers(port); + let headers: Option> = if needs_headers { + Some(req.headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect()) + } else { + None + }; debug!("HTTP {} {} (host: {:?}) from {}", method, path, host, peer_addr); @@ -414,19 +422,19 @@ impl HttpProxyService { } } - // Match route + // Match route (current_rm already loaded above for headers check) + let ip_string = peer_addr.ip().to_string(); let ctx = rustproxy_routing::MatchContext { port, domain: host.as_deref(), path: Some(&path), - client_ip: Some(&peer_addr.ip().to_string()), + client_ip: Some(&ip_string), tls_version: None, - headers: Some(&headers), + headers: headers.as_ref(), is_tls: false, protocol: Some("http"), }; - let current_rm = self.route_manager.load(); let route_match = match current_rm.find_route(&ctx) { Some(rm) => rm, None => { @@ -436,7 +444,7 @@ impl HttpProxyService { }; let route_id = route_match.route.id.as_deref(); - let ip_str = peer_addr.ip().to_string(); + let ip_str = ip_string; // reuse from above (avoid redundant to_string()) self.metrics.record_http_request(); // Apply request filters (IP check, rate limiting, auth) diff --git a/rust/crates/rustproxy-metrics/src/collector.rs b/rust/crates/rustproxy-metrics/src/collector.rs index 5a0c97f..86a7459 100644 --- a/rust/crates/rustproxy-metrics/src/collector.rs +++ b/rust/crates/rustproxy-metrics/src/collector.rs @@ -266,44 +266,67 @@ impl MetricsCollector { self.global_pending_tp_in.fetch_add(bytes_in, Ordering::Relaxed); self.global_pending_tp_out.fetch_add(bytes_out, Ordering::Relaxed); + // Per-route tracking: use get() first (zero-alloc fast path for existing entries), + // fall back to entry() with to_string() only on the rare first-chunk miss. if let Some(route_id) = route_id { - self.route_bytes_in - .entry(route_id.to_string()) - .or_insert_with(|| AtomicU64::new(0)) - .fetch_add(bytes_in, Ordering::Relaxed); - self.route_bytes_out - .entry(route_id.to_string()) - .or_insert_with(|| AtomicU64::new(0)) - .fetch_add(bytes_out, Ordering::Relaxed); + if let Some(counter) = self.route_bytes_in.get(route_id) { + counter.fetch_add(bytes_in, Ordering::Relaxed); + } else { + self.route_bytes_in.entry(route_id.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(bytes_in, Ordering::Relaxed); + } + if let Some(counter) = self.route_bytes_out.get(route_id) { + counter.fetch_add(bytes_out, Ordering::Relaxed); + } else { + self.route_bytes_out.entry(route_id.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(bytes_out, Ordering::Relaxed); + } // Accumulate into per-route pending throughput counters (lock-free) - let entry = self.route_pending_tp - .entry(route_id.to_string()) - .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); - entry.0.fetch_add(bytes_in, Ordering::Relaxed); - entry.1.fetch_add(bytes_out, Ordering::Relaxed); + if let Some(entry) = self.route_pending_tp.get(route_id) { + entry.0.fetch_add(bytes_in, Ordering::Relaxed); + entry.1.fetch_add(bytes_out, Ordering::Relaxed); + } else { + let entry = self.route_pending_tp.entry(route_id.to_string()) + .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); + entry.0.fetch_add(bytes_in, Ordering::Relaxed); + entry.1.fetch_add(bytes_out, Ordering::Relaxed); + } } + // Per-IP tracking: same get()-first pattern to avoid String allocation on hot path. if let Some(ip) = source_ip { // Only record per-IP stats if the IP still has active connections. // This prevents orphaned entries when record_bytes races with // connection_closed (which evicts all per-IP data on last close). if self.ip_connections.contains_key(ip) { - self.ip_bytes_in - .entry(ip.to_string()) - .or_insert_with(|| AtomicU64::new(0)) - .fetch_add(bytes_in, Ordering::Relaxed); - self.ip_bytes_out - .entry(ip.to_string()) - .or_insert_with(|| AtomicU64::new(0)) - .fetch_add(bytes_out, Ordering::Relaxed); + if let Some(counter) = self.ip_bytes_in.get(ip) { + counter.fetch_add(bytes_in, Ordering::Relaxed); + } else { + self.ip_bytes_in.entry(ip.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(bytes_in, Ordering::Relaxed); + } + if let Some(counter) = self.ip_bytes_out.get(ip) { + counter.fetch_add(bytes_out, Ordering::Relaxed); + } else { + self.ip_bytes_out.entry(ip.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(bytes_out, Ordering::Relaxed); + } // Accumulate into per-IP pending throughput counters (lock-free) - let entry = self.ip_pending_tp - .entry(ip.to_string()) - .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); - entry.0.fetch_add(bytes_in, Ordering::Relaxed); - entry.1.fetch_add(bytes_out, Ordering::Relaxed); + if let Some(entry) = self.ip_pending_tp.get(ip) { + entry.0.fetch_add(bytes_in, Ordering::Relaxed); + entry.1.fetch_add(bytes_out, Ordering::Relaxed); + } else { + let entry = self.ip_pending_tp.entry(ip.to_string()) + .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); + entry.0.fetch_add(bytes_in, Ordering::Relaxed); + entry.1.fetch_add(bytes_out, Ordering::Relaxed); + } } } } diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index 8f0f1b3..6dacb89 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -561,8 +561,9 @@ impl TcpListenerManager { // Non-proxy connections skip the peek entirely (no latency cost). let mut effective_peer_addr = peer_addr; if !conn_config.proxy_ips.is_empty() && conn_config.proxy_ips.contains(&peer_addr.ip()) { - // Trusted proxy IP — peek for PROXY protocol header - let mut proxy_peek = vec![0u8; 256]; + // Trusted proxy IP — peek for PROXY protocol header. + // Use stack-allocated buffers (PROXY v1 headers are max ~108 bytes). + let mut proxy_peek = [0u8; 256]; let pn = match tokio::time::timeout( std::time::Duration::from_millis(conn_config.initial_data_timeout_ms), stream.peek(&mut proxy_peek), @@ -577,9 +578,9 @@ impl TcpListenerManager { Ok((header, consumed)) => { debug!("PROXY protocol: real client {} -> {}", header.source_addr, header.dest_addr); effective_peer_addr = header.source_addr; - // Consume the proxy protocol header bytes - let mut discard = vec![0u8; consumed]; - stream.read_exact(&mut discard).await?; + // Consume the proxy protocol header bytes (stack buffer, max 108 bytes) + let mut discard = [0u8; 128]; + stream.read_exact(&mut discard[..consumed]).await?; } Err(e) => { debug!("Failed to parse PROXY protocol header: {}", e); diff --git a/rust/crates/rustproxy-routing/src/matchers/domain.rs b/rust/crates/rustproxy-routing/src/matchers/domain.rs index b7529d1..f27b244 100644 --- a/rust/crates/rustproxy-routing/src/matchers/domain.rs +++ b/rust/crates/rustproxy-routing/src/matchers/domain.rs @@ -6,25 +6,28 @@ /// - `example.com` exact match /// - `**.example.com` matches any depth of subdomain pub fn domain_matches(pattern: &str, domain: &str) -> bool { - let pattern = pattern.trim().to_lowercase(); - let domain = domain.trim().to_lowercase(); + let pattern = pattern.trim(); + let domain = domain.trim(); if pattern == "*" { return true; } - if pattern == domain { + if pattern.eq_ignore_ascii_case(domain) { return true; } // Wildcard patterns - if pattern.starts_with("*.") { + if pattern.starts_with("*.") || pattern.starts_with("*.") { let suffix = &pattern[2..]; // e.g., "example.com" // Match exact parent or any single-level subdomain - if domain == suffix { + if domain.eq_ignore_ascii_case(suffix) { return true; } - if domain.ends_with(&format!(".{}", suffix)) { + if domain.len() > suffix.len() + 1 + && domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.' + && domain[domain.len() - suffix.len()..].eq_ignore_ascii_case(suffix) + { // Check it's a single level subdomain for `*.` let prefix = &domain[..domain.len() - suffix.len() - 1]; return !prefix.contains('.'); @@ -35,11 +38,22 @@ pub fn domain_matches(pattern: &str, domain: &str) -> bool { if pattern.starts_with("**.") { let suffix = &pattern[3..]; // Match exact parent or any depth of subdomain - return domain == suffix || domain.ends_with(&format!(".{}", suffix)); + if domain.eq_ignore_ascii_case(suffix) { + return true; + } + if domain.len() > suffix.len() + 1 + && domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.' + && domain[domain.len() - suffix.len()..].eq_ignore_ascii_case(suffix) + { + return true; + } + return false; } - // Use glob-match for more complex patterns - glob_match::glob_match(&pattern, &domain) + // Use glob-match for more complex patterns (case-insensitive via lowercasing) + let pattern_lower = pattern.to_lowercase(); + let domain_lower = domain.to_lowercase(); + glob_match::glob_match(&pattern_lower, &domain_lower) } /// Check if a domain matches any of the given patterns. diff --git a/rust/crates/rustproxy-routing/src/route_manager.rs b/rust/crates/rustproxy-routing/src/route_manager.rs index 5b8807c..f4336df 100644 --- a/rust/crates/rustproxy-routing/src/route_manager.rs +++ b/rust/crates/rustproxy-routing/src/route_manager.rs @@ -60,6 +60,16 @@ impl RouteManager { manager } + /// Check if any route on the given port uses header matching. + /// Used to skip expensive header HashMap construction when no route needs it. + pub fn any_route_has_headers(&self, port: u16) -> bool { + if let Some(indices) = self.port_index.get(&port) { + indices.iter().any(|&idx| self.routes[idx].route_match.headers.is_some()) + } else { + false + } + } + /// Find the best matching route for the given context. pub fn find_route<'a>(&'a self, ctx: &MatchContext<'_>) -> Option> { // Get routes for this port diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6db59bf..1e78964 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: '25.11.8', + version: '25.11.9', 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.' }