From 33cd5330c491cdfb17b247042792bf0b451587f8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Feb 2026 20:56:37 +0000 Subject: [PATCH] fix(rustproxy): Use cooperative cancellation for background tasks, prune stale caches and metric entries, and switch tests to dynamic port allocation to avoid port conflicts --- changelog.md | 10 + .../rustproxy-http/src/proxy_service.rs | 8 + .../rustproxy-http/src/upstream_selector.rs | 8 + .../crates/rustproxy-metrics/src/collector.rs | 85 +++++- .../src/connection_record.rs | 155 ----------- .../src/connection_tracker.rs | 251 +++--------------- rust/crates/rustproxy-passthrough/src/lib.rs | 2 - .../rustproxy-passthrough/src/tcp_listener.rs | 42 ++- rust/crates/rustproxy/src/lib.rs | 131 ++++++--- test/helpers/port-allocator.ts | 70 +++++ test/test.acme-http01-challenge.ts | 15 +- test/test.connection-forwarding.ts | 40 +-- test/test.forwarding-regression.ts | 14 +- test/test.http-port8080-forwarding.ts | 29 +- test/test.long-lived-connections.ts | 8 +- test/test.metrics-new.ts | 8 +- test/test.perf-improvements.ts | 17 +- test/test.port-forwarding-fix.ts | 9 +- test/test.port-mapping.ts | 89 ++++--- test/test.smartproxy.ts | 41 +-- test/test.socket-handler-race.ts | 8 +- test/test.socket-handler.ts | 25 +- test/test.throughput.ts | 28 +- ts/00_commitinfo_data.ts | 2 +- 24 files changed, 535 insertions(+), 560 deletions(-) delete mode 100644 rust/crates/rustproxy-passthrough/src/connection_record.rs create mode 100644 test/helpers/port-allocator.ts diff --git a/changelog.md b/changelog.md index 30e74a5..93547e5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-24 - 25.7.10 - fix(rustproxy) +Use cooperative cancellation for background tasks, prune stale caches and metric entries, and switch tests to dynamic port allocation to avoid port conflicts + +- Introduce tokio_util::sync::CancellationToken to coordinate graceful shutdown of sampling and renewal tasks; await handles on stop and reset the token so the proxy can be restarted. +- Add safety Drop impls (RustProxy, TcpListenerManager) as a last-resort abort path when stop() is not called. +- MetricsCollector: avoid creating per-IP metric entries when the IP has no active connections; prune orphaned per-IP metric maps during sampling; add tests covering late record_bytes races and pruning behavior. +- Passthrough/ConnectionTracker: remove per-connection record/zombie-scanner complexity, add cleanup_stale_timestamps to prune rate-limit timestamp entries, and add an RAII ConnectionTrackerGuard to guarantee connection_closed is invoked. +- HTTP proxy improvements: add prune_stale_routes and reset_round_robin to clear caches (rate limiters, regex cache, round-robin counters) on route updates. +- Tests: add test/helpers/port-allocator.ts and update many tests to use findFreePorts/assertPortsFree (dynamic ports + post-test port assertions) to avoid flakiness and port collisions in CI. + ## 2026-02-21 - 25.7.9 - fix(tests) use high non-privileged ports in tests to avoid conflicts and CI failures diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 2cb8cce..ddd214f 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -167,6 +167,14 @@ impl HttpProxyService { self.backend_tls_config = config; } + /// Prune caches for route IDs that are no longer active. + /// Call after route updates to prevent unbounded growth. + pub fn prune_stale_routes(&self, active_route_ids: &std::collections::HashSet) { + self.route_rate_limiters.retain(|k, _| active_route_ids.contains(k)); + self.regex_cache.clear(); + self.upstream_selector.reset_round_robin(); + } + /// Handle an incoming HTTP connection on a plain TCP stream. pub async fn handle_connection( self: Arc, diff --git a/rust/crates/rustproxy-http/src/upstream_selector.rs b/rust/crates/rustproxy-http/src/upstream_selector.rs index ba98e1d..60577c0 100644 --- a/rust/crates/rustproxy-http/src/upstream_selector.rs +++ b/rust/crates/rustproxy-http/src/upstream_selector.rs @@ -131,6 +131,14 @@ impl UpstreamSelector { } } + /// Clear stale round-robin counters on route update. + /// Resetting is harmless — counters just restart cycling from index 0. + pub fn reset_round_robin(&self) { + if let Ok(mut counters) = self.round_robin.lock() { + counters.clear(); + } + } + fn ip_hash(addr: &SocketAddr) -> usize { let ip_str = addr.ip().to_string(); let mut hash: usize = 5381; diff --git a/rust/crates/rustproxy-metrics/src/collector.rs b/rust/crates/rustproxy-metrics/src/collector.rs index 9904142..4bc6215 100644 --- a/rust/crates/rustproxy-metrics/src/collector.rs +++ b/rust/crates/rustproxy-metrics/src/collector.rs @@ -239,21 +239,26 @@ impl MetricsCollector { } if let Some(ip) = source_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); + // 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); - // 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); + // 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); + } } } @@ -347,6 +352,15 @@ impl MetricsCollector { tracker.record_bytes(pending_reqs, 0); tracker.sample(); } + + // Safety-net: prune orphaned per-IP entries that have no corresponding + // ip_connections entry. This catches any entries created by a race between + // record_bytes and connection_closed. + self.ip_bytes_in.retain(|k, _| self.ip_connections.contains_key(k)); + self.ip_bytes_out.retain(|k, _| self.ip_connections.contains_key(k)); + self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k)); + self.ip_throughput.retain(|k, _| self.ip_connections.contains_key(k)); + self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k)); } /// Remove per-route metrics for route IDs that are no longer active. @@ -733,6 +747,49 @@ mod tests { assert!(collector.route_total_connections.get("route-c").is_some()); } + #[test] + fn test_record_bytes_after_close_no_orphan() { + let collector = MetricsCollector::with_retention(60); + + // Open a connection, record bytes, then close + collector.connection_opened(Some("route-a"), Some("10.0.0.1")); + collector.record_bytes(100, 200, Some("route-a"), Some("10.0.0.1")); + collector.connection_closed(Some("route-a"), Some("10.0.0.1")); + + // IP should be fully evicted + assert!(collector.ip_connections.get("10.0.0.1").is_none()); + + // Now record_bytes arrives late (simulates race) — should NOT re-create entries + collector.record_bytes(50, 75, Some("route-a"), Some("10.0.0.1")); + assert!(collector.ip_bytes_in.get("10.0.0.1").is_none()); + assert!(collector.ip_bytes_out.get("10.0.0.1").is_none()); + assert!(collector.ip_pending_tp.get("10.0.0.1").is_none()); + + // Global bytes should still be counted + assert_eq!(collector.total_bytes_in.load(Ordering::Relaxed), 150); + assert_eq!(collector.total_bytes_out.load(Ordering::Relaxed), 275); + } + + #[test] + fn test_sample_all_prunes_orphaned_ip_entries() { + let collector = MetricsCollector::with_retention(60); + + // Manually insert orphaned entries (simulates the race before the guard) + collector.ip_bytes_in.insert("orphan-ip".to_string(), AtomicU64::new(100)); + collector.ip_bytes_out.insert("orphan-ip".to_string(), AtomicU64::new(200)); + collector.ip_pending_tp.insert("orphan-ip".to_string(), (AtomicU64::new(0), AtomicU64::new(0))); + + // No ip_connections entry for "orphan-ip" + assert!(collector.ip_connections.get("orphan-ip").is_none()); + + // sample_all should prune the orphans + collector.sample_all(); + + assert!(collector.ip_bytes_in.get("orphan-ip").is_none()); + assert!(collector.ip_bytes_out.get("orphan-ip").is_none()); + assert!(collector.ip_pending_tp.get("orphan-ip").is_none()); + } + #[test] fn test_throughput_history_in_snapshot() { let collector = MetricsCollector::with_retention(60); diff --git a/rust/crates/rustproxy-passthrough/src/connection_record.rs b/rust/crates/rustproxy-passthrough/src/connection_record.rs deleted file mode 100644 index 3913565..0000000 --- a/rust/crates/rustproxy-passthrough/src/connection_record.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -/// Per-connection tracking record with atomics for lock-free updates. -/// -/// Each field uses atomics so that the forwarding tasks can update -/// bytes_received / bytes_sent / last_activity without holding any lock, -/// while the zombie scanner reads them concurrently. -pub struct ConnectionRecord { - /// Unique connection ID assigned by the ConnectionTracker. - pub id: u64, - /// Wall-clock instant when this connection was created. - pub created_at: Instant, - /// Milliseconds since `created_at` when the last activity occurred. - /// Updated atomically by the forwarding loops. - pub last_activity: AtomicU64, - /// Total bytes received from the client (inbound). - pub bytes_received: AtomicU64, - /// Total bytes sent to the client (outbound / from backend). - pub bytes_sent: AtomicU64, - /// True once the client side of the connection has closed. - pub client_closed: AtomicBool, - /// True once the backend side of the connection has closed. - pub backend_closed: AtomicBool, - /// Whether this connection uses TLS (affects zombie thresholds). - pub is_tls: AtomicBool, - /// Whether this connection has keep-alive semantics. - pub has_keep_alive: AtomicBool, -} - -impl ConnectionRecord { - /// Create a new connection record with the given ID. - /// All counters start at zero, all flags start as false. - pub fn new(id: u64) -> Self { - Self { - id, - created_at: Instant::now(), - last_activity: AtomicU64::new(0), - bytes_received: AtomicU64::new(0), - bytes_sent: AtomicU64::new(0), - client_closed: AtomicBool::new(false), - backend_closed: AtomicBool::new(false), - is_tls: AtomicBool::new(false), - has_keep_alive: AtomicBool::new(false), - } - } - - /// Update `last_activity` to reflect the current elapsed time. - pub fn touch(&self) { - let elapsed_ms = self.created_at.elapsed().as_millis() as u64; - self.last_activity.store(elapsed_ms, Ordering::Relaxed); - } - - /// Record `n` bytes received from the client (inbound). - pub fn record_bytes_in(&self, n: u64) { - self.bytes_received.fetch_add(n, Ordering::Relaxed); - self.touch(); - } - - /// Record `n` bytes sent to the client (outbound / from backend). - pub fn record_bytes_out(&self, n: u64) { - self.bytes_sent.fetch_add(n, Ordering::Relaxed); - self.touch(); - } - - /// How long since the last activity on this connection. - pub fn idle_duration(&self) -> Duration { - let last_ms = self.last_activity.load(Ordering::Relaxed); - let age_ms = self.created_at.elapsed().as_millis() as u64; - Duration::from_millis(age_ms.saturating_sub(last_ms)) - } - - /// Total age of this connection (time since creation). - pub fn age(&self) -> Duration { - self.created_at.elapsed() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread; - - #[test] - fn test_new_record() { - let record = ConnectionRecord::new(42); - assert_eq!(record.id, 42); - assert_eq!(record.bytes_received.load(Ordering::Relaxed), 0); - assert_eq!(record.bytes_sent.load(Ordering::Relaxed), 0); - assert!(!record.client_closed.load(Ordering::Relaxed)); - assert!(!record.backend_closed.load(Ordering::Relaxed)); - assert!(!record.is_tls.load(Ordering::Relaxed)); - assert!(!record.has_keep_alive.load(Ordering::Relaxed)); - } - - #[test] - fn test_record_bytes() { - let record = ConnectionRecord::new(1); - record.record_bytes_in(100); - record.record_bytes_in(200); - assert_eq!(record.bytes_received.load(Ordering::Relaxed), 300); - - record.record_bytes_out(50); - record.record_bytes_out(75); - assert_eq!(record.bytes_sent.load(Ordering::Relaxed), 125); - } - - #[test] - fn test_touch_updates_activity() { - let record = ConnectionRecord::new(1); - assert_eq!(record.last_activity.load(Ordering::Relaxed), 0); - - // Sleep briefly so elapsed time is nonzero - thread::sleep(Duration::from_millis(10)); - record.touch(); - - let activity = record.last_activity.load(Ordering::Relaxed); - assert!(activity >= 10, "last_activity should be at least 10ms, got {}", activity); - } - - #[test] - fn test_idle_duration() { - let record = ConnectionRecord::new(1); - // Initially idle_duration ~ age since last_activity is 0 - thread::sleep(Duration::from_millis(20)); - let idle = record.idle_duration(); - assert!(idle >= Duration::from_millis(20)); - - // After touch, idle should be near zero - record.touch(); - let idle = record.idle_duration(); - assert!(idle < Duration::from_millis(10)); - } - - #[test] - fn test_age() { - let record = ConnectionRecord::new(1); - thread::sleep(Duration::from_millis(20)); - let age = record.age(); - assert!(age >= Duration::from_millis(20)); - } - - #[test] - fn test_flags() { - let record = ConnectionRecord::new(1); - record.client_closed.store(true, Ordering::Relaxed); - record.is_tls.store(true, Ordering::Relaxed); - record.has_keep_alive.store(true, Ordering::Relaxed); - - assert!(record.client_closed.load(Ordering::Relaxed)); - assert!(!record.backend_closed.load(Ordering::Relaxed)); - assert!(record.is_tls.load(Ordering::Relaxed)); - assert!(record.has_keep_alive.load(Ordering::Relaxed)); - } -} diff --git a/rust/crates/rustproxy-passthrough/src/connection_tracker.rs b/rust/crates/rustproxy-passthrough/src/connection_tracker.rs index 069a621..f61e2c1 100644 --- a/rust/crates/rustproxy-passthrough/src/connection_tracker.rs +++ b/rust/crates/rustproxy-passthrough/src/connection_tracker.rs @@ -2,24 +2,9 @@ use dashmap::DashMap; use std::collections::VecDeque; use std::net::IpAddr; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; - -use super::connection_record::ConnectionRecord; - -/// Thresholds for zombie detection (non-TLS connections). -const HALF_ZOMBIE_TIMEOUT_PLAIN: Duration = Duration::from_secs(30); -/// Thresholds for zombie detection (TLS connections). -const HALF_ZOMBIE_TIMEOUT_TLS: Duration = Duration::from_secs(300); -/// Stuck connection timeout (non-TLS): received data but never sent any. -const STUCK_TIMEOUT_PLAIN: Duration = Duration::from_secs(60); -/// Stuck connection timeout (TLS): received data but never sent any. -const STUCK_TIMEOUT_TLS: Duration = Duration::from_secs(300); /// Tracks active connections per IP and enforces per-IP limits and rate limiting. -/// Also maintains per-connection records for zombie detection. pub struct ConnectionTracker { /// Active connection counts per IP active: DashMap, @@ -29,10 +14,6 @@ pub struct ConnectionTracker { max_per_ip: Option, /// Maximum new connections per minute per IP (None = unlimited) rate_limit_per_minute: Option, - /// Per-connection tracking records for zombie detection - connections: DashMap>, - /// Monotonically increasing connection ID counter - next_id: AtomicU64, } impl ConnectionTracker { @@ -42,8 +23,6 @@ impl ConnectionTracker { timestamps: DashMap::new(), max_per_ip, rate_limit_per_minute, - connections: DashMap::new(), - next_id: AtomicU64::new(1), } } @@ -112,118 +91,27 @@ impl ConnectionTracker { .unwrap_or(0) } + /// Prune stale timestamp entries for IPs that have no active connections + /// and no recent timestamps. This cleans up entries left by rate-limited IPs + /// that never had connection_opened called. + pub fn cleanup_stale_timestamps(&self) { + if self.rate_limit_per_minute.is_none() { + return; // No rate limiting — timestamps map should be empty + } + let now = Instant::now(); + let one_minute = Duration::from_secs(60); + self.timestamps.retain(|ip, timestamps| { + timestamps.retain(|t| now.duration_since(*t) < one_minute); + // Keep if there are active connections or recent timestamps + !timestamps.is_empty() || self.active.contains_key(ip) + }); + } + /// Get the total number of tracked IPs. pub fn tracked_ips(&self) -> usize { self.active.len() } - /// Register a new connection and return its tracking record. - /// - /// The returned `Arc` should be passed to the forwarding - /// loop so it can update bytes / activity atomics in real time. - pub fn register_connection(&self, is_tls: bool) -> Arc { - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let record = Arc::new(ConnectionRecord::new(id)); - record.is_tls.store(is_tls, Ordering::Relaxed); - self.connections.insert(id, Arc::clone(&record)); - record - } - - /// Remove a connection record when the connection is fully closed. - pub fn unregister_connection(&self, id: u64) { - self.connections.remove(&id); - } - - /// Scan all tracked connections and return IDs of zombie connections. - /// - /// A connection is considered a zombie in any of these cases: - /// - **Full zombie**: both `client_closed` and `backend_closed` are true. - /// - **Half zombie**: one side closed for longer than the threshold - /// (5 min for TLS, 30s for non-TLS). - /// - **Stuck**: `bytes_received > 0` but `bytes_sent == 0` for longer - /// than the stuck threshold (5 min for TLS, 60s for non-TLS). - pub fn scan_zombies(&self) -> Vec { - let mut zombies = Vec::new(); - - for entry in self.connections.iter() { - let record = entry.value(); - let id = *entry.key(); - let is_tls = record.is_tls.load(Ordering::Relaxed); - let client_closed = record.client_closed.load(Ordering::Relaxed); - let backend_closed = record.backend_closed.load(Ordering::Relaxed); - let idle = record.idle_duration(); - let bytes_in = record.bytes_received.load(Ordering::Relaxed); - let bytes_out = record.bytes_sent.load(Ordering::Relaxed); - - // Full zombie: both sides closed - if client_closed && backend_closed { - zombies.push(id); - continue; - } - - // Half zombie: one side closed for too long - let half_timeout = if is_tls { - HALF_ZOMBIE_TIMEOUT_TLS - } else { - HALF_ZOMBIE_TIMEOUT_PLAIN - }; - - if (client_closed || backend_closed) && idle >= half_timeout { - zombies.push(id); - continue; - } - - // Stuck: received data but never sent anything for too long - let stuck_timeout = if is_tls { - STUCK_TIMEOUT_TLS - } else { - STUCK_TIMEOUT_PLAIN - }; - - if bytes_in > 0 && bytes_out == 0 && idle >= stuck_timeout { - zombies.push(id); - } - } - - zombies - } - - /// Start a background task that periodically scans for zombie connections. - /// - /// The scanner runs every 10 seconds and logs any zombies it finds. - /// It stops when the provided `CancellationToken` is cancelled. - pub fn start_zombie_scanner(self: &Arc, cancel: CancellationToken) { - let tracker = Arc::clone(self); - tokio::spawn(async move { - let interval = Duration::from_secs(10); - loop { - tokio::select! { - _ = cancel.cancelled() => { - debug!("Zombie scanner shutting down"); - break; - } - _ = tokio::time::sleep(interval) => { - let zombies = tracker.scan_zombies(); - if !zombies.is_empty() { - warn!( - "Cleaning up {} zombie connection(s): {:?}", - zombies.len(), - zombies - ); - for id in &zombies { - tracker.unregister_connection(*id); - } - } - } - } - } - }); - } - - /// Get the total number of tracked connections (with records). - pub fn total_connections(&self) -> usize { - self.connections.len() - } } #[cfg(test)] @@ -333,98 +221,27 @@ mod tests { } #[test] - fn test_register_unregister_connection() { - let tracker = ConnectionTracker::new(None, None); - assert_eq!(tracker.total_connections(), 0); + fn test_cleanup_stale_timestamps() { + // Rate limit of 100/min so timestamps are tracked + let tracker = ConnectionTracker::new(None, Some(100)); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); - let record1 = tracker.register_connection(false); - assert_eq!(tracker.total_connections(), 1); - assert!(!record1.is_tls.load(Ordering::Relaxed)); + // try_accept adds a timestamp entry + assert!(tracker.try_accept(&ip)); - let record2 = tracker.register_connection(true); - assert_eq!(tracker.total_connections(), 2); - assert!(record2.is_tls.load(Ordering::Relaxed)); + // Simulate: connection was rate-limited and never accepted, + // so no connection_opened / connection_closed pair + assert!(tracker.timestamps.get(&ip).is_some()); + assert!(tracker.active.get(&ip).is_none()); // never opened - // IDs should be unique - assert_ne!(record1.id, record2.id); + // Cleanup won't remove it yet because timestamp is recent + tracker.cleanup_stale_timestamps(); + assert!(tracker.timestamps.get(&ip).is_some()); - tracker.unregister_connection(record1.id); - assert_eq!(tracker.total_connections(), 1); - - tracker.unregister_connection(record2.id); - assert_eq!(tracker.total_connections(), 0); - } - - #[test] - fn test_full_zombie_detection() { - let tracker = ConnectionTracker::new(None, None); - let record = tracker.register_connection(false); - - // Not a zombie initially - assert!(tracker.scan_zombies().is_empty()); - - // Set both sides closed -> full zombie - record.client_closed.store(true, Ordering::Relaxed); - record.backend_closed.store(true, Ordering::Relaxed); - - let zombies = tracker.scan_zombies(); - assert_eq!(zombies.len(), 1); - assert_eq!(zombies[0], record.id); - } - - #[test] - fn test_half_zombie_not_triggered_immediately() { - let tracker = ConnectionTracker::new(None, None); - let record = tracker.register_connection(false); - record.touch(); // mark activity now - - // Only one side closed, but just now -> not a zombie yet - record.client_closed.store(true, Ordering::Relaxed); - assert!(tracker.scan_zombies().is_empty()); - } - - #[test] - fn test_stuck_connection_not_triggered_immediately() { - let tracker = ConnectionTracker::new(None, None); - let record = tracker.register_connection(false); - record.touch(); // mark activity now - - // Has received data but sent nothing -> but just started, not stuck yet - record.bytes_received.store(1000, Ordering::Relaxed); - assert!(tracker.scan_zombies().is_empty()); - } - - #[test] - fn test_unregister_removes_from_zombie_scan() { - let tracker = ConnectionTracker::new(None, None); - let record = tracker.register_connection(false); - let id = record.id; - - // Make it a full zombie - record.client_closed.store(true, Ordering::Relaxed); - record.backend_closed.store(true, Ordering::Relaxed); - assert_eq!(tracker.scan_zombies().len(), 1); - - // Unregister should remove it - tracker.unregister_connection(id); - assert!(tracker.scan_zombies().is_empty()); - } - - #[test] - fn test_total_connections() { - let tracker = ConnectionTracker::new(None, None); - assert_eq!(tracker.total_connections(), 0); - - let r1 = tracker.register_connection(false); - let r2 = tracker.register_connection(true); - let r3 = tracker.register_connection(false); - assert_eq!(tracker.total_connections(), 3); - - tracker.unregister_connection(r2.id); - assert_eq!(tracker.total_connections(), 2); - - tracker.unregister_connection(r1.id); - tracker.unregister_connection(r3.id); - assert_eq!(tracker.total_connections(), 0); + // After expiry (use 0-second window trick: create tracker with 0 rate) + // Actually, we can't fast-forward time easily, so just verify the cleanup + // doesn't panic and handles the no-rate-limit case + let tracker2 = ConnectionTracker::new(None, None); + tracker2.cleanup_stale_timestamps(); // should be a no-op } } diff --git a/rust/crates/rustproxy-passthrough/src/lib.rs b/rust/crates/rustproxy-passthrough/src/lib.rs index df37104..6d2387d 100644 --- a/rust/crates/rustproxy-passthrough/src/lib.rs +++ b/rust/crates/rustproxy-passthrough/src/lib.rs @@ -8,7 +8,6 @@ pub mod sni_parser; pub mod forwarder; pub mod proxy_protocol; pub mod tls_handler; -pub mod connection_record; pub mod connection_tracker; pub mod socket_relay; pub mod socket_opts; @@ -18,7 +17,6 @@ pub use sni_parser::*; pub use forwarder::*; pub use proxy_protocol::*; pub use tls_handler::*; -pub use connection_record::*; pub use connection_tracker::*; pub use socket_relay::*; pub use socket_opts::*; diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index cc8323e..5e0b91d 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -41,6 +41,25 @@ impl Drop for ConnectionGuard { } } +/// RAII guard that calls ConnectionTracker::connection_closed on drop. +/// Ensures per-IP tracking is cleaned up on ALL exit paths — normal, error, or panic. +struct ConnectionTrackerGuard { + tracker: Arc, + ip: std::net::IpAddr, +} + +impl ConnectionTrackerGuard { + fn new(tracker: Arc, ip: std::net::IpAddr) -> Self { + Self { tracker, ip } + } +} + +impl Drop for ConnectionTrackerGuard { + fn drop(&mut self) { + self.tracker.connection_closed(&self.ip); + } +} + #[derive(Debug, Error)] pub enum ListenerError { #[error("Failed to bind port {port}: {source}")] @@ -351,6 +370,16 @@ impl TcpListenerManager { self.route_manager.store(route_manager); } + /// Prune HTTP proxy caches for route IDs that are no longer active. + pub fn prune_http_proxy_caches(&self, active_route_ids: &std::collections::HashSet) { + self.http_proxy.prune_stale_routes(active_route_ids); + } + + /// Get a reference to the connection tracker. + pub fn conn_tracker(&self) -> &Arc { + &self.conn_tracker + } + /// Get a reference to the metrics collector. pub fn metrics(&self) -> &Arc { &self.metrics @@ -429,13 +458,14 @@ impl TcpListenerManager { tokio::spawn(async move { // Move permit into the task — auto-releases on drop let _permit = permit; + // RAII guard ensures connection_closed is called on all paths + let _ct_guard = ConnectionTrackerGuard::new(ct, ip); let result = Self::handle_connection( stream, port, peer_addr, rm, m, tc, sa, hp, cc, cn, sr, ).await; if let Err(e) = result { debug!("Connection error from {}: {}", peer_addr, e); } - ct.connection_closed(&ip); }); } Err(e) => { @@ -1372,3 +1402,13 @@ impl TcpListenerManager { (bytes_in, bytes_out) } } + +/// Safety net: cancel and abort all listener tasks if dropped without graceful_stop(). +impl Drop for TcpListenerManager { + fn drop(&mut self) { + self.cancel_token.cancel(); + for (_, handle) in self.listeners.drain() { + handle.abort(); + } + } +} diff --git a/rust/crates/rustproxy/src/lib.rs b/rust/crates/rustproxy/src/lib.rs index d9bc552..408549a 100644 --- a/rust/crates/rustproxy/src/lib.rs +++ b/rust/crates/rustproxy/src/lib.rs @@ -51,6 +51,7 @@ use rustproxy_passthrough::{TcpListenerManager, TlsCertConfig, ConnectionConfig} use rustproxy_metrics::{MetricsCollector, Metrics, Statistics}; use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource}; use rustproxy_nftables::{NftManager, rule_builder}; +use tokio_util::sync::CancellationToken; /// Certificate status. #[derive(Debug, Clone)] @@ -79,6 +80,8 @@ pub struct RustProxy { socket_handler_relay: Arc>>, /// Dynamically loaded certificates (via loadCertificate IPC), independent of CertManager. loaded_certs: HashMap, + /// Cancellation token for cooperative shutdown of background tasks. + cancel_token: CancellationToken, } impl RustProxy { @@ -121,6 +124,7 @@ impl RustProxy { started_at: None, socket_handler_relay: Arc::new(std::sync::RwLock::new(None)), loaded_certs: HashMap::new(), + cancel_token: CancellationToken::new(), }) } @@ -299,18 +303,26 @@ impl RustProxy { self.started = true; self.started_at = Some(Instant::now()); - // Start the throughput sampling task + // Start the throughput sampling task with cooperative cancellation let metrics = Arc::clone(&self.metrics); + let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone(); let interval_ms = self.options.metrics.as_ref() .and_then(|m| m.sample_interval_ms) .unwrap_or(1000); + let sampling_cancel = self.cancel_token.clone(); self.sampling_handle = Some(tokio::spawn(async move { let mut interval = tokio::time::interval( std::time::Duration::from_millis(interval_ms) ); loop { - interval.tick().await; - metrics.sample_all(); + tokio::select! { + _ = sampling_cancel.cancelled() => break, + _ = interval.tick() => { + metrics.sample_all(); + // Periodically clean up stale rate-limit timestamp entries + conn_tracker.cleanup_stale_timestamps(); + } + } } })); @@ -457,51 +469,59 @@ impl RustProxy { .unwrap_or(80); let interval = std::time::Duration::from_secs(check_interval_hours as u64 * 3600); + let renewal_cancel = self.cancel_token.clone(); let handle = tokio::spawn(async move { loop { - tokio::time::sleep(interval).await; - debug!("Certificate renewal check triggered (interval: {}h)", check_interval_hours); + tokio::select! { + _ = renewal_cancel.cancelled() => { + debug!("Renewal timer shutting down"); + break; + } + _ = tokio::time::sleep(interval) => { + debug!("Certificate renewal check triggered (interval: {}h)", check_interval_hours); - // Check which domains need renewal - let domains = { - let cm = cm_arc.lock().await; - cm.check_renewals() - }; + // Check which domains need renewal + let domains = { + let cm = cm_arc.lock().await; + cm.check_renewals() + }; - if domains.is_empty() { - debug!("No certificates need renewal"); - continue; - } - - info!("Renewing {} certificate(s)", domains.len()); - - // Start challenge server for renewals - let mut cs = challenge_server::ChallengeServer::new(); - if let Err(e) = cs.start(acme_port).await { - error!("Failed to start challenge server for renewal: {}", e); - continue; - } - - for domain in &domains { - let cs_ref = &cs; - let mut cm = cm_arc.lock().await; - let result = cm.renew_domain(domain, |token, key_auth| { - cs_ref.set_challenge(token, key_auth); - async {} - }).await; - - match result { - Ok(_bundle) => { - info!("Successfully renewed certificate for {}", domain); + if domains.is_empty() { + debug!("No certificates need renewal"); + continue; } - Err(e) => { - error!("Failed to renew certificate for {}: {}", domain, e); + + info!("Renewing {} certificate(s)", domains.len()); + + // Start challenge server for renewals + let mut cs = challenge_server::ChallengeServer::new(); + if let Err(e) = cs.start(acme_port).await { + error!("Failed to start challenge server for renewal: {}", e); + continue; } + + for domain in &domains { + let cs_ref = &cs; + let mut cm = cm_arc.lock().await; + let result = cm.renew_domain(domain, |token, key_auth| { + cs_ref.set_challenge(token, key_auth); + async {} + }).await; + + match result { + Ok(_bundle) => { + info!("Successfully renewed certificate for {}", domain); + } + Err(e) => { + error!("Failed to renew certificate for {}: {}", domain, e); + } + } + } + + cs.stop().await; } } - - cs.stop().await; } }); @@ -516,14 +536,17 @@ impl RustProxy { info!("Stopping RustProxy..."); - // Stop sampling task + // Signal all background tasks to stop cooperatively + self.cancel_token.cancel(); + + // Await sampling task (cooperative shutdown) if let Some(handle) = self.sampling_handle.take() { - handle.abort(); + let _ = handle.await; } - // Stop renewal timer + // Await renewal timer (cooperative shutdown) if let Some(handle) = self.renewal_handle.take() { - handle.abort(); + let _ = handle.await; } // Stop challenge server if running @@ -545,6 +568,8 @@ impl RustProxy { } self.listener_manager = None; self.started = false; + // Reset cancel token so proxy can be restarted + self.cancel_token = CancellationToken::new(); info!("RustProxy stopped"); Ok(()) @@ -585,6 +610,8 @@ impl RustProxy { // Update listener manager if let Some(ref mut listener) = self.listener_manager { listener.update_route_manager(Arc::clone(&new_manager)); + // Prune HTTP proxy caches (rate limiters, regex cache, round-robin counters) + listener.prune_http_proxy_caches(&active_route_ids); // Update TLS configs let mut tls_configs = Self::extract_tls_configs(&routes); @@ -983,3 +1010,21 @@ impl RustProxy { configs } } + +/// Safety net: abort background tasks if RustProxy is dropped without calling stop(). +/// Normal shutdown should still use stop() for graceful behavior. +impl Drop for RustProxy { + fn drop(&mut self) { + self.cancel_token.cancel(); + if let Some(handle) = self.sampling_handle.take() { + handle.abort(); + } + if let Some(handle) = self.renewal_handle.take() { + handle.abort(); + } + // Cancel the listener manager's token and abort accept loops + if let Some(ref mut listener) = self.listener_manager { + listener.stop_all(); + } + } +} diff --git a/test/helpers/port-allocator.ts b/test/helpers/port-allocator.ts new file mode 100644 index 0000000..fb8c0c1 --- /dev/null +++ b/test/helpers/port-allocator.ts @@ -0,0 +1,70 @@ +import * as net from 'net'; + +/** + * Finds `count` free ports by binding to port 0 and reading the OS-assigned port. + * All servers are opened simultaneously to guarantee uniqueness. + * Returns an array of guaranteed-free ports. + */ +export async function findFreePorts(count: number): Promise { + const servers: net.Server[] = []; + const ports: number[] = []; + + // Open all servers simultaneously on port 0 + await Promise.all( + Array.from({ length: count }, () => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo; + ports.push(addr.port); + servers.push(server); + resolve(); + }); + server.on('error', reject); + }) + ) + ); + + // Close all servers + await Promise.all( + servers.map( + (server) => new Promise((resolve) => server.close(() => resolve())) + ) + ); + + return ports; +} + +/** + * Verifies that all given ports are free (not listening). + * Useful as a cleanup assertion at the end of tests. + * Throws if any port is still in use. + */ +export async function assertPortsFree(ports: number[]): Promise { + const results = await Promise.all( + ports.map( + (port) => + new Promise<{ port: number; free: boolean }>((resolve) => { + const client = net.connect({ port, host: '127.0.0.1' }); + client.on('connect', () => { + client.destroy(); + resolve({ port, free: false }); + }); + client.on('error', () => { + resolve({ port, free: true }); + }); + client.setTimeout(1000, () => { + client.destroy(); + resolve({ port, free: true }); + }); + }) + ) + ); + + const occupied = results.filter((r) => !r.free); + if (occupied.length > 0) { + throw new Error( + `Ports still in use after cleanup: ${occupied.map((r) => r.port).join(', ')}` + ); + } +} diff --git a/test/test.acme-http01-challenge.ts b/test/test.acme-http01-challenge.ts index 94fa897..29e27ec 100644 --- a/test/test.acme-http01-challenge.ts +++ b/test/test.acme-http01-challenge.ts @@ -1,9 +1,12 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { SmartProxy, SocketHandlers } from '../ts/index.js'; import * as net from 'net'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; // Test that HTTP-01 challenges are properly processed when the initial data arrives tap.test('should correctly handle HTTP-01 challenge requests with initial data chunk', async (tapTest) => { + const [PORT] = await findFreePorts(1); + // Prepare test data const challengeToken = 'test-acme-http01-challenge-token'; const challengeResponse = 'mock-response-for-challenge'; @@ -37,7 +40,7 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c routes: [{ name: 'acme-challenge-route', match: { - ports: 47700, + ports: PORT, path: '/.well-known/acme-challenge/*' }, action: { @@ -60,7 +63,7 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c // Connect to the proxy and send the HTTP-01 challenge request await new Promise((resolve, reject) => { - testClient.connect(47700, 'localhost', () => { + testClient.connect(PORT, 'localhost', () => { // Send HTTP request for the challenge token testClient.write( `GET ${challengePath} HTTP/1.1\r\n` + @@ -86,10 +89,13 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c // Cleanup testClient.destroy(); await proxy.stop(); + await assertPortsFree([PORT]); }); // Test that non-existent challenge tokens return 404 tap.test('should return 404 for non-existent challenge tokens', async (tapTest) => { + const [PORT] = await findFreePorts(1); + // Create a socket handler that behaves like a real ACME handler const acmeHandler = SocketHandlers.httpServer((req, res) => { if (req.url?.startsWith('/.well-known/acme-challenge/')) { @@ -113,7 +119,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest) routes: [{ name: 'acme-challenge-route', match: { - ports: 47701, + ports: PORT, path: '/.well-known/acme-challenge/*' }, action: { @@ -135,7 +141,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest) // Connect and send a request for a non-existent token await new Promise((resolve, reject) => { - testClient.connect(47701, 'localhost', () => { + testClient.connect(PORT, 'localhost', () => { testClient.write( 'GET /.well-known/acme-challenge/invalid-token HTTP/1.1\r\n' + 'Host: test.example.com\r\n' + @@ -157,6 +163,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest) // Cleanup testClient.destroy(); await proxy.stop(); + await assertPortsFree([PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.connection-forwarding.ts b/test/test.connection-forwarding.ts index c56bce5..88a00c8 100644 --- a/test/test.connection-forwarding.ts +++ b/test/test.connection-forwarding.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; // Setup test infrastructure const testCertPath = path.join(process.cwd(), 'test', 'helpers', 'test-cert.pem'); @@ -13,8 +14,14 @@ const testKeyPath = path.join(process.cwd(), 'test', 'helpers', 'test-key.pem'); let testServer: net.Server; let tlsTestServer: tls.Server; let smartProxy: SmartProxy; +let PROXY_TCP_PORT: number; +let PROXY_TLS_PORT: number; +let TCP_SERVER_PORT: number; +let TLS_SERVER_PORT: number; tap.test('setup test servers', async () => { + [PROXY_TCP_PORT, PROXY_TLS_PORT, TCP_SERVER_PORT, TLS_SERVER_PORT] = await findFreePorts(4); + // Create TCP test server testServer = net.createServer((socket) => { socket.write('Connected to TCP test server\n'); @@ -24,8 +31,8 @@ tap.test('setup test servers', async () => { }); await new Promise((resolve) => { - testServer.listen(47712, '127.0.0.1', () => { - console.log('TCP test server listening on port 47712'); + testServer.listen(TCP_SERVER_PORT, '127.0.0.1', () => { + console.log(`TCP test server listening on port ${TCP_SERVER_PORT}`); resolve(); }); }); @@ -45,8 +52,8 @@ tap.test('setup test servers', async () => { ); await new Promise((resolve) => { - tlsTestServer.listen(47713, '127.0.0.1', () => { - console.log('TLS test server listening on port 47713'); + tlsTestServer.listen(TLS_SERVER_PORT, '127.0.0.1', () => { + console.log(`TLS test server listening on port ${TLS_SERVER_PORT}`); resolve(); }); }); @@ -60,13 +67,13 @@ tap.test('should forward TCP connections correctly', async () => { { name: 'tcp-forward', match: { - ports: 47710, + ports: PROXY_TCP_PORT, }, action: { type: 'forward', targets: [{ host: '127.0.0.1', - port: 47712, + port: TCP_SERVER_PORT, }], }, }, @@ -77,7 +84,7 @@ tap.test('should forward TCP connections correctly', async () => { // Test TCP forwarding const client = await new Promise((resolve, reject) => { - const socket = net.connect(47710, '127.0.0.1', () => { + const socket = net.connect(PROXY_TCP_PORT, '127.0.0.1', () => { console.log('Connected to proxy'); resolve(socket); }); @@ -106,7 +113,7 @@ tap.test('should handle TLS passthrough correctly', async () => { { name: 'tls-passthrough', match: { - ports: 47711, + ports: PROXY_TLS_PORT, domains: 'test.example.com', }, action: { @@ -116,7 +123,7 @@ tap.test('should handle TLS passthrough correctly', async () => { }, targets: [{ host: '127.0.0.1', - port: 47713, + port: TLS_SERVER_PORT, }], }, }, @@ -129,7 +136,7 @@ tap.test('should handle TLS passthrough correctly', async () => { const client = await new Promise((resolve, reject) => { const socket = tls.connect( { - port: 47711, + port: PROXY_TLS_PORT, host: '127.0.0.1', servername: 'test.example.com', rejectUnauthorized: false, @@ -164,7 +171,7 @@ tap.test('should handle SNI-based forwarding', async () => { { name: 'domain-a', match: { - ports: 47711, + ports: PROXY_TLS_PORT, domains: 'a.example.com', }, action: { @@ -174,14 +181,14 @@ tap.test('should handle SNI-based forwarding', async () => { }, targets: [{ host: '127.0.0.1', - port: 47713, + port: TLS_SERVER_PORT, }], }, }, { name: 'domain-b', match: { - ports: 47711, + ports: PROXY_TLS_PORT, domains: 'b.example.com', }, action: { @@ -191,7 +198,7 @@ tap.test('should handle SNI-based forwarding', async () => { }, targets: [{ host: '127.0.0.1', - port: 47713, + port: TLS_SERVER_PORT, }], }, }, @@ -204,7 +211,7 @@ tap.test('should handle SNI-based forwarding', async () => { const clientA = await new Promise((resolve, reject) => { const socket = tls.connect( { - port: 47711, + port: PROXY_TLS_PORT, host: '127.0.0.1', servername: 'a.example.com', rejectUnauthorized: false, @@ -231,7 +238,7 @@ tap.test('should handle SNI-based forwarding', async () => { const clientB = await new Promise((resolve, reject) => { const socket = tls.connect( { - port: 47711, + port: PROXY_TLS_PORT, host: '127.0.0.1', servername: 'b.example.com', rejectUnauthorized: false, @@ -261,6 +268,7 @@ tap.test('should handle SNI-based forwarding', async () => { tap.test('cleanup', async () => { testServer.close(); tlsTestServer.close(); + await assertPortsFree([PROXY_TCP_PORT, PROXY_TLS_PORT, TCP_SERVER_PORT, TLS_SERVER_PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.forwarding-regression.ts b/test/test.forwarding-regression.ts index 5556c1b..b3bc8ce 100644 --- a/test/test.forwarding-regression.ts +++ b/test/test.forwarding-regression.ts @@ -1,9 +1,12 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; // Test to verify port forwarding works correctly tap.test('forward connections should not be immediately closed', async (t) => { + const [PROXY_PORT, SERVER_PORT] = await findFreePorts(2); + // Create a backend server that accepts connections const testServer = net.createServer((socket) => { console.log('Client connected to test server'); @@ -21,8 +24,8 @@ tap.test('forward connections should not be immediately closed', async (t) => { // Listen on a non-privileged port await new Promise((resolve) => { - testServer.listen(47721, '127.0.0.1', () => { - console.log('Test server listening on port 47721'); + testServer.listen(SERVER_PORT, '127.0.0.1', () => { + console.log(`Test server listening on port ${SERVER_PORT}`); resolve(); }); }); @@ -34,13 +37,13 @@ tap.test('forward connections should not be immediately closed', async (t) => { { name: 'forward-test', match: { - ports: 47720, + ports: PROXY_PORT, }, action: { type: 'forward', targets: [{ host: '127.0.0.1', - port: 47721, + port: SERVER_PORT, }], }, }, @@ -51,7 +54,7 @@ tap.test('forward connections should not be immediately closed', async (t) => { // Create a client connection through the proxy const client = net.createConnection({ - port: 47720, + port: PROXY_PORT, host: '127.0.0.1', }); @@ -105,6 +108,7 @@ tap.test('forward connections should not be immediately closed', async (t) => { client.end(); await smartProxy.stop(); testServer.close(); + await assertPortsFree([PROXY_PORT, SERVER_PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.http-port8080-forwarding.ts b/test/test.http-port8080-forwarding.ts index c6ae61b..243cfe6 100644 --- a/test/test.http-port8080-forwarding.ts +++ b/test/test.http-port8080-forwarding.ts @@ -1,10 +1,13 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { SmartProxy } from '../ts/index.js'; import * as http from 'http'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; tap.test('should forward HTTP connections on port 8080', async (tapTest) => { + const [PROXY_PORT, TARGET_PORT] = await findFreePorts(2); + // Create a mock HTTP server to act as our target - const targetPort = 47732; + const targetPort = TARGET_PORT; let receivedRequest = false; let receivedPath = ''; @@ -36,7 +39,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => { routes: [{ name: 'test-route', match: { - ports: 47730 + ports: PROXY_PORT // Remove domain restriction for HTTP connections // Domain matching happens after HTTP headers are received }, @@ -46,16 +49,16 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => { } }] }); - + await proxy.start(); - + // Give the proxy a moment to fully initialize await new Promise(resolve => setTimeout(resolve, 500)); - + // Make an HTTP request to port 8080 const options = { hostname: 'localhost', - port: 47730, + port: PROXY_PORT, path: '/.well-known/acme-challenge/test-token', method: 'GET', headers: { @@ -97,14 +100,17 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => { await new Promise((resolve) => { targetServer.close(() => resolve()); }); - + // Wait a bit to ensure port is fully released await new Promise(resolve => setTimeout(resolve, 500)); + await assertPortsFree([PROXY_PORT, TARGET_PORT]); }); tap.test('should handle basic HTTP request forwarding', async (tapTest) => { + const [PROXY_PORT, TARGET_PORT] = await findFreePorts(2); + // Create a simple target server - const targetPort = 47733; + const targetPort = TARGET_PORT; let receivedRequest = false; const targetServer = http.createServer((req, res) => { @@ -126,7 +132,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => { routes: [{ name: 'simple-forward', match: { - ports: 47731 + ports: PROXY_PORT // Remove domain restriction for HTTP connections }, action: { @@ -142,7 +148,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => { // Make request const options = { hostname: 'localhost', - port: 47731, + port: PROXY_PORT, path: '/test', method: 'GET', headers: { @@ -184,9 +190,10 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => { await new Promise((resolve) => { targetServer.close(() => resolve()); }); - + // Wait a bit to ensure port is fully released await new Promise(resolve => setTimeout(resolve, 500)); + await assertPortsFree([PROXY_PORT, TARGET_PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.long-lived-connections.ts b/test/test.long-lived-connections.ts index b7fa551..33bac7b 100644 --- a/test/test.long-lived-connections.ts +++ b/test/test.long-lived-connections.ts @@ -2,15 +2,17 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import * as tls from 'tls'; import { SmartProxy } from '../ts/index.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; let testProxy: SmartProxy; let targetServer: net.Server; -const ECHO_PORT = 47200; -const PROXY_PORT = 47201; +let ECHO_PORT: number; +let PROXY_PORT: number; // Create a simple echo server as target tap.test('setup test environment', async () => { + [ECHO_PORT, PROXY_PORT] = await findFreePorts(2); // Create target server that echoes data back targetServer = net.createServer((socket) => { console.log('Target server: client connected'); @@ -148,6 +150,8 @@ tap.test('cleanup', async () => { resolve(); }); }); + + await assertPortsFree([ECHO_PORT, PROXY_PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.metrics-new.ts b/test/test.metrics-new.ts index 40b0c4f..9ca15fa 100644 --- a/test/test.metrics-new.ts +++ b/test/test.metrics-new.ts @@ -2,14 +2,16 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as plugins from '../ts/plugins.js'; import { SmartProxy } from '../ts/index.js'; import * as net from 'net'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; let smartProxyInstance: SmartProxy; let echoServer: net.Server; -const echoServerPort = 47300; -const proxyPort = 47301; +let echoServerPort: number; +let proxyPort: number; // Create an echo server for testing tap.test('should create echo server for testing', async () => { + [echoServerPort, proxyPort] = await findFreePorts(2); echoServer = net.createServer((socket) => { socket.on('data', (data) => { socket.write(data); // Echo back the data @@ -267,6 +269,8 @@ tap.test('should clean up resources', async () => { resolve(); }); }); + + await assertPortsFree([echoServerPort, proxyPort]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.perf-improvements.ts b/test/test.perf-improvements.ts index e851c9e..96648e5 100644 --- a/test/test.perf-improvements.ts +++ b/test/test.perf-improvements.ts @@ -7,15 +7,16 @@ import * as net from 'net'; import * as tls from 'tls'; import * as fs from 'fs'; import * as path from 'path'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; // --------------------------------------------------------------------------- -// Port assignments (47600–47620 range to avoid conflicts) +// Port assignments (dynamically allocated to avoid conflicts) // --------------------------------------------------------------------------- -const HTTP_ECHO_PORT = 47600; // backend HTTP echo server -const PROXY_HTTP_PORT = 47601; // SmartProxy plain HTTP forwarding -const PROXY_HTTPS_PORT = 47602; // SmartProxy TLS-terminate HTTPS forwarding -const TCP_ECHO_PORT = 47603; // backend TCP echo server -const PROXY_TCP_PORT = 47604; // SmartProxy plain TCP forwarding +let HTTP_ECHO_PORT: number; +let PROXY_HTTP_PORT: number; +let PROXY_HTTPS_PORT: number; +let TCP_ECHO_PORT: number; +let PROXY_TCP_PORT: number; // --------------------------------------------------------------------------- // Shared state @@ -88,6 +89,8 @@ async function waitForMetrics( // 1. Setup backend servers // =========================================================================== tap.test('setup - backend servers', async () => { + [HTTP_ECHO_PORT, PROXY_HTTP_PORT, PROXY_HTTPS_PORT, TCP_ECHO_PORT, PROXY_TCP_PORT] = await findFreePorts(5); + // HTTP echo server: POST → echo:, GET → ok httpEchoServer = http.createServer((req, res) => { if (req.method === 'POST') { @@ -467,6 +470,8 @@ tap.test('cleanup', async () => { resolve(); }); }); + + await assertPortsFree([HTTP_ECHO_PORT, PROXY_HTTP_PORT, PROXY_HTTPS_PORT, TCP_ECHO_PORT, PROXY_TCP_PORT]); }); export default tap.start(); diff --git a/test/test.port-forwarding-fix.ts b/test/test.port-forwarding-fix.ts index 45cc858..2c476ec 100644 --- a/test/test.port-forwarding-fix.ts +++ b/test/test.port-forwarding-fix.ts @@ -1,17 +1,19 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; let echoServer: net.Server; let proxy: SmartProxy; -const ECHO_PORT = 47400; -const PROXY_PORT_1 = 47401; -const PROXY_PORT_2 = 47402; +let ECHO_PORT: number; +let PROXY_PORT_1: number; +let PROXY_PORT_2: number; tap.test('port forwarding should not immediately close connections', async (tools) => { // Set a timeout for this test tools.timeout(10000); // 10 seconds + [ECHO_PORT, PROXY_PORT_1, PROXY_PORT_2] = await findFreePorts(3); // Create an echo server echoServer = await new Promise((resolve, reject) => { const server = net.createServer((socket) => { @@ -96,6 +98,7 @@ tap.test('cleanup', async () => { }); }); } + await assertPortsFree([ECHO_PORT, PROXY_PORT_1, PROXY_PORT_2]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.port-mapping.ts b/test/test.port-mapping.ts index 07d7a94..3dbc106 100644 --- a/test/test.port-mapping.ts +++ b/test/test.port-mapping.ts @@ -9,13 +9,14 @@ import { createPortOffset } from '../ts/proxies/smart-proxy/utils/route-helpers.js'; import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; // Test server and client utilities let testServers: Array<{ server: net.Server; port: number }> = []; let smartProxy: SmartProxy; -const TEST_PORT_START = 47750; -const PROXY_PORT_START = 48750; +let TEST_PORTS: number[]; // 3 test server ports +let PROXY_PORTS: number[]; // 6 proxy ports const TEST_DATA = 'Hello through dynamic port mapper!'; // Cleanup function to close all servers and proxies @@ -101,53 +102,60 @@ function createTestClient(port: number, data: string): Promise { // Set up test environment tap.test('setup port mapping test environment', async () => { + const allPorts = await findFreePorts(9); + TEST_PORTS = allPorts.slice(0, 3); + PROXY_PORTS = allPorts.slice(3, 9); + // Create multiple test servers on different ports await Promise.all([ - createTestServer(TEST_PORT_START), // Server on port 47750 - createTestServer(TEST_PORT_START + 1), // Server on port 47751 - createTestServer(TEST_PORT_START + 2), // Server on port 47752 + createTestServer(TEST_PORTS[0]), + createTestServer(TEST_PORTS[1]), + createTestServer(TEST_PORTS[2]), ]); - + + // Compute dynamic offset between proxy and test ports + const portOffset = TEST_PORTS[1] - PROXY_PORTS[1]; + // Create a SmartProxy with dynamic port mapping routes smartProxy = new SmartProxy({ routes: [ // Simple function that returns the same port (identity mapping) createPortMappingRoute({ - sourcePortRange: PROXY_PORT_START, + sourcePortRange: PROXY_PORTS[0], targetHost: 'localhost', - portMapper: (context) => TEST_PORT_START, + portMapper: (context) => TEST_PORTS[0], name: 'Identity Port Mapping' }), - - // Offset port mapping from 48751 to 47751 (offset -1000) + + // Offset port mapping using dynamic offset createOffsetPortMappingRoute({ - ports: PROXY_PORT_START + 1, + ports: PROXY_PORTS[1], targetHost: 'localhost', - offset: -1000, - name: 'Offset Port Mapping (-1000)' + offset: portOffset, + name: `Offset Port Mapping (${portOffset})` }), - + // Dynamic route with conditional port mapping createDynamicRoute({ - ports: [PROXY_PORT_START + 2, PROXY_PORT_START + 3], + ports: [PROXY_PORTS[2], PROXY_PORTS[3]], targetHost: (context) => { // Dynamic host selection based on port - return context.port === PROXY_PORT_START + 2 ? 'localhost' : '127.0.0.1'; + return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1'; }, portMapper: (context) => { // Port mapping logic based on incoming port - if (context.port === PROXY_PORT_START + 2) { - return TEST_PORT_START; + if (context.port === PROXY_PORTS[2]) { + return TEST_PORTS[0]; } else { - return TEST_PORT_START + 2; + return TEST_PORTS[2]; } }, name: 'Dynamic Host and Port Mapping' }), - + // Smart load balancer for domain-based routing createSmartLoadBalancer({ - ports: PROXY_PORT_START + 4, + ports: PROXY_PORTS[4], domainTargets: { 'test1.example.com': 'localhost', 'test2.example.com': '127.0.0.1' @@ -155,9 +163,9 @@ tap.test('setup port mapping test environment', async () => { portMapper: (context) => { // Use different backend ports based on domain if (context.domain === 'test1.example.com') { - return TEST_PORT_START; + return TEST_PORTS[0]; } else { - return TEST_PORT_START + 1; + return TEST_PORTS[1]; } }, defaultTarget: 'localhost', @@ -165,44 +173,45 @@ tap.test('setup port mapping test environment', async () => { }) ] }); - + // Start the SmartProxy await smartProxy.start(); }); -// Test 1: Simple identity port mapping (48750 -> 47750) +// Test 1: Simple identity port mapping tap.test('should map port using identity function', async () => { - const response = await createTestClient(PROXY_PORT_START, TEST_DATA); - expect(response).toEqual(`Server ${TEST_PORT_START} says: ${TEST_DATA}`); + const response = await createTestClient(PROXY_PORTS[0], TEST_DATA); + expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`); }); -// Test 2: Offset port mapping (48751 -> 47751) +// Test 2: Offset port mapping tap.test('should map port using offset function', async () => { - const response = await createTestClient(PROXY_PORT_START + 1, TEST_DATA); - expect(response).toEqual(`Server ${TEST_PORT_START + 1} says: ${TEST_DATA}`); + const response = await createTestClient(PROXY_PORTS[1], TEST_DATA); + expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${TEST_DATA}`); }); // Test 3: Dynamic port and host mapping (conditional logic) tap.test('should map port using dynamic logic', async () => { - const response = await createTestClient(PROXY_PORT_START + 2, TEST_DATA); - expect(response).toEqual(`Server ${TEST_PORT_START} says: ${TEST_DATA}`); + const response = await createTestClient(PROXY_PORTS[2], TEST_DATA); + expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`); }); // Test 4: Test reuse of createPortOffset helper tap.test('should use createPortOffset helper for port mapping', async () => { - // Test the createPortOffset helper - const offsetFn = createPortOffset(-1000); + // Test the createPortOffset helper with dynamic offset + const portOffset = TEST_PORTS[1] - PROXY_PORTS[1]; + const offsetFn = createPortOffset(portOffset); const context = { - port: PROXY_PORT_START + 1, + port: PROXY_PORTS[1], clientIp: '127.0.0.1', serverIp: '127.0.0.1', isTls: false, timestamp: Date.now(), connectionId: 'test-connection' } as IRouteContext; - + const mappedPort = offsetFn(context); - expect(mappedPort).toEqual(TEST_PORT_START + 1); + expect(mappedPort).toEqual(TEST_PORTS[1]); }); // Test 5: Test error handling for invalid port mapping functions @@ -210,7 +219,7 @@ tap.test('should handle errors in port mapping functions', async () => { // Create a route with a function that throws an error const errorRoute: IRouteConfig = { match: { - ports: PROXY_PORT_START + 5 + ports: PROXY_PORTS[5] }, action: { type: 'forward', @@ -229,7 +238,7 @@ tap.test('should handle errors in port mapping functions', async () => { // The connection should fail or timeout try { - await createTestClient(PROXY_PORT_START + 5, TEST_DATA); + await createTestClient(PROXY_PORTS[5], TEST_DATA); // Connection should not succeed expect(false).toBeTrue(); } catch (error) { @@ -254,6 +263,8 @@ tap.test('cleanup port mapping test environment', async () => { testServers = []; smartProxy = null as any; } + + await assertPortsFree([...TEST_PORTS, ...PROXY_PORTS]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.smartproxy.ts b/test/test.smartproxy.ts index 17ae3d2..8d1b8c4 100644 --- a/test/test.smartproxy.ts +++ b/test/test.smartproxy.ts @@ -1,11 +1,19 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; let testServer: net.Server; let smartProxy: SmartProxy; -const TEST_SERVER_PORT = 47770; -const PROXY_PORT = 47771; +let TEST_SERVER_PORT: number; +let PROXY_PORT: number; +let CUSTOM_HOST_PORT: number; +let CUSTOM_IP_PROXY_PORT: number; +let CUSTOM_IP_TARGET_PORT: number; +let CHAIN_DEFAULT_1_PORT: number; +let CHAIN_DEFAULT_2_PORT: number; +let CHAIN_PRESERVED_1_PORT: number; +let CHAIN_PRESERVED_2_PORT: number; const TEST_DATA = 'Hello through port proxy!'; // Track all created servers and proxies for proper cleanup @@ -64,6 +72,7 @@ function createTestClient(port: number, data: string): Promise { // SETUP: Create a test server and a PortProxy instance. tap.test('setup port proxy test environment', async () => { + [TEST_SERVER_PORT, PROXY_PORT, CUSTOM_HOST_PORT, CUSTOM_IP_PROXY_PORT, CUSTOM_IP_TARGET_PORT, CHAIN_DEFAULT_1_PORT, CHAIN_DEFAULT_2_PORT, CHAIN_PRESERVED_1_PORT, CHAIN_PRESERVED_2_PORT] = await findFreePorts(9); testServer = await createTestServer(TEST_SERVER_PORT); smartProxy = new SmartProxy({ routes: [ @@ -110,7 +119,7 @@ tap.test('should forward TCP connections to custom host', async () => { { name: 'custom-host-route', match: { - ports: PROXY_PORT + 1 + ports: CUSTOM_HOST_PORT }, action: { type: 'forward', @@ -128,9 +137,9 @@ tap.test('should forward TCP connections to custom host', async () => { } }); allProxies.push(customHostProxy); // Track this proxy - + await customHostProxy.start(); - const response = await createTestClient(PROXY_PORT + 1, TEST_DATA); + const response = await createTestClient(CUSTOM_HOST_PORT, TEST_DATA); expect(response).toEqual(`Echo: ${TEST_DATA}`); await customHostProxy.stop(); @@ -143,8 +152,8 @@ tap.test('should forward TCP connections to custom host', async () => { // Modified to work in Docker/CI environments without needing 127.0.0.2 tap.test('should forward connections to custom IP', async () => { // Set up ports that are FAR apart to avoid any possible confusion - const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on - const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port + const forcedProxyPort = CUSTOM_IP_PROXY_PORT; + const targetServerPort = CUSTOM_IP_TARGET_PORT; // Create a test server listening on a unique port on 127.0.0.1 (works in all environments) const testServer2 = await createTestServer(targetServerPort, '127.0.0.1'); @@ -252,13 +261,13 @@ tap.test('should support optional source IP preservation in chained proxies', as { name: 'first-proxy-default-route', match: { - ports: PROXY_PORT + 4 + ports: CHAIN_DEFAULT_1_PORT }, action: { type: 'forward', targets: [{ host: 'localhost', - port: PROXY_PORT + 5 + port: CHAIN_DEFAULT_2_PORT }] } } @@ -274,7 +283,7 @@ tap.test('should support optional source IP preservation in chained proxies', as { name: 'second-proxy-default-route', match: { - ports: PROXY_PORT + 5 + ports: CHAIN_DEFAULT_2_PORT }, action: { type: 'forward', @@ -296,7 +305,7 @@ tap.test('should support optional source IP preservation in chained proxies', as await secondProxyDefault.start(); await firstProxyDefault.start(); - const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA); + const response1 = await createTestClient(CHAIN_DEFAULT_1_PORT, TEST_DATA); expect(response1).toEqual(`Echo: ${TEST_DATA}`); await firstProxyDefault.stop(); await secondProxyDefault.stop(); @@ -313,13 +322,13 @@ tap.test('should support optional source IP preservation in chained proxies', as { name: 'first-proxy-preserved-route', match: { - ports: PROXY_PORT + 6 + ports: CHAIN_PRESERVED_1_PORT }, action: { type: 'forward', targets: [{ host: 'localhost', - port: PROXY_PORT + 7 + port: CHAIN_PRESERVED_2_PORT }] } } @@ -337,7 +346,7 @@ tap.test('should support optional source IP preservation in chained proxies', as { name: 'second-proxy-preserved-route', match: { - ports: PROXY_PORT + 7 + ports: CHAIN_PRESERVED_2_PORT }, action: { type: 'forward', @@ -361,7 +370,7 @@ tap.test('should support optional source IP preservation in chained proxies', as await secondProxyPreserved.start(); await firstProxyPreserved.start(); - const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA); + const response2 = await createTestClient(CHAIN_PRESERVED_1_PORT, TEST_DATA); expect(response2).toEqual(`Echo: ${TEST_DATA}`); await firstProxyPreserved.stop(); await secondProxyPreserved.stop(); @@ -446,6 +455,8 @@ tap.test('cleanup port proxy test environment', async () => { // Verify all resources are cleaned up expect(allProxies.length).toEqual(0); expect(allServers.length).toEqual(0); + + await assertPortsFree([TEST_SERVER_PORT, PROXY_PORT, CUSTOM_HOST_PORT, CUSTOM_IP_PROXY_PORT, CUSTOM_IP_TARGET_PORT, CHAIN_DEFAULT_1_PORT, CHAIN_DEFAULT_2_PORT, CHAIN_PRESERVED_1_PORT, CHAIN_PRESERVED_2_PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.socket-handler-race.ts b/test/test.socket-handler-race.ts index d88257b..9d2663b 100644 --- a/test/test.socket-handler-race.ts +++ b/test/test.socket-handler-race.ts @@ -1,12 +1,15 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { SmartProxy } from '../ts/index.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; tap.test('should handle async handler that sets up listeners after delay', async () => { + const [PORT] = await findFreePorts(1); + const proxy = new SmartProxy({ routes: [{ name: 'delayed-setup-handler', - match: { ports: 7777 }, + match: { ports: PORT }, action: { type: 'socket-handler', socketHandler: async (socket, context) => { @@ -41,7 +44,7 @@ tap.test('should handle async handler that sets up listeners after delay', async }); await new Promise((resolve, reject) => { - client.connect(7777, 'localhost', () => { + client.connect(PORT, 'localhost', () => { // Send initial data immediately - this tests the race condition client.write('initial-message\n'); resolve(); @@ -78,6 +81,7 @@ tap.test('should handle async handler that sets up listeners after delay', async expect(response).toContain('RECEIVED: test-message'); await proxy.stop(); + await assertPortsFree([PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.socket-handler.ts b/test/test.socket-handler.ts index 45d59ce..26668af 100644 --- a/test/test.socket-handler.ts +++ b/test/test.socket-handler.ts @@ -2,15 +2,19 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { SmartProxy } from '../ts/index.js'; import type { IRouteConfig } from '../ts/index.js'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; let proxy: SmartProxy; +let PORT: number; tap.test('setup socket handler test', async () => { + [PORT] = await findFreePorts(1); + // Create a simple socket handler route const routes: IRouteConfig[] = [{ name: 'echo-handler', - match: { - ports: 47780 + match: { + ports: PORT // No domains restriction - matches all connections }, action: { @@ -43,11 +47,11 @@ tap.test('should handle socket with custom function', async () => { let response = ''; await new Promise((resolve, reject) => { - client.connect(47780, 'localhost', () => { + client.connect(PORT, 'localhost', () => { console.log('Client connected to proxy'); resolve(); }); - + client.on('error', reject); }); @@ -78,7 +82,7 @@ tap.test('should handle async socket handler', async () => { // Update route with async handler await proxy.updateRoutes([{ name: 'async-handler', - match: { ports: 47780 }, + match: { ports: PORT }, action: { type: 'socket-handler', socketHandler: async (socket, context) => { @@ -108,12 +112,12 @@ tap.test('should handle async socket handler', async () => { }); await new Promise((resolve, reject) => { - client.connect(47780, 'localhost', () => { + client.connect(PORT, 'localhost', () => { // Send initial data to trigger the handler client.write('test data\n'); resolve(); }); - + client.on('error', reject); }); @@ -131,7 +135,7 @@ tap.test('should handle errors in socket handler', async () => { // Update route with error-throwing handler await proxy.updateRoutes([{ name: 'error-handler', - match: { ports: 47780 }, + match: { ports: PORT }, action: { type: 'socket-handler', socketHandler: (socket, context) => { @@ -148,12 +152,12 @@ tap.test('should handle errors in socket handler', async () => { }); await new Promise((resolve, reject) => { - client.connect(47780, 'localhost', () => { + client.connect(PORT, 'localhost', () => { // Connection established - send data to trigger handler client.write('trigger\n'); resolve(); }); - + client.on('error', () => { // Ignore client errors - we expect the connection to be closed }); @@ -168,6 +172,7 @@ tap.test('should handle errors in socket handler', async () => { tap.test('cleanup', async () => { await proxy.stop(); + await assertPortsFree([PORT]); }); export default tap.start(); \ No newline at end of file diff --git a/test/test.throughput.ts b/test/test.throughput.ts index c7d7be0..4dc9e0a 100644 --- a/test/test.throughput.ts +++ b/test/test.throughput.ts @@ -8,24 +8,25 @@ import * as https from 'https'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ──────────────────────────────────────────────────────────────────────────── -// Port assignments (unique to avoid conflicts with other tests) +// Port assignments (dynamically allocated to avoid conflicts) // ──────────────────────────────────────────────────────────────────────────── -const TCP_ECHO_PORT = 47500; -const HTTP_ECHO_PORT = 47501; -const TLS_ECHO_PORT = 47502; -const PROXY_TCP_PORT = 47510; -const PROXY_HTTP_PORT = 47511; -const PROXY_TLS_PASS_PORT = 47512; -const PROXY_TLS_TERM_PORT = 47513; -const PROXY_SOCKET_PORT = 47514; -const PROXY_MULTI_A_PORT = 47515; -const PROXY_MULTI_B_PORT = 47516; -const PROXY_TP_HTTP_PORT = 47517; +let TCP_ECHO_PORT: number; +let HTTP_ECHO_PORT: number; +let TLS_ECHO_PORT: number; +let PROXY_TCP_PORT: number; +let PROXY_HTTP_PORT: number; +let PROXY_TLS_PASS_PORT: number; +let PROXY_TLS_TERM_PORT: number; +let PROXY_SOCKET_PORT: number; +let PROXY_MULTI_A_PORT: number; +let PROXY_MULTI_B_PORT: number; +let PROXY_TP_HTTP_PORT: number; // ──────────────────────────────────────────────────────────────────────────── // Test certificates @@ -49,6 +50,8 @@ async function pollMetrics(proxy: SmartProxy): Promise { // Setup: backend servers // ════════════════════════════════════════════════════════════════════════════ tap.test('setup - TCP echo server', async () => { + [TCP_ECHO_PORT, HTTP_ECHO_PORT, TLS_ECHO_PORT, PROXY_TCP_PORT, PROXY_HTTP_PORT, PROXY_TLS_PASS_PORT, PROXY_TLS_TERM_PORT, PROXY_SOCKET_PORT, PROXY_MULTI_A_PORT, PROXY_MULTI_B_PORT, PROXY_TP_HTTP_PORT] = await findFreePorts(11); + tcpEchoServer = net.createServer((socket) => { socket.on('data', (data) => socket.write(data)); socket.on('error', () => {}); @@ -700,6 +703,7 @@ tap.test('cleanup - close backend servers', async () => { await new Promise((resolve) => httpEchoServer.close(() => resolve())); await new Promise((resolve) => tlsEchoServer.close(() => resolve())); console.log('All backend servers closed'); + await assertPortsFree([TCP_ECHO_PORT, HTTP_ECHO_PORT, TLS_ECHO_PORT, PROXY_TCP_PORT, PROXY_HTTP_PORT, PROXY_TLS_PASS_PORT, PROXY_TLS_TERM_PORT, PROXY_SOCKET_PORT, PROXY_MULTI_A_PORT, PROXY_MULTI_B_PORT, PROXY_TP_HTTP_PORT]); }); export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ca4c79e..96fc869 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.7.9', + version: '25.7.10', 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.' }