From fc04a0210bebb2703d5bc0bb9d23ba6554147579 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 21 Mar 2026 22:23:38 +0000 Subject: [PATCH] BREAKING CHANGE(ts-api,rustproxy): remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths --- changelog.md | 8 + rust/crates/rustproxy-http/src/h3_service.rs | 75 ++- .../rustproxy-http/src/proxy_service.rs | 40 +- .../crates/rustproxy-metrics/src/collector.rs | 26 +- .../rustproxy-passthrough/src/quic_handler.rs | 145 +++- .../rustproxy-passthrough/src/udp_listener.rs | 22 + test/core/utils/test.async-utils.ts | 200 ------ test/core/utils/test.binary-heap.ts | 206 ------ test/core/utils/test.fs-utils.ts | 185 ------ test/core/utils/test.ip-utils.ts | 156 ----- test/core/utils/test.lifecycle-component.ts | 252 ------- .../utils/test.shared-security-manager.ts | 158 ----- test/core/utils/test.validation-utils.ts | 302 --------- test/test.detection.ts | 146 ---- test/test.ip-validation.ts | 128 ---- test/test.log-deduplication.node.ts | 112 ---- test/test.router.ts | 403 ----------- ...est.shared-security-manager-limits.node.ts | 157 ----- test/test.wrapped-socket.ts | 315 --------- ts/00_commitinfo_data.ts | 2 +- ts/core/events/index.ts | 3 - ts/core/index.ts | 1 - ts/core/models/index.ts | 1 - ts/core/models/socket-augmentation.ts | 38 -- ts/core/utils/async-utils.ts | 275 -------- ts/core/utils/binary-heap.ts | 225 ------- ts/core/utils/enhanced-connection-pool.ts | 425 ------------ ts/core/utils/fs-utils.ts | 270 -------- ts/core/utils/index.ts | 12 - ts/core/utils/ip-utils.ts | 303 --------- ts/core/utils/lifecycle-component.ts | 251 ------- ts/core/utils/log-deduplicator.ts | 370 ----------- ts/core/utils/security-utils.ts | 305 --------- ts/core/utils/shared-security-manager.ts | 470 ------------- ts/core/utils/socket-utils.ts | 322 --------- ts/core/utils/template-utils.ts | 124 ---- ts/core/utils/validation-utils.ts | 177 ----- ts/core/utils/websocket-utils.ts | 33 - ts/detection/detectors/http-detector.ts | 127 ---- ts/detection/detectors/quick-detector.ts | 148 ----- ts/detection/detectors/routing-extractor.ts | 147 ---- ts/detection/detectors/tls-detector.ts | 223 ------- ts/detection/index.ts | 25 - ts/detection/models/detection-types.ts | 102 --- ts/detection/models/interfaces.ts | 115 ---- ts/detection/protocol-detector.ts | 319 --------- ts/detection/utils/buffer-utils.ts | 141 ---- ts/detection/utils/fragment-manager.ts | 64 -- ts/detection/utils/parser-utils.ts | 77 --- ts/index.ts | 8 +- ts/protocols/common/fragment-handler.ts | 167 ----- ts/protocols/common/index.ts | 8 - ts/protocols/common/types.ts | 76 --- ts/protocols/http/index.ts | 3 +- ts/protocols/http/parser.ts | 219 ------ ts/protocols/index.ts | 7 - ts/protocols/proxy/index.ts | 6 - ts/protocols/proxy/types.ts | 53 -- ts/protocols/tls/alerts/index.ts | 3 - ts/protocols/tls/alerts/tls-alert.ts | 259 -------- ts/protocols/tls/index.ts | 37 -- ts/protocols/tls/sni/client-hello-parser.ts | 629 ------------------ ts/protocols/tls/sni/index.ts | 6 - ts/protocols/tls/sni/sni-extraction.ts | 353 ---------- ts/protocols/tls/utils/index.ts | 3 - ts/protocols/tls/utils/tls-utils.ts | 201 ------ ts/protocols/websocket/constants.ts | 60 -- ts/protocols/websocket/index.ts | 8 - ts/protocols/websocket/types.ts | 53 -- ts/protocols/websocket/utils.ts | 98 --- .../smart-proxy/socket-handler-server.ts | 6 + .../utils/route-helpers/socket-handlers.ts | 119 ++-- ts/routing/index.ts | 3 - ts/routing/router/http-router.ts | 266 -------- ts/routing/router/index.ts | 7 - ts/tls/index.ts | 29 - ts/tls/sni/index.ts | 3 - ts/tls/sni/sni-handler.ts | 264 -------- 78 files changed, 331 insertions(+), 10754 deletions(-) delete mode 100644 test/core/utils/test.async-utils.ts delete mode 100644 test/core/utils/test.binary-heap.ts delete mode 100644 test/core/utils/test.fs-utils.ts delete mode 100644 test/core/utils/test.ip-utils.ts delete mode 100644 test/core/utils/test.lifecycle-component.ts delete mode 100644 test/core/utils/test.shared-security-manager.ts delete mode 100644 test/core/utils/test.validation-utils.ts delete mode 100644 test/test.detection.ts delete mode 100644 test/test.ip-validation.ts delete mode 100644 test/test.log-deduplication.node.ts delete mode 100644 test/test.router.ts delete mode 100644 test/test.shared-security-manager-limits.node.ts delete mode 100644 test/test.wrapped-socket.ts delete mode 100644 ts/core/events/index.ts delete mode 100644 ts/core/models/socket-augmentation.ts delete mode 100644 ts/core/utils/async-utils.ts delete mode 100644 ts/core/utils/binary-heap.ts delete mode 100644 ts/core/utils/enhanced-connection-pool.ts delete mode 100644 ts/core/utils/fs-utils.ts delete mode 100644 ts/core/utils/ip-utils.ts delete mode 100644 ts/core/utils/lifecycle-component.ts delete mode 100644 ts/core/utils/log-deduplicator.ts delete mode 100644 ts/core/utils/security-utils.ts delete mode 100644 ts/core/utils/shared-security-manager.ts delete mode 100644 ts/core/utils/socket-utils.ts delete mode 100644 ts/core/utils/template-utils.ts delete mode 100644 ts/core/utils/validation-utils.ts delete mode 100644 ts/core/utils/websocket-utils.ts delete mode 100644 ts/detection/detectors/http-detector.ts delete mode 100644 ts/detection/detectors/quick-detector.ts delete mode 100644 ts/detection/detectors/routing-extractor.ts delete mode 100644 ts/detection/detectors/tls-detector.ts delete mode 100644 ts/detection/index.ts delete mode 100644 ts/detection/models/detection-types.ts delete mode 100644 ts/detection/models/interfaces.ts delete mode 100644 ts/detection/protocol-detector.ts delete mode 100644 ts/detection/utils/buffer-utils.ts delete mode 100644 ts/detection/utils/fragment-manager.ts delete mode 100644 ts/detection/utils/parser-utils.ts delete mode 100644 ts/protocols/common/fragment-handler.ts delete mode 100644 ts/protocols/common/index.ts delete mode 100644 ts/protocols/common/types.ts delete mode 100644 ts/protocols/http/parser.ts delete mode 100644 ts/protocols/proxy/index.ts delete mode 100644 ts/protocols/proxy/types.ts delete mode 100644 ts/protocols/tls/alerts/index.ts delete mode 100644 ts/protocols/tls/alerts/tls-alert.ts delete mode 100644 ts/protocols/tls/index.ts delete mode 100644 ts/protocols/tls/sni/client-hello-parser.ts delete mode 100644 ts/protocols/tls/sni/index.ts delete mode 100644 ts/protocols/tls/sni/sni-extraction.ts delete mode 100644 ts/protocols/tls/utils/index.ts delete mode 100644 ts/protocols/tls/utils/tls-utils.ts delete mode 100644 ts/protocols/websocket/constants.ts delete mode 100644 ts/protocols/websocket/index.ts delete mode 100644 ts/protocols/websocket/types.ts delete mode 100644 ts/protocols/websocket/utils.ts delete mode 100644 ts/routing/router/http-router.ts delete mode 100644 ts/routing/router/index.ts delete mode 100644 ts/tls/index.ts delete mode 100644 ts/tls/sni/index.ts delete mode 100644 ts/tls/sni/sni-handler.ts diff --git a/changelog.md b/changelog.md index 4a5d881..39d9f38 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-03-21 - 26.0.0 - BREAKING CHANGE(ts-api,rustproxy) +remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths + +- Removes large parts of the public TypeScript surface including detection, TLS, router, websocket, proxy/common protocol, and multiple core utility exports and files. +- Adds parent-child cancellation handling for HTTP/3 and QUIC stream forwarding to stop orphaned tasks and close idle or overlong streams. +- Improves cleanup reliability with RAII guards for WebSocket upstream tracking and QUIC connection metrics, plus periodic cleanup for rate limiter and proxy address maps. +- Cleans backend metrics state when active backend connections drop to zero and tracks passthrough backend sockets for shutdown cleanup. + ## 2026-03-20 - 25.17.10 - fix(rustproxy-http) reuse the shared HTTP proxy service for HTTP/3 request handling diff --git a/rust/crates/rustproxy-http/src/h3_service.rs b/rust/crates/rustproxy-http/src/h3_service.rs index b63b200..e1fd612 100644 --- a/rust/crates/rustproxy-http/src/h3_service.rs +++ b/rust/crates/rustproxy-http/src/h3_service.rs @@ -43,6 +43,7 @@ impl H3ProxyService { _fallback_route: &RouteConfig, port: u16, real_client_addr: Option, + parent_cancel: &CancellationToken, ) -> anyhow::Result<()> { let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address()); debug!("HTTP/3 connection from {} on port {}", remote_addr, port); @@ -55,35 +56,44 @@ impl H3ProxyService { .map_err(|e| anyhow::anyhow!("H3 connection setup failed: {}", e))?; loop { - match h3_conn.accept().await { - Ok(Some(resolver)) => { - let (request, stream) = match resolver.resolve_request().await { - Ok(pair) => pair, + let resolver = tokio::select! { + _ = parent_cancel.cancelled() => { + debug!("HTTP/3 connection from {} cancelled by parent", remote_addr); + break; + } + result = h3_conn.accept() => { + match result { + Ok(Some(resolver)) => resolver, + Ok(None) => { + debug!("HTTP/3 connection from {} closed", remote_addr); + break; + } Err(e) => { - debug!("HTTP/3 request resolve error: {}", e); - continue; + debug!("HTTP/3 accept error from {}: {}", remote_addr, e); + break; } - }; - - let http_proxy = Arc::clone(&self.http_proxy); - - tokio::spawn(async move { - if let Err(e) = handle_h3_request( - request, stream, port, remote_addr, &http_proxy, - ).await { - debug!("HTTP/3 request error from {}: {}", remote_addr, e); - } - }); - } - Ok(None) => { - debug!("HTTP/3 connection from {} closed", remote_addr); - break; + } } + }; + + let (request, stream) = match resolver.resolve_request().await { + Ok(pair) => pair, Err(e) => { - debug!("HTTP/3 accept error from {}: {}", remote_addr, e); - break; + debug!("HTTP/3 request resolve error: {}", e); + continue; } - } + }; + + let http_proxy = Arc::clone(&self.http_proxy); + let request_cancel = parent_cancel.child_token(); + + tokio::spawn(async move { + if let Err(e) = handle_h3_request( + request, stream, port, remote_addr, &http_proxy, request_cancel, + ).await { + debug!("HTTP/3 request error from {}: {}", remote_addr, e); + } + }); } Ok(()) @@ -103,13 +113,25 @@ async fn handle_h3_request( port: u16, peer_addr: SocketAddr, http_proxy: &HttpProxyService, + cancel: CancellationToken, ) -> anyhow::Result<()> { // Stream request body from H3 client via an mpsc channel. let (body_tx, body_rx) = tokio::sync::mpsc::channel::(4); - // Spawn the H3 body reader task + // Spawn the H3 body reader task with cancellation + let body_cancel = cancel.clone(); let body_reader = tokio::spawn(async move { - while let Ok(Some(mut chunk)) = stream.recv_data().await { + loop { + let chunk = tokio::select! { + _ = body_cancel.cancelled() => break, + result = stream.recv_data() => { + match result { + Ok(Some(chunk)) => chunk, + _ => break, + } + } + }; + let mut chunk = chunk; let data = Bytes::copy_from_slice(chunk.chunk()); chunk.advance(chunk.remaining()); if body_tx.send(data).await.is_err() { @@ -128,7 +150,6 @@ async fn handle_h3_request( // Delegate to HttpProxyService — same backend path as TCP/HTTP: // route matching, ALPN protocol detection, connection pool, H1/H2/H3 auto. - let cancel = CancellationToken::new(); let conn_activity = ConnActivity::new_standalone(); let response = http_proxy.handle_request(req, peer_addr, port, cancel, conn_activity).await .map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?; diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 5d493b7..9733fde 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -203,6 +203,10 @@ pub struct HttpProxyService { route_rate_limiters: Arc>>, /// Request counter for periodic rate limiter cleanup. request_counter: AtomicU64, + /// Epoch for time-based rate limiter cleanup. + rate_limiter_epoch: std::time::Instant, + /// Last rate limiter cleanup time (ms since epoch). + last_rate_limiter_cleanup_ms: AtomicU64, /// Cache of compiled URL rewrite regexes (keyed by pattern string). regex_cache: DashMap, /// Shared backend TLS config for session resumption across connections. @@ -233,6 +237,8 @@ impl HttpProxyService { connect_timeout: DEFAULT_CONNECT_TIMEOUT, route_rate_limiters: Arc::new(DashMap::new()), request_counter: AtomicU64::new(0), + rate_limiter_epoch: std::time::Instant::now(), + last_rate_limiter_cleanup_ms: AtomicU64::new(0), regex_cache: DashMap::new(), backend_tls_config: Self::default_backend_tls_config(), backend_tls_config_alpn: Self::default_backend_tls_config_with_alpn(), @@ -258,6 +264,8 @@ impl HttpProxyService { connect_timeout, route_rate_limiters: Arc::new(DashMap::new()), request_counter: AtomicU64::new(0), + rate_limiter_epoch: std::time::Instant::now(), + last_rate_limiter_cleanup_ms: AtomicU64::new(0), regex_cache: DashMap::new(), backend_tls_config: Self::default_backend_tls_config(), backend_tls_config_alpn: Self::default_backend_tls_config_with_alpn(), @@ -524,9 +532,13 @@ impl HttpProxyService { } } - // Periodic rate limiter cleanup (every 1000 requests) + // Periodic rate limiter cleanup (every 1000 requests or every 60s) let count = self.request_counter.fetch_add(1, Ordering::Relaxed); - if count % 1000 == 0 { + let now_ms = self.rate_limiter_epoch.elapsed().as_millis() as u64; + let last_cleanup = self.last_rate_limiter_cleanup_ms.load(Ordering::Relaxed); + let time_triggered = now_ms.saturating_sub(last_cleanup) >= 60_000; + if count % 1000 == 0 || time_triggered { + self.last_rate_limiter_cleanup_ms.store(now_ms, Ordering::Relaxed); for entry in self.route_rate_limiters.iter() { entry.value().cleanup(); } @@ -2134,12 +2146,26 @@ impl HttpProxyService { let ws_max_lifetime = self.ws_max_lifetime; tokio::spawn(async move { + // RAII guard: ensures connection_ended is called even if this task panics + struct WsUpstreamGuard { + selector: UpstreamSelector, + key: String, + } + impl Drop for WsUpstreamGuard { + fn drop(&mut self) { + self.selector.connection_ended(&self.key); + } + } + let _upstream_guard = WsUpstreamGuard { + selector: upstream_selector, + key: upstream_key_owned.clone(), + }; + let client_upgraded = match on_client_upgrade.await { Ok(upgraded) => upgraded, Err(e) => { debug!("WebSocket: client upgrade failed: {}", e); - upstream_selector.connection_ended(&upstream_key_owned); - return; + return; // _upstream_guard Drop handles connection_ended } }; @@ -2298,9 +2324,7 @@ impl HttpProxyService { watchdog.abort(); debug!("WebSocket tunnel closed: {} bytes in, {} bytes out", bytes_in, bytes_out); - - upstream_selector.connection_ended(&upstream_key_owned); - // Bytes already reported per-chunk in the copy loops above + // _upstream_guard Drop handles connection_ended on all paths including panic }); let body: BoxBody = BoxBody::new( @@ -2822,6 +2846,8 @@ impl Default for HttpProxyService { connect_timeout: DEFAULT_CONNECT_TIMEOUT, route_rate_limiters: Arc::new(DashMap::new()), request_counter: AtomicU64::new(0), + rate_limiter_epoch: std::time::Instant::now(), + last_rate_limiter_cleanup_ms: AtomicU64::new(0), regex_cache: DashMap::new(), backend_tls_config: Self::default_backend_tls_config(), backend_tls_config_alpn: Self::default_backend_tls_config_with_alpn(), diff --git a/rust/crates/rustproxy-metrics/src/collector.rs b/rust/crates/rustproxy-metrics/src/collector.rs index 3b4d7e9..43f229c 100644 --- a/rust/crates/rustproxy-metrics/src/collector.rs +++ b/rust/crates/rustproxy-metrics/src/collector.rs @@ -411,11 +411,24 @@ impl MetricsCollector { } /// Record a backend connection closing. + /// Removes all per-backend tracking entries when the active count reaches 0. pub fn backend_connection_closed(&self, key: &str) { if let Some(counter) = self.backend_active.get(key) { - let val = counter.load(Ordering::Relaxed); - if val > 0 { - counter.fetch_sub(1, Ordering::Relaxed); + let prev = counter.fetch_sub(1, Ordering::Relaxed); + if prev <= 1 { + // Active count reached 0 — clean up all per-backend maps + drop(counter); // release DashMap ref before remove + self.backend_active.remove(key); + self.backend_total.remove(key); + self.backend_protocol.remove(key); + self.backend_connect_errors.remove(key); + self.backend_handshake_errors.remove(key); + self.backend_request_errors.remove(key); + self.backend_connect_time_us.remove(key); + self.backend_connect_count.remove(key); + self.backend_pool_hits.remove(key); + self.backend_pool_misses.remove(key); + self.backend_h2_failures.remove(key); } } } @@ -1213,10 +1226,13 @@ mod tests { // No entry created assert!(collector.backend_active.get(key).is_none()); - // Open one, close two — should saturate at 0 + // Open one, close — entries are removed when active count reaches 0 collector.backend_connection_opened(key, Duration::from_millis(1)); collector.backend_connection_closed(key); + // Entry should be cleaned up (active reached 0) + assert!(collector.backend_active.get(key).is_none()); + // Second close on missing entry is a no-op collector.backend_connection_closed(key); - assert_eq!(collector.backend_active.get(key).unwrap().load(Ordering::Relaxed), 0); + assert!(collector.backend_active.get(key).is_none()); } } diff --git a/rust/crates/rustproxy-passthrough/src/quic_handler.rs b/rust/crates/rustproxy-passthrough/src/quic_handler.rs index d228348..fd20c76 100644 --- a/rust/crates/rustproxy-passthrough/src/quic_handler.rs +++ b/rust/crates/rustproxy-passthrough/src/quic_handler.rs @@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UdpSocket; use tokio::task::JoinHandle; @@ -276,6 +276,17 @@ async fn quic_proxy_relay_loop( debug!("QUIC relay: cleaned up stale session for {}", key); } } + + // Also clean orphaned proxy_addr_map entries (PROXY header received + // but no relay session was ever created, e.g. client never sent data) + let orphaned: Vec = proxy_addr_map.iter() + .filter(|entry| relay_sessions.get(entry.key()).is_none()) + .map(|entry| *entry.key()) + .collect(); + for key in orphaned { + proxy_addr_map.remove(&key); + debug!("QUIC relay: cleaned up orphaned proxy_addr_map entry for {}", key); + } } } @@ -399,14 +410,32 @@ pub async fn quic_accept_loop( let real_client_addr = if real_addr != remote_addr { Some(real_addr) } else { None }; tokio::spawn(async move { + // RAII guard: ensures metrics/tracker cleanup even on panic + struct QuicConnGuard { + tracker: Arc, + metrics: Arc, + ip: std::net::IpAddr, + ip_str: String, + route_id: Option, + } + impl Drop for QuicConnGuard { + fn drop(&mut self) { + self.tracker.connection_closed(&self.ip); + self.metrics.connection_closed(self.route_id.as_deref(), Some(&self.ip_str)); + } + } + let _guard = QuicConnGuard { + tracker: conn_tracker, + metrics: Arc::clone(&metrics), + ip, + ip_str, + route_id, + }; + match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel, h3_svc, real_client_addr).await { Ok(()) => debug!("QUIC connection from {} completed", real_addr), Err(e) => debug!("QUIC connection from {} error: {}", real_addr, e), } - - // Cleanup - conn_tracker.connection_closed(&ip); - metrics.connection_closed(route_id.as_deref(), Some(&ip_str)); }); } @@ -439,7 +468,7 @@ async fn handle_quic_connection( if enable_http3 { if let Some(ref h3_svc) = h3_service { debug!("HTTP/3 enabled for route {:?}, dispatching to H3ProxyService", route.name); - h3_svc.handle_connection(connection, &route, port, real_client_addr).await + h3_svc.handle_connection(connection, &route, port, real_client_addr, cancel).await } else { warn!("HTTP/3 enabled for route {:?} but H3ProxyService not initialized", route.name); // Keep connection alive until cancelled @@ -502,6 +531,7 @@ async fn handle_quic_stream_forwarding( let ip_str = effective_addr.ip().to_string(); let stream_metrics = Arc::clone(&metrics_arc); let stream_route_id = route_id.map(|s| s.to_string()); + let stream_cancel = cancel.child_token(); // Spawn a task for each QUIC stream → TCP bidirectional forwarding tokio::spawn(async move { @@ -509,6 +539,7 @@ async fn handle_quic_stream_forwarding( send_stream, recv_stream, &backend_addr, + stream_cancel, ).await { Ok((bytes_in, bytes_out)) => { stream_metrics.record_bytes( @@ -529,27 +560,111 @@ async fn handle_quic_stream_forwarding( } /// Forward a single QUIC bidirectional stream to a TCP backend connection. +/// +/// Includes inactivity timeout (60s), max lifetime (10min), and cancellation +/// to prevent leaked stream tasks when the parent connection closes. async fn forward_quic_stream_to_tcp( mut quic_send: quinn::SendStream, mut quic_recv: quinn::RecvStream, backend_addr: &str, + cancel: CancellationToken, ) -> anyhow::Result<(u64, u64)> { + let inactivity_timeout = std::time::Duration::from_secs(60); + let max_lifetime = std::time::Duration::from_secs(600); + // Connect to backend TCP let tcp_stream = tokio::net::TcpStream::connect(backend_addr).await?; let (mut tcp_read, mut tcp_write) = tcp_stream.into_split(); - // Bidirectional copy - let client_to_backend = tokio::io::copy(&mut quic_recv, &mut tcp_write); - let backend_to_client = tokio::io::copy(&mut tcp_read, &mut quic_send); + let last_activity = Arc::new(AtomicU64::new(0)); + let start = std::time::Instant::now(); + let conn_cancel = CancellationToken::new(); - let (c2b, b2c) = tokio::join!(client_to_backend, backend_to_client); + let la1 = Arc::clone(&last_activity); + let cc1 = conn_cancel.clone(); + let c2b = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + let mut total = 0u64; + loop { + let n = tokio::select! { + result = quic_recv.read(&mut buf) => match result { + Ok(Some(0)) | Ok(None) | Err(_) => break, + Ok(Some(n)) => n, + }, + _ = cc1.cancelled() => break, + }; + if tcp_write.write_all(&buf[..n]).await.is_err() { + break; + } + total += n as u64; + la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + tcp_write.shutdown(), + ).await; + total + }); - let bytes_in = c2b.unwrap_or(0); - let bytes_out = b2c.unwrap_or(0); + let la2 = Arc::clone(&last_activity); + let cc2 = conn_cancel.clone(); + let b2c = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + let mut total = 0u64; + loop { + let n = tokio::select! { + result = tcp_read.read(&mut buf) => match result { + Ok(0) | Err(_) => break, + Ok(n) => n, + }, + _ = cc2.cancelled() => break, + }; + // quinn SendStream implements AsyncWrite + if quic_send.write_all(&buf[..n]).await.is_err() { + break; + } + total += n as u64; + la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); + } + let _ = quic_send.finish(); + total + }); - // Graceful shutdown - let _ = quic_send.finish(); - let _ = tcp_write.shutdown().await; + // Watchdog: inactivity, max lifetime, and cancellation + let la_watch = Arc::clone(&last_activity); + let c2b_abort = c2b.abort_handle(); + let b2c_abort = b2c.abort_handle(); + tokio::spawn(async move { + let check_interval = std::time::Duration::from_secs(5); + let mut last_seen = 0u64; + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep(check_interval) => { + if start.elapsed() >= max_lifetime { + debug!("QUIC stream exceeded max lifetime, closing"); + break; + } + let current = la_watch.load(Ordering::Relaxed); + if current == last_seen { + let elapsed = start.elapsed().as_millis() as u64 - current; + if elapsed >= inactivity_timeout.as_millis() as u64 { + debug!("QUIC stream inactive for {}ms, closing", elapsed); + break; + } + } + last_seen = current; + } + } + } + conn_cancel.cancel(); + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + c2b_abort.abort(); + b2c_abort.abort(); + }); + + let bytes_in = c2b.await.unwrap_or(0); + let bytes_out = b2c.await.unwrap_or(0); Ok((bytes_in, bytes_out)) } diff --git a/rust/crates/rustproxy-passthrough/src/udp_listener.rs b/rust/crates/rustproxy-passthrough/src/udp_listener.rs index f9697a5..f7e9d08 100644 --- a/rust/crates/rustproxy-passthrough/src/udp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/udp_listener.rs @@ -504,7 +504,29 @@ impl UdpListenerManager { // Only populated when proxy_ips is non-empty. let proxy_addr_map: DashMap = DashMap::new(); + // Periodic cleanup for proxy_addr_map to prevent unbounded growth + let mut last_proxy_cleanup = tokio::time::Instant::now(); + let proxy_cleanup_interval = std::time::Duration::from_secs(60); + loop { + // Periodic cleanup: remove proxy_addr_map entries with no active session + if !proxy_addr_map.is_empty() && last_proxy_cleanup.elapsed() >= proxy_cleanup_interval { + last_proxy_cleanup = tokio::time::Instant::now(); + let stale: Vec = proxy_addr_map.iter() + .filter(|entry| { + let key: SessionKey = (*entry.key(), port); + session_table.get(&key).is_none() + }) + .map(|entry| *entry.key()) + .collect(); + if !stale.is_empty() { + debug!("UDP proxy_addr_map cleanup: removing {} stale entries on port {}", stale.len(), port); + for addr in stale { + proxy_addr_map.remove(&addr); + } + } + } + let (len, client_addr) = tokio::select! { _ = cancel.cancelled() => { debug!("UDP recv loop on port {} cancelled", port); diff --git a/test/core/utils/test.async-utils.ts b/test/core/utils/test.async-utils.ts deleted file mode 100644 index 65f6dd4..0000000 --- a/test/core/utils/test.async-utils.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { - delay, - retryWithBackoff, - withTimeout, - parallelLimit, - debounceAsync, - AsyncMutex, - CircuitBreaker -} from '../../../ts/core/utils/async-utils.js'; - -tap.test('delay should pause execution for specified milliseconds', async () => { - const startTime = Date.now(); - await delay(100); - const elapsed = Date.now() - startTime; - - // Allow some tolerance for timing - expect(elapsed).toBeGreaterThan(90); - expect(elapsed).toBeLessThan(150); -}); - -tap.test('retryWithBackoff should retry failed operations', async () => { - let attempts = 0; - const operation = async () => { - attempts++; - if (attempts < 3) { - throw new Error('Test error'); - } - return 'success'; - }; - - const result = await retryWithBackoff(operation, { - maxAttempts: 3, - initialDelay: 10 - }); - - expect(result).toEqual('success'); - expect(attempts).toEqual(3); -}); - -tap.test('retryWithBackoff should throw after max attempts', async () => { - let attempts = 0; - const operation = async () => { - attempts++; - throw new Error('Always fails'); - }; - - let error: Error | null = null; - try { - await retryWithBackoff(operation, { - maxAttempts: 2, - initialDelay: 10 - }); - } catch (e: any) { - error = e; - } - - expect(error).not.toBeNull(); - expect(error?.message).toEqual('Always fails'); - expect(attempts).toEqual(2); -}); - -tap.test('withTimeout should complete operations within timeout', async () => { - const operation = async () => { - await delay(50); - return 'completed'; - }; - - const result = await withTimeout(operation, 100); - expect(result).toEqual('completed'); -}); - -tap.test('withTimeout should throw on timeout', async () => { - const operation = async () => { - await delay(200); - return 'never happens'; - }; - - let error: Error | null = null; - try { - await withTimeout(operation, 50); - } catch (e: any) { - error = e; - } - - expect(error).not.toBeNull(); - expect(error?.message).toContain('timed out'); -}); - -tap.test('parallelLimit should respect concurrency limit', async () => { - let concurrent = 0; - let maxConcurrent = 0; - - const items = [1, 2, 3, 4, 5, 6]; - const operation = async (item: number) => { - concurrent++; - maxConcurrent = Math.max(maxConcurrent, concurrent); - await delay(50); - concurrent--; - return item * 2; - }; - - const results = await parallelLimit(items, operation, 2); - - expect(results).toEqual([2, 4, 6, 8, 10, 12]); - expect(maxConcurrent).toBeLessThan(3); - expect(maxConcurrent).toBeGreaterThan(0); -}); - -tap.test('debounceAsync should debounce function calls', async () => { - let callCount = 0; - const fn = async (value: string) => { - callCount++; - return value; - }; - - const debounced = debounceAsync(fn, 50); - - // Make multiple calls quickly - debounced('a'); - debounced('b'); - debounced('c'); - const result = await debounced('d'); - - // Wait a bit to ensure no more calls - await delay(100); - - expect(result).toEqual('d'); - expect(callCount).toEqual(1); // Only the last call should execute -}); - -tap.test('AsyncMutex should ensure exclusive access', async () => { - const mutex = new AsyncMutex(); - const results: number[] = []; - - const operation = async (value: number) => { - await mutex.runExclusive(async () => { - results.push(value); - await delay(10); - results.push(value * 10); - }); - }; - - // Run operations concurrently - await Promise.all([ - operation(1), - operation(2), - operation(3) - ]); - - // Results should show sequential execution - expect(results).toEqual([1, 10, 2, 20, 3, 30]); -}); - -tap.test('CircuitBreaker should open after failures', async () => { - const breaker = new CircuitBreaker({ - failureThreshold: 2, - resetTimeout: 100 - }); - - let attempt = 0; - const failingOperation = async () => { - attempt++; - throw new Error('Test failure'); - }; - - // First two failures - for (let i = 0; i < 2; i++) { - try { - await breaker.execute(failingOperation); - } catch (e) { - // Expected - } - } - - expect(breaker.isOpen()).toBeTrue(); - - // Next attempt should fail immediately - let error: Error | null = null; - try { - await breaker.execute(failingOperation); - } catch (e: any) { - error = e; - } - - expect(error?.message).toEqual('Circuit breaker is open'); - expect(attempt).toEqual(2); // Operation not called when circuit is open - - // Wait for reset timeout - await delay(150); - - // Circuit should be half-open now, allowing one attempt - const successOperation = async () => 'success'; - const result = await breaker.execute(successOperation); - - expect(result).toEqual('success'); - expect(breaker.getState()).toEqual('closed'); -}); - -tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.binary-heap.ts b/test/core/utils/test.binary-heap.ts deleted file mode 100644 index bfd78d5..0000000 --- a/test/core/utils/test.binary-heap.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { BinaryHeap } from '../../../ts/core/utils/binary-heap.js'; - -interface TestItem { - id: string; - priority: number; - value: string; -} - -tap.test('should create empty heap', async () => { - const heap = new BinaryHeap((a, b) => a - b); - - expect(heap.size).toEqual(0); - expect(heap.isEmpty()).toBeTrue(); - expect(heap.peek()).toBeUndefined(); -}); - -tap.test('should insert and extract in correct order', async () => { - const heap = new BinaryHeap((a, b) => a - b); - - heap.insert(5); - heap.insert(3); - heap.insert(7); - heap.insert(1); - heap.insert(9); - heap.insert(4); - - expect(heap.size).toEqual(6); - - // Extract in ascending order - expect(heap.extract()).toEqual(1); - expect(heap.extract()).toEqual(3); - expect(heap.extract()).toEqual(4); - expect(heap.extract()).toEqual(5); - expect(heap.extract()).toEqual(7); - expect(heap.extract()).toEqual(9); - expect(heap.extract()).toBeUndefined(); -}); - -tap.test('should work with custom objects and comparator', async () => { - const heap = new BinaryHeap( - (a, b) => a.priority - b.priority, - (item) => item.id - ); - - heap.insert({ id: 'a', priority: 5, value: 'five' }); - heap.insert({ id: 'b', priority: 2, value: 'two' }); - heap.insert({ id: 'c', priority: 8, value: 'eight' }); - heap.insert({ id: 'd', priority: 1, value: 'one' }); - - const first = heap.extract(); - expect(first?.priority).toEqual(1); - expect(first?.value).toEqual('one'); - - const second = heap.extract(); - expect(second?.priority).toEqual(2); - expect(second?.value).toEqual('two'); -}); - -tap.test('should support reverse order (max heap)', async () => { - const heap = new BinaryHeap((a, b) => b - a); - - heap.insert(5); - heap.insert(3); - heap.insert(7); - heap.insert(1); - heap.insert(9); - - // Extract in descending order - expect(heap.extract()).toEqual(9); - expect(heap.extract()).toEqual(7); - expect(heap.extract()).toEqual(5); -}); - -tap.test('should extract by predicate', async () => { - const heap = new BinaryHeap((a, b) => a.priority - b.priority); - - heap.insert({ id: 'a', priority: 5, value: 'five' }); - heap.insert({ id: 'b', priority: 2, value: 'two' }); - heap.insert({ id: 'c', priority: 8, value: 'eight' }); - - const extracted = heap.extractIf(item => item.id === 'b'); - expect(extracted?.id).toEqual('b'); - expect(heap.size).toEqual(2); - - // Should not find it again - const notFound = heap.extractIf(item => item.id === 'b'); - expect(notFound).toBeUndefined(); -}); - -tap.test('should extract by key', async () => { - const heap = new BinaryHeap( - (a, b) => a.priority - b.priority, - (item) => item.id - ); - - heap.insert({ id: 'a', priority: 5, value: 'five' }); - heap.insert({ id: 'b', priority: 2, value: 'two' }); - heap.insert({ id: 'c', priority: 8, value: 'eight' }); - - expect(heap.hasKey('b')).toBeTrue(); - - const extracted = heap.extractByKey('b'); - expect(extracted?.id).toEqual('b'); - expect(heap.size).toEqual(2); - expect(heap.hasKey('b')).toBeFalse(); - - // Should not find it again - const notFound = heap.extractByKey('b'); - expect(notFound).toBeUndefined(); -}); - -tap.test('should throw when using key operations without extractKey', async () => { - const heap = new BinaryHeap((a, b) => a.priority - b.priority); - - heap.insert({ id: 'a', priority: 5, value: 'five' }); - - let error: Error | null = null; - try { - heap.extractByKey('a'); - } catch (e: any) { - error = e; - } - - expect(error).not.toBeNull(); - expect(error?.message).toContain('extractKey function must be provided'); -}); - -tap.test('should handle duplicates correctly', async () => { - const heap = new BinaryHeap((a, b) => a - b); - - heap.insert(5); - heap.insert(5); - heap.insert(5); - heap.insert(3); - heap.insert(7); - - expect(heap.size).toEqual(5); - expect(heap.extract()).toEqual(3); - expect(heap.extract()).toEqual(5); - expect(heap.extract()).toEqual(5); - expect(heap.extract()).toEqual(5); - expect(heap.extract()).toEqual(7); -}); - -tap.test('should convert to array without modifying heap', async () => { - const heap = new BinaryHeap((a, b) => a - b); - - heap.insert(5); - heap.insert(3); - heap.insert(7); - - const array = heap.toArray(); - expect(array).toContain(3); - expect(array).toContain(5); - expect(array).toContain(7); - expect(array.length).toEqual(3); - - // Heap should still be intact - expect(heap.size).toEqual(3); - expect(heap.extract()).toEqual(3); -}); - -tap.test('should clear the heap', async () => { - const heap = new BinaryHeap( - (a, b) => a.priority - b.priority, - (item) => item.id - ); - - heap.insert({ id: 'a', priority: 5, value: 'five' }); - heap.insert({ id: 'b', priority: 2, value: 'two' }); - - expect(heap.size).toEqual(2); - expect(heap.hasKey('a')).toBeTrue(); - - heap.clear(); - - expect(heap.size).toEqual(0); - expect(heap.isEmpty()).toBeTrue(); - expect(heap.hasKey('a')).toBeFalse(); -}); - -tap.test('should handle complex extraction patterns', async () => { - const heap = new BinaryHeap((a, b) => a - b); - - // Insert numbers 1-10 in random order - [8, 3, 5, 9, 1, 7, 4, 10, 2, 6].forEach(n => heap.insert(n)); - - // Extract some in order - expect(heap.extract()).toEqual(1); - expect(heap.extract()).toEqual(2); - - // Insert more - heap.insert(0); - heap.insert(1.5); - - // Continue extracting - expect(heap.extract()).toEqual(0); - expect(heap.extract()).toEqual(1.5); - expect(heap.extract()).toEqual(3); - - // Verify remaining size (10 - 2 extracted + 2 inserted - 3 extracted = 7) - expect(heap.size).toEqual(7); -}); - -tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.fs-utils.ts b/test/core/utils/test.fs-utils.ts deleted file mode 100644 index 3959eed..0000000 --- a/test/core/utils/test.fs-utils.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as path from 'path'; -import { AsyncFileSystem } from '../../../ts/core/utils/fs-utils.js'; - -// Use a temporary directory for tests -const testDir = path.join(process.cwd(), '.nogit', 'test-fs-utils'); -const testFile = path.join(testDir, 'test.txt'); -const testJsonFile = path.join(testDir, 'test.json'); - -tap.test('should create and check directory existence', async () => { - // Ensure directory - await AsyncFileSystem.ensureDir(testDir); - - // Check it exists - const exists = await AsyncFileSystem.exists(testDir); - expect(exists).toBeTrue(); - - // Check it's a directory - const isDir = await AsyncFileSystem.isDirectory(testDir); - expect(isDir).toBeTrue(); -}); - -tap.test('should write and read text files', async () => { - const testContent = 'Hello, async filesystem!'; - - // Write file - await AsyncFileSystem.writeFile(testFile, testContent); - - // Check file exists - const exists = await AsyncFileSystem.exists(testFile); - expect(exists).toBeTrue(); - - // Read file - const content = await AsyncFileSystem.readFile(testFile); - expect(content).toEqual(testContent); - - // Check it's a file - const isFile = await AsyncFileSystem.isFile(testFile); - expect(isFile).toBeTrue(); -}); - -tap.test('should write and read JSON files', async () => { - const testData = { - name: 'Test', - value: 42, - nested: { - array: [1, 2, 3] - } - }; - - // Write JSON - await AsyncFileSystem.writeJSON(testJsonFile, testData); - - // Read JSON - const readData = await AsyncFileSystem.readJSON(testJsonFile); - expect(readData).toEqual(testData); -}); - -tap.test('should copy files', async () => { - const copyFile = path.join(testDir, 'copy.txt'); - - // Copy file - await AsyncFileSystem.copyFile(testFile, copyFile); - - // Check copy exists - const exists = await AsyncFileSystem.exists(copyFile); - expect(exists).toBeTrue(); - - // Check content matches - const content = await AsyncFileSystem.readFile(copyFile); - const originalContent = await AsyncFileSystem.readFile(testFile); - expect(content).toEqual(originalContent); -}); - -tap.test('should move files', async () => { - const moveFile = path.join(testDir, 'moved.txt'); - const copyFile = path.join(testDir, 'copy.txt'); - - // Move file - await AsyncFileSystem.moveFile(copyFile, moveFile); - - // Check moved file exists - const movedExists = await AsyncFileSystem.exists(moveFile); - expect(movedExists).toBeTrue(); - - // Check original doesn't exist - const originalExists = await AsyncFileSystem.exists(copyFile); - expect(originalExists).toBeFalse(); -}); - -tap.test('should list files in directory', async () => { - const files = await AsyncFileSystem.listFiles(testDir); - - expect(files).toContain('test.txt'); - expect(files).toContain('test.json'); - expect(files).toContain('moved.txt'); -}); - -tap.test('should list files with full paths', async () => { - const files = await AsyncFileSystem.listFilesFullPath(testDir); - - const fileNames = files.map(f => path.basename(f)); - expect(fileNames).toContain('test.txt'); - expect(fileNames).toContain('test.json'); - - // All paths should be absolute - files.forEach(file => { - expect(path.isAbsolute(file)).toBeTrue(); - }); -}); - -tap.test('should get file stats', async () => { - const stats = await AsyncFileSystem.getStats(testFile); - - expect(stats).not.toBeNull(); - expect(stats?.isFile()).toBeTrue(); - expect(stats?.size).toBeGreaterThan(0); -}); - -tap.test('should handle non-existent files gracefully', async () => { - const nonExistent = path.join(testDir, 'does-not-exist.txt'); - - // Check existence - const exists = await AsyncFileSystem.exists(nonExistent); - expect(exists).toBeFalse(); - - // Get stats should return null - const stats = await AsyncFileSystem.getStats(nonExistent); - expect(stats).toBeNull(); - - // Remove should not throw - await AsyncFileSystem.remove(nonExistent); -}); - -tap.test('should remove files', async () => { - // Remove a file - await AsyncFileSystem.remove(testFile); - - // Check it's gone - const exists = await AsyncFileSystem.exists(testFile); - expect(exists).toBeFalse(); -}); - -tap.test('should ensure file exists', async () => { - const ensureFile = path.join(testDir, 'ensure.txt'); - - // Ensure file - await AsyncFileSystem.ensureFile(ensureFile); - - // Check it exists - const exists = await AsyncFileSystem.exists(ensureFile); - expect(exists).toBeTrue(); - - // Check it's empty - const content = await AsyncFileSystem.readFile(ensureFile); - expect(content).toEqual(''); -}); - -tap.test('should recursively list files', async () => { - // Create subdirectory with file - const subDir = path.join(testDir, 'subdir'); - const subFile = path.join(subDir, 'nested.txt'); - - await AsyncFileSystem.ensureDir(subDir); - await AsyncFileSystem.writeFile(subFile, 'nested content'); - - // List recursively - const files = await AsyncFileSystem.listFilesRecursive(testDir); - - // Should include files from subdirectory - const fileNames = files.map(f => path.relative(testDir, f)); - expect(fileNames).toContain('test.json'); - expect(fileNames).toContain(path.join('subdir', 'nested.txt')); -}); - -tap.test('should clean up test directory', async () => { - // Remove entire test directory - await AsyncFileSystem.removeDir(testDir); - - // Check it's gone - const exists = await AsyncFileSystem.exists(testDir); - expect(exists).toBeFalse(); -}); - -tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.ip-utils.ts b/test/core/utils/test.ip-utils.ts deleted file mode 100644 index 659887d..0000000 --- a/test/core/utils/test.ip-utils.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { IpUtils } from '../../../ts/core/utils/ip-utils.js'; - -tap.test('ip-utils - normalizeIP', async () => { - // IPv4 normalization - const ipv4Variants = IpUtils.normalizeIP('127.0.0.1'); - expect(ipv4Variants).toEqual(['127.0.0.1', '::ffff:127.0.0.1']); - - // IPv6-mapped IPv4 normalization - const ipv6MappedVariants = IpUtils.normalizeIP('::ffff:127.0.0.1'); - expect(ipv6MappedVariants).toEqual(['::ffff:127.0.0.1', '127.0.0.1']); - - // IPv6 normalization - const ipv6Variants = IpUtils.normalizeIP('::1'); - expect(ipv6Variants).toEqual(['::1']); - - // Invalid/empty input handling - expect(IpUtils.normalizeIP('')).toEqual([]); - expect(IpUtils.normalizeIP(null as any)).toEqual([]); - expect(IpUtils.normalizeIP(undefined as any)).toEqual([]); -}); - -tap.test('ip-utils - isGlobIPMatch', async () => { - // Direct matches - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.1'])).toEqual(true); - expect(IpUtils.isGlobIPMatch('::1', ['::1'])).toEqual(true); - - // Wildcard matches - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.*'])).toEqual(true); - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.*.*'])).toEqual(true); - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.*.*.*'])).toEqual(true); - - // IPv4-mapped IPv6 handling - expect(IpUtils.isGlobIPMatch('::ffff:127.0.0.1', ['127.0.0.1'])).toEqual(true); - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['::ffff:127.0.0.1'])).toEqual(true); - - // Match multiple patterns - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['10.0.0.1', '127.0.0.1', '192.168.1.1'])).toEqual(true); - - // Non-matching patterns - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['10.0.0.1'])).toEqual(false); - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['128.0.0.1'])).toEqual(false); - expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.2'])).toEqual(false); - - // Edge cases - expect(IpUtils.isGlobIPMatch('', ['127.0.0.1'])).toEqual(false); - expect(IpUtils.isGlobIPMatch('127.0.0.1', [])).toEqual(false); - expect(IpUtils.isGlobIPMatch('127.0.0.1', null as any)).toEqual(false); - expect(IpUtils.isGlobIPMatch(null as any, ['127.0.0.1'])).toEqual(false); -}); - -tap.test('ip-utils - isIPAuthorized', async () => { - // Basic tests to check the core functionality works - // No restrictions - all IPs allowed - expect(IpUtils.isIPAuthorized('127.0.0.1')).toEqual(true); - - // Basic blocked IP test - const blockedIP = '8.8.8.8'; - const blockedIPs = [blockedIP]; - expect(IpUtils.isIPAuthorized(blockedIP, [], blockedIPs)).toEqual(false); - - // Basic allowed IP test - const allowedIP = '10.0.0.1'; - const allowedIPs = [allowedIP]; - expect(IpUtils.isIPAuthorized(allowedIP, allowedIPs)).toEqual(true); - expect(IpUtils.isIPAuthorized('192.168.1.1', allowedIPs)).toEqual(false); -}); - -tap.test('ip-utils - isPrivateIP', async () => { - // Private IPv4 ranges - expect(IpUtils.isPrivateIP('10.0.0.1')).toEqual(true); - expect(IpUtils.isPrivateIP('172.16.0.1')).toEqual(true); - expect(IpUtils.isPrivateIP('172.31.255.255')).toEqual(true); - expect(IpUtils.isPrivateIP('192.168.0.1')).toEqual(true); - expect(IpUtils.isPrivateIP('127.0.0.1')).toEqual(true); - - // Public IPv4 addresses - expect(IpUtils.isPrivateIP('8.8.8.8')).toEqual(false); - expect(IpUtils.isPrivateIP('203.0.113.1')).toEqual(false); - - // IPv4-mapped IPv6 handling - expect(IpUtils.isPrivateIP('::ffff:10.0.0.1')).toEqual(true); - expect(IpUtils.isPrivateIP('::ffff:8.8.8.8')).toEqual(false); - - // Private IPv6 addresses - expect(IpUtils.isPrivateIP('::1')).toEqual(true); - expect(IpUtils.isPrivateIP('fd00::')).toEqual(true); - expect(IpUtils.isPrivateIP('fe80::1')).toEqual(true); - - // Public IPv6 addresses - expect(IpUtils.isPrivateIP('2001:db8::1')).toEqual(false); - - // Edge cases - expect(IpUtils.isPrivateIP('')).toEqual(false); - expect(IpUtils.isPrivateIP(null as any)).toEqual(false); - expect(IpUtils.isPrivateIP(undefined as any)).toEqual(false); -}); - -tap.test('ip-utils - isPublicIP', async () => { - // Public IPv4 addresses - expect(IpUtils.isPublicIP('8.8.8.8')).toEqual(true); - expect(IpUtils.isPublicIP('203.0.113.1')).toEqual(true); - - // Private IPv4 ranges - expect(IpUtils.isPublicIP('10.0.0.1')).toEqual(false); - expect(IpUtils.isPublicIP('172.16.0.1')).toEqual(false); - expect(IpUtils.isPublicIP('192.168.0.1')).toEqual(false); - expect(IpUtils.isPublicIP('127.0.0.1')).toEqual(false); - - // Public IPv6 addresses - expect(IpUtils.isPublicIP('2001:db8::1')).toEqual(true); - - // Private IPv6 addresses - expect(IpUtils.isPublicIP('::1')).toEqual(false); - expect(IpUtils.isPublicIP('fd00::')).toEqual(false); - expect(IpUtils.isPublicIP('fe80::1')).toEqual(false); - - // Edge cases - the implementation treats these as non-private, which is technically correct but might not be what users expect - const emptyResult = IpUtils.isPublicIP(''); - expect(emptyResult).toEqual(true); - - const nullResult = IpUtils.isPublicIP(null as any); - expect(nullResult).toEqual(true); - - const undefinedResult = IpUtils.isPublicIP(undefined as any); - expect(undefinedResult).toEqual(true); -}); - -tap.test('ip-utils - cidrToGlobPatterns', async () => { - // Class C network - const classC = IpUtils.cidrToGlobPatterns('192.168.1.0/24'); - expect(classC).toEqual(['192.168.1.*']); - - // Class B network - const classB = IpUtils.cidrToGlobPatterns('172.16.0.0/16'); - expect(classB).toEqual(['172.16.*.*']); - - // Class A network - const classA = IpUtils.cidrToGlobPatterns('10.0.0.0/8'); - expect(classA).toEqual(['10.*.*.*']); - - // Small subnet (/28 = 16 addresses) - const smallSubnet = IpUtils.cidrToGlobPatterns('192.168.1.0/28'); - expect(smallSubnet.length).toEqual(16); - expect(smallSubnet).toContain('192.168.1.0'); - expect(smallSubnet).toContain('192.168.1.15'); - - // Invalid inputs - expect(IpUtils.cidrToGlobPatterns('')).toEqual([]); - expect(IpUtils.cidrToGlobPatterns('192.168.1.0')).toEqual([]); - expect(IpUtils.cidrToGlobPatterns('192.168.1.0/')).toEqual([]); - expect(IpUtils.cidrToGlobPatterns('192.168.1.0/33')).toEqual([]); - expect(IpUtils.cidrToGlobPatterns('invalid/24')).toEqual([]); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.lifecycle-component.ts b/test/core/utils/test.lifecycle-component.ts deleted file mode 100644 index 37c8604..0000000 --- a/test/core/utils/test.lifecycle-component.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { LifecycleComponent } from '../../../ts/core/utils/lifecycle-component.js'; -import { EventEmitter } from 'events'; - -// Test implementation of LifecycleComponent -class TestComponent extends LifecycleComponent { - public timerCallCount = 0; - public intervalCallCount = 0; - public cleanupCalled = false; - public testEmitter = new EventEmitter(); - public listenerCallCount = 0; - - constructor() { - super(); - this.setupTimers(); - this.setupListeners(); - } - - private setupTimers() { - // Set up a timeout - this.setTimeout(() => { - this.timerCallCount++; - }, 100); - - // Set up an interval - this.setInterval(() => { - this.intervalCallCount++; - }, 50); - } - - private setupListeners() { - this.addEventListener(this.testEmitter, 'test-event', () => { - this.listenerCallCount++; - }); - } - - protected async onCleanup(): Promise { - this.cleanupCalled = true; - } - - // Expose protected methods for testing - public testSetTimeout(handler: Function, timeout: number): NodeJS.Timeout { - return this.setTimeout(handler, timeout); - } - - public testSetInterval(handler: Function, interval: number): NodeJS.Timeout { - return this.setInterval(handler, interval); - } - - public testClearTimeout(timer: NodeJS.Timeout): void { - return this.clearTimeout(timer); - } - - public testClearInterval(timer: NodeJS.Timeout): void { - return this.clearInterval(timer); - } - - public testAddEventListener(target: any, event: string, handler: Function, options?: { once?: boolean }): void { - return this.addEventListener(target, event, handler, options); - } - - public testIsShuttingDown(): boolean { - return this.isShuttingDownState(); - } -} - -tap.test('should manage timers properly', async () => { - const component = new TestComponent(); - - // Wait for timers to fire - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(component.timerCallCount).toEqual(1); - expect(component.intervalCallCount).toBeGreaterThan(2); - - await component.cleanup(); -}); - -tap.test('should manage event listeners properly', async () => { - const component = new TestComponent(); - - // Emit events - component.testEmitter.emit('test-event'); - component.testEmitter.emit('test-event'); - - expect(component.listenerCallCount).toEqual(2); - - // Cleanup and verify listeners are removed - await component.cleanup(); - - component.testEmitter.emit('test-event'); - expect(component.listenerCallCount).toEqual(2); // Should not increase -}); - -tap.test('should prevent timer execution after cleanup', async () => { - const component = new TestComponent(); - - let laterCallCount = 0; - component.testSetTimeout(() => { - laterCallCount++; - }, 100); - - // Cleanup immediately - await component.cleanup(); - - // Wait for timer that would have fired - await new Promise(resolve => setTimeout(resolve, 150)); - - expect(laterCallCount).toEqual(0); -}); - -tap.test('should handle child components', async () => { - class ParentComponent extends LifecycleComponent { - public child: TestComponent; - - constructor() { - super(); - this.child = new TestComponent(); - this.registerChildComponent(this.child); - } - } - - const parent = new ParentComponent(); - - // Wait for child timers - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(parent.child.timerCallCount).toEqual(1); - - // Cleanup parent should cleanup child - await parent.cleanup(); - - expect(parent.child.cleanupCalled).toBeTrue(); - expect(parent.child.testIsShuttingDown()).toBeTrue(); -}); - -tap.test('should handle multiple cleanup calls gracefully', async () => { - const component = new TestComponent(); - - // Call cleanup multiple times - const promises = [ - component.cleanup(), - component.cleanup(), - component.cleanup() - ]; - - await Promise.all(promises); - - // Should only clean up once - expect(component.cleanupCalled).toBeTrue(); -}); - -tap.test('should clear specific timers', async () => { - const component = new TestComponent(); - - let callCount = 0; - const timer = component.testSetTimeout(() => { - callCount++; - }, 100); - - // Clear the timer - component.testClearTimeout(timer); - - // Wait and verify it didn't fire - await new Promise(resolve => setTimeout(resolve, 150)); - - expect(callCount).toEqual(0); - - await component.cleanup(); -}); - -tap.test('should clear specific intervals', async () => { - const component = new TestComponent(); - - let callCount = 0; - const interval = component.testSetInterval(() => { - callCount++; - }, 50); - - // Let it run a bit - await new Promise(resolve => setTimeout(resolve, 120)); - - const countBeforeClear = callCount; - expect(countBeforeClear).toBeGreaterThan(1); - - // Clear the interval - component.testClearInterval(interval); - - // Wait and verify it stopped - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(callCount).toEqual(countBeforeClear); - - await component.cleanup(); -}); - -tap.test('should handle once event listeners', async () => { - const component = new TestComponent(); - const emitter = new EventEmitter(); - - let callCount = 0; - const handler = () => { - callCount++; - }; - - component.testAddEventListener(emitter, 'once-event', handler, { once: true }); - - // Check listener count before emit - const beforeCount = emitter.listenerCount('once-event'); - expect(beforeCount).toEqual(1); - - // Emit once - the listener should fire and auto-remove - emitter.emit('once-event'); - expect(callCount).toEqual(1); - - // Check listener was auto-removed - const afterCount = emitter.listenerCount('once-event'); - expect(afterCount).toEqual(0); - - // Emit again - should not increase count - emitter.emit('once-event'); - expect(callCount).toEqual(1); - - await component.cleanup(); -}); - -tap.test('should not create timers when shutting down', async () => { - const component = new TestComponent(); - - // Start cleanup - const cleanupPromise = component.cleanup(); - - // Try to create timers during shutdown - let timerFired = false; - let intervalFired = false; - - component.testSetTimeout(() => { - timerFired = true; - }, 10); - - component.testSetInterval(() => { - intervalFired = true; - }, 10); - - await cleanupPromise; - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(timerFired).toBeFalse(); - expect(intervalFired).toBeFalse(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.shared-security-manager.ts b/test/core/utils/test.shared-security-manager.ts deleted file mode 100644 index 0619810..0000000 --- a/test/core/utils/test.shared-security-manager.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { SharedSecurityManager } from '../../../ts/core/utils/shared-security-manager.js'; -import type { IRouteConfig, IRouteContext } from '../../../ts/proxies/smart-proxy/models/route-types.js'; - -// Test security manager -tap.test('Shared Security Manager', async () => { - let securityManager: SharedSecurityManager; - - // Set up a new security manager for each test - securityManager = new SharedSecurityManager({ - maxConnectionsPerIP: 5, - connectionRateLimitPerMinute: 10 - }); - - tap.test('should validate IPs correctly', async () => { - // Should allow IPs under connection limit - expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue(); - - // Track multiple connections - for (let i = 0; i < 4; i++) { - securityManager.trackConnectionByIP('192.168.1.1', `conn_${i}`); - } - - // Should still allow IPs under connection limit - expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue(); - - // Add one more to reach the limit - securityManager.trackConnectionByIP('192.168.1.1', 'conn_4'); - - // Should now block IPs over connection limit - expect(securityManager.validateIP('192.168.1.1').allowed).toBeFalse(); - - // Remove a connection - securityManager.removeConnectionByIP('192.168.1.1', 'conn_0'); - - // Should allow again after connection is removed - expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue(); - }); - - tap.test('should authorize IPs based on allow/block lists', async () => { - // Test with allow list only - expect(securityManager.isIPAuthorized('192.168.1.1', ['192.168.1.*'])).toBeTrue(); - expect(securityManager.isIPAuthorized('192.168.2.1', ['192.168.1.*'])).toBeFalse(); - - // Test with block list - expect(securityManager.isIPAuthorized('192.168.1.5', ['*'], ['192.168.1.5'])).toBeFalse(); - expect(securityManager.isIPAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).toBeTrue(); - - // Test with both allow and block lists - expect(securityManager.isIPAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).toBeTrue(); - expect(securityManager.isIPAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).toBeFalse(); - }); - - tap.test('should validate route access', async () => { - const route: IRouteConfig = { - match: { - ports: [8080] - }, - action: { - type: 'forward', - targets: [{ host: 'target.com', port: 443 }] - }, - security: { - ipAllowList: ['10.0.0.*', '192.168.1.*'], - ipBlockList: ['192.168.1.100'], - maxConnections: 3 - } - }; - - const allowedContext: IRouteContext = { - clientIp: '192.168.1.1', - port: 8080, - serverIp: '127.0.0.1', - isTls: false, - timestamp: Date.now(), - connectionId: 'test_conn_1' - }; - - const blockedByIPContext: IRouteContext = { - ...allowedContext, - clientIp: '192.168.1.100' - }; - - const blockedByRangeContext: IRouteContext = { - ...allowedContext, - clientIp: '172.16.0.1' - }; - - const blockedByMaxConnectionsContext: IRouteContext = { - ...allowedContext, - connectionId: 'test_conn_4' - }; - - expect(securityManager.isAllowed(route, allowedContext)).toBeTrue(); - expect(securityManager.isAllowed(route, blockedByIPContext)).toBeFalse(); - expect(securityManager.isAllowed(route, blockedByRangeContext)).toBeFalse(); - - // Test max connections for route - assuming implementation has been updated - if ((securityManager as any).trackConnectionByRoute) { - (securityManager as any).trackConnectionByRoute(route, 'conn_1'); - (securityManager as any).trackConnectionByRoute(route, 'conn_2'); - (securityManager as any).trackConnectionByRoute(route, 'conn_3'); - - // Should now block due to max connections - expect(securityManager.isAllowed(route, blockedByMaxConnectionsContext)).toBeFalse(); - } - }); - - tap.test('should clean up expired entries', async () => { - const route: IRouteConfig = { - match: { - ports: [8080] - }, - action: { - type: 'forward', - targets: [{ host: 'target.com', port: 443 }] - }, - security: { - rateLimit: { - enabled: true, - maxRequests: 5, - window: 60 // 60 seconds - } - } - }; - - const context: IRouteContext = { - clientIp: '192.168.1.1', - port: 8080, - serverIp: '127.0.0.1', - isTls: false, - timestamp: Date.now(), - connectionId: 'test_conn_1' - }; - - // Test rate limiting if method exists - if ((securityManager as any).checkRateLimit) { - // Add 5 attempts (max allowed) - for (let i = 0; i < 5; i++) { - expect((securityManager as any).checkRateLimit(route, context)).toBeTrue(); - } - - // Should now be blocked - expect((securityManager as any).checkRateLimit(route, context)).toBeFalse(); - - // Force cleanup (normally runs periodically) - if ((securityManager as any).cleanup) { - (securityManager as any).cleanup(); - } - - // Should still be blocked since entries are not expired yet - expect((securityManager as any).checkRateLimit(route, context)).toBeFalse(); - } - }); -}); - -// Export test runner -export default tap.start(); \ No newline at end of file diff --git a/test/core/utils/test.validation-utils.ts b/test/core/utils/test.validation-utils.ts deleted file mode 100644 index 34c3a94..0000000 --- a/test/core/utils/test.validation-utils.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { ValidationUtils } from '../../../ts/core/utils/validation-utils.js'; -import type { IDomainOptions, IAcmeOptions } from '../../../ts/core/models/common-types.js'; - -tap.test('validation-utils - isValidPort', async () => { - // Valid port values - expect(ValidationUtils.isValidPort(1)).toEqual(true); - expect(ValidationUtils.isValidPort(80)).toEqual(true); - expect(ValidationUtils.isValidPort(443)).toEqual(true); - expect(ValidationUtils.isValidPort(8080)).toEqual(true); - expect(ValidationUtils.isValidPort(65535)).toEqual(true); - - // Invalid port values - expect(ValidationUtils.isValidPort(0)).toEqual(false); - expect(ValidationUtils.isValidPort(-1)).toEqual(false); - expect(ValidationUtils.isValidPort(65536)).toEqual(false); - expect(ValidationUtils.isValidPort(80.5)).toEqual(false); - expect(ValidationUtils.isValidPort(NaN)).toEqual(false); - expect(ValidationUtils.isValidPort(null as any)).toEqual(false); - expect(ValidationUtils.isValidPort(undefined as any)).toEqual(false); -}); - -tap.test('validation-utils - isValidDomainName', async () => { - // Valid domain names - expect(ValidationUtils.isValidDomainName('example.com')).toEqual(true); - expect(ValidationUtils.isValidDomainName('sub.example.com')).toEqual(true); - expect(ValidationUtils.isValidDomainName('*.example.com')).toEqual(true); - expect(ValidationUtils.isValidDomainName('a-hyphenated-domain.example.com')).toEqual(true); - expect(ValidationUtils.isValidDomainName('example123.com')).toEqual(true); - - // Invalid domain names - expect(ValidationUtils.isValidDomainName('')).toEqual(false); - expect(ValidationUtils.isValidDomainName(null as any)).toEqual(false); - expect(ValidationUtils.isValidDomainName(undefined as any)).toEqual(false); - expect(ValidationUtils.isValidDomainName('-invalid.com')).toEqual(false); - expect(ValidationUtils.isValidDomainName('invalid-.com')).toEqual(false); - expect(ValidationUtils.isValidDomainName('inv@lid.com')).toEqual(false); - expect(ValidationUtils.isValidDomainName('example')).toEqual(false); - expect(ValidationUtils.isValidDomainName('example.')).toEqual(false); -}); - -tap.test('validation-utils - isValidEmail', async () => { - // Valid email addresses - expect(ValidationUtils.isValidEmail('user@example.com')).toEqual(true); - expect(ValidationUtils.isValidEmail('admin@sub.example.com')).toEqual(true); - expect(ValidationUtils.isValidEmail('first.last@example.com')).toEqual(true); - expect(ValidationUtils.isValidEmail('user+tag@example.com')).toEqual(true); - - // Invalid email addresses - expect(ValidationUtils.isValidEmail('')).toEqual(false); - expect(ValidationUtils.isValidEmail(null as any)).toEqual(false); - expect(ValidationUtils.isValidEmail(undefined as any)).toEqual(false); - expect(ValidationUtils.isValidEmail('user')).toEqual(false); - expect(ValidationUtils.isValidEmail('user@')).toEqual(false); - expect(ValidationUtils.isValidEmail('@example.com')).toEqual(false); - expect(ValidationUtils.isValidEmail('user example.com')).toEqual(false); -}); - -tap.test('validation-utils - isValidCertificate', async () => { - // Valid certificate format - const validCert = `-----BEGIN CERTIFICATE----- -MIIDazCCAlOgAwIBAgIUJlq+zz9CO2E91rlD4vhx0CX1Z/kwDQYJKoZIhvcNAQEL -BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM -GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAx -MDEwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw -HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQC0aQeHIV9vQpZ4UVwW/xhx9zl01UbppLXdoqe3NP9x -KfXTCB1YbtJ4GgKIlQqHGLGsLI5ZOE7KxmJeGEwK7ueP4f3WkUlM5C5yTbZ5hSUo -R+OFnszFRJJiBXJlw57YAW9+zqKQHYxwve64O64dlgw6pekDYJhXtrUUZ78Lz0GX -veJvCrci1M4Xk6/7/p1Ii9PNmbPKqHafdmkFLf6TXiWPuRDhPuHW7cXyE8xD5ahr -NsDuwJyRUk+GS4/oJg0TqLSiD0IPxDH50V5MSfUIB82i+lc1t+OAGwLhjUDuQmJi -Pv1+9Zvv+HA5PXBCsGXnSADrOOUO6t9q5R9PXbSvAgMBAAGjUzBRMB0GA1UdDgQW -BBQEtdtBhH/z1XyIf+y+5O9ErDGCVjAfBgNVHSMEGDAWgBQEtdtBhH/z1XyIf+y+ -5O9ErDGCVjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBmJyQ0 -r0pBJkYJJVDJ6i3WMoEEFTD8MEUkWxASHRnuMzm7XlZ8WS1HvbEWF0+WfJPCYHnk -tGbvUFGaZ4qUxZ4Ip2mvKXoeYTJCZRxxhHeSVWnZZu0KS3X7xVAFwQYQNhdLOqP8 -XOHyLhHV/1/kcFd3GvKKjXxE79jUUZ/RXHZ/IY50KvxGzWc/5ZOFYrPEW1/rNlRo -7ixXo1hNnBQsG1YoFAxTBGegdTFJeTYHYjZZ5XlRvY2aBq6QveRbJGJLcPm1UQMd -HQYxacbWSVAQf3ltYwSH+y3a97C5OsJJiQXpRRJlQKL3txklzcpg3E5swhr63bM2 -jUoNXr5G5Q5h3GD5 ------END CERTIFICATE-----`; - - expect(ValidationUtils.isValidCertificate(validCert)).toEqual(true); - - // Invalid certificate format - expect(ValidationUtils.isValidCertificate('')).toEqual(false); - expect(ValidationUtils.isValidCertificate(null as any)).toEqual(false); - expect(ValidationUtils.isValidCertificate(undefined as any)).toEqual(false); - expect(ValidationUtils.isValidCertificate('invalid certificate')).toEqual(false); - expect(ValidationUtils.isValidCertificate('-----BEGIN CERTIFICATE-----')).toEqual(false); -}); - -tap.test('validation-utils - isValidPrivateKey', async () => { - // Valid private key format - const validKey = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0aQeHIV9vQpZ4 -UVwW/xhx9zl01UbppLXdoqe3NP9xKfXTCB1YbtJ4GgKIlQqHGLGsLI5ZOE7KxmJe -GEwK7ueP4f3WkUlM5C5yTbZ5hSUoR+OFnszFRJJiBXJlw57YAW9+zqKQHYxwve64 -O64dlgw6pekDYJhXtrUUZ78Lz0GXveJvCrci1M4Xk6/7/p1Ii9PNmbPKqHafdmkF -Lf6TXiWPuRDhPuHW7cXyE8xD5ahrNsDuwJyRUk+GS4/oJg0TqLSiD0IPxDH50V5M -SfUIB82i+lc1t+OAGwLhjUDuQmJiPv1+9Zvv+HA5PXBCsGXnSADrOOUO6t9q5R9P -XbSvAgMBAAECggEADw8Xx9iEv3FvS8hYIRn2ZWM8ObRgbHkFN92NJ/5RvUwgyV03 -gG8GwVN+7IsVLnIQRyIYEGGJ0ZLZFIq7//Jy0jYUgEGLmXxknuZQn1cQEqqYVyBr -G9JrfKkXaDEoP/bZBMvZ0KEO2C9Vq6mY8M0h0GxDT2y6UQnQYjH3+H6Rvhbhh+Ld -n8lCJqWoW1t9GOUZ4xLsZ5jEDibcMJJzLBWYRxgHWyECK31/VtEQDKFiUcymrJ3I -/zoDEDGbp1gdJHvlCxfSLJ2za7ErtRKRXYFRhZ9QkNSXl1pVFMqRQkedXIcA1/Cs -VpUxiIE2JA3hSrv2csjmXoGJKDLVCvZ3CFxKL3u/AQKBgQDf6MxHXN3IDuJNrJP7 -0gyRbO5d6vcvP/8qiYjtEt2xB2MNt5jDz9Bxl6aKEdNW2+UE0rvXXT6KAMZv9LiF -hxr5qiJmmSB8OeGfr0W4FCixGN4BkRNwfT1gUqZgQOrfMOLHNXOksc1CJwHJfROV -h6AH+gjtF2BCXnVEHcqtRklk4QKBgQDOOYnLJn1CwgFAyRUYK8LQYKnrLp2cGn7N -YH0SLf+VnCu7RCeNr3dm9FoHBCynjkx+qv9kGvCaJuZqEJ7+7IimNUZfDjwXTOJ+ -pzs8kEPN5EQOcbkmYCTQyOA0YeBuEXcv5xIZRZUYQvKg1xXOe/JhAQ4siVIMhgQL -2XR3QwzRDwKBgB7rjZs2VYnuVExGr74lUUAGoZ71WCgt9Du9aYGJfNUriDtTEWAd -VT5sKgVqpRwkY/zXujdxGr+K8DZu4vSdHBLcDLQsEBvRZIILTzjwXBRPGMnVe95v -Q90+vytbmHshlkbMaVRNQxCjdbf7LbQbLecgRt+5BKxHVwL4u3BZNIqhAoGAas4f -PoPOdFfKAMKZL7FLGMhEXLyFsg1JcGRfmByxTNgOJKXpYv5Hl7JLYOvfaiUOUYKI -5Dnh5yLdFOaOjnB3iP0KEiSVEwZK0/Vna5JkzFTqImK9QD3SQCtQLXHJLD52EPFR -9gRa8N5k68+mIzGDEzPBoC1AajbXFGPxNOwaQQ0CgYEAq0dPYK0TTv3Yez27LzVy -RbHkwpE+df4+KhpHbCzUKzfQYo4WTahlR6IzhpOyVQKIptkjuTDyQzkmt0tXEGw3 -/M3yHa1FcY9IzPrHXHJoOeU1r9ay0GOQUi4FxKkYYWxUCtjOi5xlUxI0ABD8vGGR -QbKMrQXRgLd/84nDnY2cYzA= ------END PRIVATE KEY-----`; - - expect(ValidationUtils.isValidPrivateKey(validKey)).toEqual(true); - - // Invalid private key format - expect(ValidationUtils.isValidPrivateKey('')).toEqual(false); - expect(ValidationUtils.isValidPrivateKey(null as any)).toEqual(false); - expect(ValidationUtils.isValidPrivateKey(undefined as any)).toEqual(false); - expect(ValidationUtils.isValidPrivateKey('invalid key')).toEqual(false); - expect(ValidationUtils.isValidPrivateKey('-----BEGIN PRIVATE KEY-----')).toEqual(false); -}); - -tap.test('validation-utils - validateDomainOptions', async () => { - // Valid domain options - const validDomainOptions: IDomainOptions = { - domainName: 'example.com', - sslRedirect: true, - acmeMaintenance: true - }; - - expect(ValidationUtils.validateDomainOptions(validDomainOptions).isValid).toEqual(true); - - // Valid domain options with forward - const validDomainOptionsWithForward: IDomainOptions = { - domainName: 'example.com', - sslRedirect: true, - acmeMaintenance: true, - forward: { - ip: '127.0.0.1', - port: 8080 - } - }; - - expect(ValidationUtils.validateDomainOptions(validDomainOptionsWithForward).isValid).toEqual(true); - - // Invalid domain options - no domain name - const invalidDomainOptions1: IDomainOptions = { - domainName: '', - sslRedirect: true, - acmeMaintenance: true - }; - - expect(ValidationUtils.validateDomainOptions(invalidDomainOptions1).isValid).toEqual(false); - - // Invalid domain options - invalid domain name - const invalidDomainOptions2: IDomainOptions = { - domainName: 'inv@lid.com', - sslRedirect: true, - acmeMaintenance: true - }; - - expect(ValidationUtils.validateDomainOptions(invalidDomainOptions2).isValid).toEqual(false); - - // Invalid domain options - forward missing ip - const invalidDomainOptions3: IDomainOptions = { - domainName: 'example.com', - sslRedirect: true, - acmeMaintenance: true, - forward: { - ip: '', - port: 8080 - } - }; - - expect(ValidationUtils.validateDomainOptions(invalidDomainOptions3).isValid).toEqual(false); - - // Invalid domain options - forward missing port - const invalidDomainOptions4: IDomainOptions = { - domainName: 'example.com', - sslRedirect: true, - acmeMaintenance: true, - forward: { - ip: '127.0.0.1', - port: null as any - } - }; - - expect(ValidationUtils.validateDomainOptions(invalidDomainOptions4).isValid).toEqual(false); - - // Invalid domain options - invalid forward port - const invalidDomainOptions5: IDomainOptions = { - domainName: 'example.com', - sslRedirect: true, - acmeMaintenance: true, - forward: { - ip: '127.0.0.1', - port: 99999 - } - }; - - expect(ValidationUtils.validateDomainOptions(invalidDomainOptions5).isValid).toEqual(false); -}); - -tap.test('validation-utils - validateAcmeOptions', async () => { - // Valid ACME options - const validAcmeOptions: IAcmeOptions = { - enabled: true, - accountEmail: 'admin@example.com', - port: 80, - httpsRedirectPort: 443, - useProduction: false, - renewThresholdDays: 30, - renewCheckIntervalHours: 24, - certificateStore: './certs' - }; - - expect(ValidationUtils.validateAcmeOptions(validAcmeOptions).isValid).toEqual(true); - - // ACME disabled - should be valid regardless of other options - const disabledAcmeOptions: IAcmeOptions = { - enabled: false - }; - - // Don't need to verify other fields when ACME is disabled - const disabledResult = ValidationUtils.validateAcmeOptions(disabledAcmeOptions); - expect(disabledResult.isValid).toEqual(true); - - // Invalid ACME options - missing email - const invalidAcmeOptions1: IAcmeOptions = { - enabled: true, - accountEmail: '', - port: 80 - }; - - expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions1).isValid).toEqual(false); - - // Invalid ACME options - invalid email - const invalidAcmeOptions2: IAcmeOptions = { - enabled: true, - accountEmail: 'invalid-email', - port: 80 - }; - - expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions2).isValid).toEqual(false); - - // Invalid ACME options - invalid port - const invalidAcmeOptions3: IAcmeOptions = { - enabled: true, - accountEmail: 'admin@example.com', - port: 99999 - }; - - expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions3).isValid).toEqual(false); - - // Invalid ACME options - invalid HTTPS redirect port - const invalidAcmeOptions4: IAcmeOptions = { - enabled: true, - accountEmail: 'admin@example.com', - port: 80, - httpsRedirectPort: -1 - }; - - expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions4).isValid).toEqual(false); - - // Invalid ACME options - invalid renew threshold days - const invalidAcmeOptions5: IAcmeOptions = { - enabled: true, - accountEmail: 'admin@example.com', - port: 80, - renewThresholdDays: 0 - }; - - // The implementation allows renewThresholdDays of 0, even though the docstring suggests otherwise - const validationResult5 = ValidationUtils.validateAcmeOptions(invalidAcmeOptions5); - expect(validationResult5.isValid).toEqual(true); - - // Invalid ACME options - invalid renew check interval hours - const invalidAcmeOptions6: IAcmeOptions = { - enabled: true, - accountEmail: 'admin@example.com', - port: 80, - renewCheckIntervalHours: 0 - }; - - // The implementation should validate this, but let's check the actual result - const checkIntervalResult = ValidationUtils.validateAcmeOptions(invalidAcmeOptions6); - // Adjust test to match actual implementation behavior - expect(checkIntervalResult.isValid !== false ? true : false).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.detection.ts b/test/test.detection.ts deleted file mode 100644 index 77ec1d4..0000000 --- a/test/test.detection.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartproxy from '../ts/index.js'; - -tap.test('Protocol Detection - TLS Detection', async () => { - // Test TLS handshake detection - const tlsHandshake = Buffer.from([ - 0x16, // Handshake record type - 0x03, 0x01, // TLS 1.0 - 0x00, 0x05, // Length: 5 bytes - 0x01, // ClientHello - 0x00, 0x00, 0x01, 0x00 // Handshake length and data - ]); - - const detector = new smartproxy.detection.TlsDetector(); - expect(detector.canHandle(tlsHandshake)).toEqual(true); - - const result = detector.detect(tlsHandshake); - expect(result).toBeDefined(); - expect(result?.protocol).toEqual('tls'); - expect(result?.connectionInfo.tlsVersion).toEqual('TLSv1.0'); -}); - -tap.test('Protocol Detection - HTTP Detection', async () => { - // Test HTTP request detection - const httpRequest = Buffer.from( - 'GET /test HTTP/1.1\r\n' + - 'Host: example.com\r\n' + - 'User-Agent: TestClient/1.0\r\n' + - '\r\n' - ); - - const detector = new smartproxy.detection.HttpDetector(); - expect(detector.canHandle(httpRequest)).toEqual(true); - - const result = detector.detect(httpRequest); - expect(result).toBeDefined(); - expect(result?.protocol).toEqual('http'); - expect(result?.connectionInfo.method).toEqual('GET'); - expect(result?.connectionInfo.path).toEqual('/test'); - expect(result?.connectionInfo.domain).toEqual('example.com'); -}); - -tap.test('Protocol Detection - Main Detector TLS', async () => { - const tlsHandshake = Buffer.from([ - 0x16, // Handshake record type - 0x03, 0x03, // TLS 1.2 - 0x00, 0x05, // Length: 5 bytes - 0x01, // ClientHello - 0x00, 0x00, 0x01, 0x00 // Handshake length and data - ]); - - const result = await smartproxy.detection.ProtocolDetector.detect(tlsHandshake); - expect(result.protocol).toEqual('tls'); - expect(result.connectionInfo.tlsVersion).toEqual('TLSv1.2'); -}); - -tap.test('Protocol Detection - Main Detector HTTP', async () => { - const httpRequest = Buffer.from( - 'POST /api/test HTTP/1.1\r\n' + - 'Host: api.example.com\r\n' + - 'Content-Type: application/json\r\n' + - 'Content-Length: 2\r\n' + - '\r\n' + - '{}' - ); - - const result = await smartproxy.detection.ProtocolDetector.detect(httpRequest); - expect(result.protocol).toEqual('http'); - expect(result.connectionInfo.method).toEqual('POST'); - expect(result.connectionInfo.path).toEqual('/api/test'); - expect(result.connectionInfo.domain).toEqual('api.example.com'); -}); - -tap.test('Protocol Detection - Unknown Protocol', async () => { - const unknownData = Buffer.from('UNKNOWN PROTOCOL DATA\r\n'); - - const result = await smartproxy.detection.ProtocolDetector.detect(unknownData); - expect(result.protocol).toEqual('unknown'); - expect(result.isComplete).toEqual(true); -}); - -tap.test('Protocol Detection - Fragmented HTTP', async () => { - // Create connection context - const context = smartproxy.detection.ProtocolDetector.createConnectionContext({ - sourceIp: '127.0.0.1', - sourcePort: 12345, - destIp: '127.0.0.1', - destPort: 80, - socketId: 'test-connection-1' - }); - - // First fragment - const fragment1 = Buffer.from('GET /test HT'); - let result = await smartproxy.detection.ProtocolDetector.detectWithContext( - fragment1, - context - ); - expect(result.protocol).toEqual('http'); - expect(result.isComplete).toEqual(false); - - // Second fragment - const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n'); - result = await smartproxy.detection.ProtocolDetector.detectWithContext( - fragment2, - context - ); - expect(result.protocol).toEqual('http'); - expect(result.isComplete).toEqual(true); - expect(result.connectionInfo.method).toEqual('GET'); - expect(result.connectionInfo.path).toEqual('/test'); - expect(result.connectionInfo.domain).toEqual('example.com'); - - // Clean up fragments - smartproxy.detection.ProtocolDetector.cleanupConnection(context); -}); - -tap.test('Protocol Detection - HTTP Methods', async () => { - const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; - - for (const method of methods) { - const request = Buffer.from( - `${method} /test HTTP/1.1\r\n` + - 'Host: example.com\r\n' + - '\r\n' - ); - - const detector = new smartproxy.detection.HttpDetector(); - const result = detector.detect(request); - expect(result?.connectionInfo.method).toEqual(method); - } -}); - -tap.test('Protocol Detection - Invalid Data', async () => { - // Binary data that's not a valid protocol - const binaryData = Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0xFB]); - - const result = await smartproxy.detection.ProtocolDetector.detect(binaryData); - expect(result.protocol).toEqual('unknown'); -}); - -tap.test('cleanup detection', async () => { - // Clean up the protocol detector instance - smartproxy.detection.ProtocolDetector.destroy(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.ip-validation.ts b/test/test.ip-validation.ts deleted file mode 100644 index 60dea59..0000000 --- a/test/test.ip-validation.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartproxy from '../ts/index.js'; -import { RouteValidator } from '../ts/proxies/smart-proxy/utils/route-validator.js'; -import { IpUtils } from '../ts/core/utils/ip-utils.js'; - -tap.test('IP Validation - Shorthand patterns', async () => { - - // Test shorthand patterns are now accepted - const testPatterns = [ - { pattern: '192.168.*', shouldPass: true }, - { pattern: '192.168.*.*', shouldPass: true }, - { pattern: '10.*', shouldPass: true }, - { pattern: '10.*.*.*', shouldPass: true }, - { pattern: '172.16.*', shouldPass: true }, - { pattern: '10.0.0.0/8', shouldPass: true }, - { pattern: '192.168.0.0/16', shouldPass: true }, - { pattern: '192.168.1.100', shouldPass: true }, - { pattern: '*', shouldPass: true }, - { pattern: '192.168.1.1-192.168.1.100', shouldPass: true }, - ]; - - for (const { pattern, shouldPass } of testPatterns) { - const route = { - name: 'test', - match: { ports: 80 }, - action: { type: 'forward' as const, targets: [{ host: 'localhost', port: 8080 }] }, - security: { ipAllowList: [pattern] } - }; - - const result = RouteValidator.validateRoute(route); - - if (shouldPass) { - expect(result.valid).toEqual(true); - console.log(`✅ Pattern '${pattern}' correctly accepted`); - } else { - expect(result.valid).toEqual(false); - console.log(`✅ Pattern '${pattern}' correctly rejected`); - } - } -}); - -tap.test('IP Matching - Runtime shorthand pattern matching', async () => { - - // Test runtime matching with shorthand patterns - const testCases = [ - { ip: '192.168.1.100', patterns: ['192.168.*'], expected: true }, - { ip: '192.168.1.100', patterns: ['192.168.1.*'], expected: true }, - { ip: '192.168.1.100', patterns: ['192.168.2.*'], expected: false }, - { ip: '10.0.0.1', patterns: ['10.*'], expected: true }, - { ip: '10.1.2.3', patterns: ['10.*'], expected: true }, - { ip: '172.16.0.1', patterns: ['10.*'], expected: false }, - { ip: '192.168.1.1', patterns: ['192.168.*.*'], expected: true }, - ]; - - for (const { ip, patterns, expected } of testCases) { - const result = IpUtils.isGlobIPMatch(ip, patterns); - expect(result).toEqual(expected); - console.log(`✅ IP ${ip} with pattern ${patterns[0]} = ${result} (expected ${expected})`); - } -}); - -tap.test('IP Matching - CIDR notation', async () => { - - // Test CIDR notation matching - const cidrTests = [ - { ip: '10.0.0.1', cidr: '10.0.0.0/8', expected: true }, - { ip: '10.255.255.255', cidr: '10.0.0.0/8', expected: true }, - { ip: '11.0.0.1', cidr: '10.0.0.0/8', expected: false }, - { ip: '192.168.1.1', cidr: '192.168.0.0/16', expected: true }, - { ip: '192.168.255.255', cidr: '192.168.0.0/16', expected: true }, - { ip: '192.169.0.1', cidr: '192.168.0.0/16', expected: false }, - { ip: '192.168.1.100', cidr: '192.168.1.0/24', expected: true }, - { ip: '192.168.2.100', cidr: '192.168.1.0/24', expected: false }, - ]; - - for (const { ip, cidr, expected } of cidrTests) { - const result = IpUtils.isGlobIPMatch(ip, [cidr]); - expect(result).toEqual(expected); - console.log(`✅ IP ${ip} in CIDR ${cidr} = ${result} (expected ${expected})`); - } -}); - -tap.test('IP Matching - Range notation', async () => { - - // Test range notation matching - const rangeTests = [ - { ip: '192.168.1.1', range: '192.168.1.1-192.168.1.100', expected: true }, - { ip: '192.168.1.50', range: '192.168.1.1-192.168.1.100', expected: true }, - { ip: '192.168.1.100', range: '192.168.1.1-192.168.1.100', expected: true }, - { ip: '192.168.1.101', range: '192.168.1.1-192.168.1.100', expected: false }, - { ip: '192.168.2.50', range: '192.168.1.1-192.168.1.100', expected: false }, - ]; - - for (const { ip, range, expected } of rangeTests) { - const result = IpUtils.isGlobIPMatch(ip, [range]); - expect(result).toEqual(expected); - console.log(`✅ IP ${ip} in range ${range} = ${result} (expected ${expected})`); - } -}); - -tap.test('IP Matching - Mixed patterns', async () => { - - // Test with mixed pattern types - const allowList = [ - '10.0.0.0/8', // CIDR - '192.168.*', // Shorthand glob - '172.16.1.*', // Specific subnet glob - '8.8.8.8', // Single IP - '1.1.1.1-1.1.1.10' // Range - ]; - - const tests = [ - { ip: '10.1.2.3', expected: true }, // Matches CIDR - { ip: '192.168.100.1', expected: true }, // Matches shorthand glob - { ip: '172.16.1.5', expected: true }, // Matches specific glob - { ip: '8.8.8.8', expected: true }, // Matches single IP - { ip: '1.1.1.5', expected: true }, // Matches range - { ip: '9.9.9.9', expected: false }, // Doesn't match any - ]; - - for (const { ip, expected } of tests) { - const result = IpUtils.isGlobIPMatch(ip, allowList); - expect(result).toEqual(expected); - console.log(`✅ IP ${ip} in mixed patterns = ${result} (expected ${expected})`); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.log-deduplication.node.ts b/test/test.log-deduplication.node.ts deleted file mode 100644 index 6ee3f7d..0000000 --- a/test/test.log-deduplication.node.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { LogDeduplicator } from '../ts/core/utils/log-deduplicator.js'; - -let deduplicator: LogDeduplicator; - -tap.test('Setup log deduplicator', async () => { - deduplicator = new LogDeduplicator(1000); // 1 second flush interval for testing -}); - -tap.test('Connection rejection deduplication', async (tools) => { - // Simulate multiple connection rejections - for (let i = 0; i < 10; i++) { - deduplicator.log( - 'connection-rejected', - 'warn', - 'Connection rejected', - { reason: 'global-limit', component: 'test' }, - 'global-limit' - ); - } - - for (let i = 0; i < 5; i++) { - deduplicator.log( - 'connection-rejected', - 'warn', - 'Connection rejected', - { reason: 'route-limit', component: 'test' }, - 'route-limit' - ); - } - - // Force flush - deduplicator.flush('connection-rejected'); - - // The logs should have been aggregated - // (Can't easily test the actual log output, but we can verify the mechanism works) - expect(deduplicator).toBeInstanceOf(LogDeduplicator); -}); - -tap.test('IP rejection deduplication', async (tools) => { - // Simulate rejections from multiple IPs - const ips = ['192.168.1.100', '192.168.1.101', '192.168.1.100', '10.0.0.1']; - const reasons = ['per-ip-limit', 'rate-limit', 'per-ip-limit', 'global-limit']; - - for (let i = 0; i < ips.length; i++) { - deduplicator.log( - 'ip-rejected', - 'warn', - `Connection rejected from ${ips[i]}`, - { remoteIP: ips[i], reason: reasons[i] }, - ips[i] - ); - } - - // Add more rejections from the same IP - for (let i = 0; i < 20; i++) { - deduplicator.log( - 'ip-rejected', - 'warn', - 'Connection rejected from 192.168.1.100', - { remoteIP: '192.168.1.100', reason: 'rate-limit' }, - '192.168.1.100' - ); - } - - // Force flush - deduplicator.flush('ip-rejected'); - - // Verify the deduplicator exists and works - expect(deduplicator).toBeInstanceOf(LogDeduplicator); -}); - -tap.test('Connection cleanup deduplication', async (tools) => { - // Simulate various cleanup events - const reasons = ['normal', 'timeout', 'error', 'normal', 'zombie']; - - for (const reason of reasons) { - for (let i = 0; i < 5; i++) { - deduplicator.log( - 'connection-cleanup', - 'info', - `Connection cleanup: ${reason}`, - { connectionId: `conn-${i}`, reason }, - reason - ); - } - } - - // Wait for automatic flush - await tools.delayFor(1500); - - // Verify deduplicator is working - expect(deduplicator).toBeInstanceOf(LogDeduplicator); -}); - -tap.test('Automatic periodic flush', async (tools) => { - // Add some events - deduplicator.log('test-event', 'info', 'Test message', {}, 'test'); - - // Wait for automatic flush (should happen within 2x flush interval = 2 seconds) - await tools.delayFor(2500); - - // Events should have been flushed automatically - expect(deduplicator).toBeInstanceOf(LogDeduplicator); -}); - -tap.test('Cleanup deduplicator', async () => { - deduplicator.cleanup(); - expect(deduplicator).toBeInstanceOf(LogDeduplicator); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.router.ts b/test/test.router.ts deleted file mode 100644 index 8df87de..0000000 --- a/test/test.router.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as http from 'http'; -import { HttpRouter, type RouterResult } from '../ts/routing/router/http-router.js'; -import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; - -// Test proxies and configurations -let router: HttpRouter; - -// Sample hostname for testing -const TEST_DOMAIN = 'example.com'; -const TEST_SUBDOMAIN = 'api.example.com'; -const TEST_WILDCARD = '*.example.com'; - -// Helper: Creates a mock HTTP request for testing -function createMockRequest(host: string, url: string = '/'): http.IncomingMessage { - const req = { - headers: { host }, - url, - socket: { - remoteAddress: '127.0.0.1' - } - } as any; - return req; -} - -// Helper: Creates a test route configuration -function createRouteConfig( - hostname: string, - destinationIp: string = '10.0.0.1', - destinationPort: number = 8080 -): IRouteConfig { - return { - name: `route-${hostname}`, - match: { - domains: [hostname], - ports: 443 - }, - action: { - type: 'forward', - targets: [{ - host: destinationIp, - port: destinationPort - }] - } - }; -} - -// SETUP: Create an HttpRouter instance -tap.test('setup http router test environment', async () => { - router = new HttpRouter(); - - // Initialize with empty config - router.setRoutes([]); -}); - -// Test basic routing by hostname -tap.test('should route requests by hostname', async () => { - const config = createRouteConfig(TEST_DOMAIN); - router.setRoutes([config]); - - const req = createMockRequest(TEST_DOMAIN); - const result = router.routeReq(req); - - expect(result).toBeTruthy(); - expect(result).toEqual(config); -}); - -// Test handling of hostname with port number -tap.test('should handle hostname with port number', async () => { - const config = createRouteConfig(TEST_DOMAIN); - router.setRoutes([config]); - - const req = createMockRequest(`${TEST_DOMAIN}:443`); - const result = router.routeReq(req); - - expect(result).toBeTruthy(); - expect(result).toEqual(config); -}); - -// Test case-insensitive hostname matching -tap.test('should perform case-insensitive hostname matching', async () => { - const config = createRouteConfig(TEST_DOMAIN.toLowerCase()); - router.setRoutes([config]); - - const req = createMockRequest(TEST_DOMAIN.toUpperCase()); - const result = router.routeReq(req); - - expect(result).toBeTruthy(); - expect(result).toEqual(config); -}); - -// Test handling of unmatched hostnames -tap.test('should return undefined for unmatched hostnames', async () => { - const config = createRouteConfig(TEST_DOMAIN); - router.setRoutes([config]); - - const req = createMockRequest('unknown.domain.com'); - const result = router.routeReq(req); - - expect(result).toBeUndefined(); -}); - -// Test adding path patterns -tap.test('should match requests using path patterns', async () => { - const config = createRouteConfig(TEST_DOMAIN); - config.match.path = '/api/users'; - router.setRoutes([config]); - - // Test that path matches - const req1 = createMockRequest(TEST_DOMAIN, '/api/users'); - const result1 = router.routeReqWithDetails(req1); - - expect(result1).toBeTruthy(); - expect(result1.route).toEqual(config); - expect(result1.pathMatch).toEqual('/api/users'); - - // Test that non-matching path doesn't match - const req2 = createMockRequest(TEST_DOMAIN, '/web/users'); - const result2 = router.routeReqWithDetails(req2); - - expect(result2).toBeUndefined(); -}); - -// Test handling wildcard patterns -tap.test('should support wildcard path patterns', async () => { - const config = createRouteConfig(TEST_DOMAIN); - config.match.path = '/api/*'; - router.setRoutes([config]); - - // Test with path that matches the wildcard pattern - const req = createMockRequest(TEST_DOMAIN, '/api/users/123'); - const result = router.routeReqWithDetails(req); - - expect(result).toBeTruthy(); - expect(result.route).toEqual(config); - expect(result.pathMatch).toEqual('/api'); - - // Print the actual value to diagnose issues - console.log('Path remainder value:', result.pathRemainder); - expect(result.pathRemainder).toBeTruthy(); - expect(result.pathRemainder).toEqual('/users/123'); -}); - -// Test extracting path parameters -tap.test('should extract path parameters from URL', async () => { - const config = createRouteConfig(TEST_DOMAIN); - config.match.path = '/users/:id/profile'; - router.setRoutes([config]); - - const req = createMockRequest(TEST_DOMAIN, '/users/123/profile'); - const result = router.routeReqWithDetails(req); - - expect(result).toBeTruthy(); - expect(result.route).toEqual(config); - expect(result.pathParams).toBeTruthy(); - expect(result.pathParams.id).toEqual('123'); -}); - -// Test multiple configs for same hostname with different paths -tap.test('should support multiple configs for same hostname with different paths', async () => { - const apiConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001); - apiConfig.match.path = '/api/*'; - apiConfig.name = 'api-route'; - - const webConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002); - webConfig.match.path = '/web/*'; - webConfig.name = 'web-route'; - - // Add both configs - router.setRoutes([apiConfig, webConfig]); - - // Test API path routes to API config - const apiReq = createMockRequest(TEST_DOMAIN, '/api/users'); - const apiResult = router.routeReq(apiReq); - - expect(apiResult).toEqual(apiConfig); - - // Test web path routes to web config - const webReq = createMockRequest(TEST_DOMAIN, '/web/dashboard'); - const webResult = router.routeReq(webReq); - - expect(webResult).toEqual(webConfig); - - // Test unknown path returns undefined - const unknownReq = createMockRequest(TEST_DOMAIN, '/unknown'); - const unknownResult = router.routeReq(unknownReq); - - expect(unknownResult).toBeUndefined(); -}); - -// Test wildcard subdomains -tap.test('should match wildcard subdomains', async () => { - const wildcardConfig = createRouteConfig(TEST_WILDCARD); - router.setRoutes([wildcardConfig]); - - // Test that subdomain.example.com matches *.example.com - const req = createMockRequest('subdomain.example.com'); - const result = router.routeReq(req); - - expect(result).toBeTruthy(); - expect(result).toEqual(wildcardConfig); -}); - -// Test TLD wildcards (example.*) -tap.test('should match TLD wildcards', async () => { - const tldWildcardConfig = createRouteConfig('example.*'); - router.setRoutes([tldWildcardConfig]); - - // Test that example.com matches example.* - const req1 = createMockRequest('example.com'); - const result1 = router.routeReq(req1); - expect(result1).toBeTruthy(); - expect(result1).toEqual(tldWildcardConfig); - - // Test that example.org matches example.* - const req2 = createMockRequest('example.org'); - const result2 = router.routeReq(req2); - expect(result2).toBeTruthy(); - expect(result2).toEqual(tldWildcardConfig); - - // Test that subdomain.example.com doesn't match example.* - const req3 = createMockRequest('subdomain.example.com'); - const result3 = router.routeReq(req3); - expect(result3).toBeUndefined(); -}); - -// Test complex pattern matching (*.lossless*) -tap.test('should match complex wildcard patterns', async () => { - const complexWildcardConfig = createRouteConfig('*.lossless*'); - router.setRoutes([complexWildcardConfig]); - - // Test that sub.lossless.com matches *.lossless* - const req1 = createMockRequest('sub.lossless.com'); - const result1 = router.routeReq(req1); - expect(result1).toBeTruthy(); - expect(result1).toEqual(complexWildcardConfig); - - // Test that api.lossless.org matches *.lossless* - const req2 = createMockRequest('api.lossless.org'); - const result2 = router.routeReq(req2); - expect(result2).toBeTruthy(); - expect(result2).toEqual(complexWildcardConfig); - - // Test that losslessapi.com matches *.lossless* - const req3 = createMockRequest('losslessapi.com'); - const result3 = router.routeReq(req3); - expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain -}); - -// Test default configuration fallback -tap.test('should fall back to default configuration', async () => { - const defaultConfig = createRouteConfig('*'); - const specificConfig = createRouteConfig(TEST_DOMAIN); - - router.setRoutes([specificConfig, defaultConfig]); - - // Test specific domain routes to specific config - const specificReq = createMockRequest(TEST_DOMAIN); - const specificResult = router.routeReq(specificReq); - - expect(specificResult).toEqual(specificConfig); - - // Test unknown domain falls back to default config - const unknownReq = createMockRequest('unknown.com'); - const unknownResult = router.routeReq(unknownReq); - - expect(unknownResult).toEqual(defaultConfig); -}); - -// Test priority between exact and wildcard matches -tap.test('should prioritize exact hostname over wildcard', async () => { - const wildcardConfig = createRouteConfig(TEST_WILDCARD); - const exactConfig = createRouteConfig(TEST_SUBDOMAIN); - - router.setRoutes([exactConfig, wildcardConfig]); - - // Test that exact match takes priority - const req = createMockRequest(TEST_SUBDOMAIN); - const result = router.routeReq(req); - - expect(result).toEqual(exactConfig); -}); - -// Test adding and removing configurations -tap.test('should manage configurations correctly', async () => { - router.setRoutes([]); - - // Add a config - const config = createRouteConfig(TEST_DOMAIN); - router.setRoutes([config]); - - // Verify routing works - const req = createMockRequest(TEST_DOMAIN); - let result = router.routeReq(req); - - expect(result).toEqual(config); - - // Remove the config and verify it no longer routes - router.setRoutes([]); - - result = router.routeReq(req); - expect(result).toBeUndefined(); -}); - -// Test path pattern specificity -tap.test('should prioritize more specific path patterns', async () => { - const genericConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001); - genericConfig.match.path = '/api/*'; - genericConfig.name = 'generic-api'; - - const specificConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002); - specificConfig.match.path = '/api/users'; - specificConfig.name = 'specific-api'; - specificConfig.priority = 10; // Higher priority - - router.setRoutes([genericConfig, specificConfig]); - - // The more specific '/api/users' should match before the '/api/*' wildcard - const req = createMockRequest(TEST_DOMAIN, '/api/users'); - const result = router.routeReq(req); - - expect(result).toEqual(specificConfig); -}); - -// Test multiple hostnames -tap.test('should handle multiple configured hostnames', async () => { - const routes = [ - createRouteConfig(TEST_DOMAIN), - createRouteConfig(TEST_SUBDOMAIN) - ]; - router.setRoutes(routes); - - // Test first domain routes correctly - const req1 = createMockRequest(TEST_DOMAIN); - const result1 = router.routeReq(req1); - expect(result1).toEqual(routes[0]); - - // Test second domain routes correctly - const req2 = createMockRequest(TEST_SUBDOMAIN); - const result2 = router.routeReq(req2); - expect(result2).toEqual(routes[1]); -}); - -// Test handling missing host header -tap.test('should handle missing host header', async () => { - const defaultConfig = createRouteConfig('*'); - router.setRoutes([defaultConfig]); - - const req = createMockRequest(''); - req.headers.host = undefined; - - const result = router.routeReq(req); - - expect(result).toEqual(defaultConfig); -}); - -// Test complex path parameters -tap.test('should handle complex path parameters', async () => { - const config = createRouteConfig(TEST_DOMAIN); - config.match.path = '/api/:version/users/:userId/posts/:postId'; - router.setRoutes([config]); - - const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456'); - const result = router.routeReqWithDetails(req); - - expect(result).toBeTruthy(); - expect(result.route).toEqual(config); - expect(result.pathParams).toBeTruthy(); - expect(result.pathParams.version).toEqual('v1'); - expect(result.pathParams.userId).toEqual('123'); - expect(result.pathParams.postId).toEqual('456'); -}); - -// Performance test -tap.test('should handle many configurations efficiently', async () => { - const configs = []; - - // Create many configs with different hostnames - for (let i = 0; i < 100; i++) { - configs.push(createRouteConfig(`host-${i}.example.com`)); - } - - router.setRoutes(configs); - - // Test middle of the list to avoid best/worst case - const req = createMockRequest('host-50.example.com'); - const result = router.routeReq(req); - - expect(result).toEqual(configs[50]); -}); - -// Test cleanup -tap.test('cleanup proxy router test environment', async () => { - // Clear all configurations - router.setRoutes([]); - - // Verify empty state by testing that no routes match - const req = createMockRequest(TEST_DOMAIN); - const result = router.routeReq(req); - expect(result).toBeUndefined(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.shared-security-manager-limits.node.ts b/test/test.shared-security-manager-limits.node.ts deleted file mode 100644 index 071e6f3..0000000 --- a/test/test.shared-security-manager-limits.node.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { SharedSecurityManager } from '../ts/core/utils/shared-security-manager.js'; -import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js'; - -let securityManager: SharedSecurityManager; - -tap.test('Setup SharedSecurityManager', async () => { - securityManager = new SharedSecurityManager({ - maxConnectionsPerIP: 5, - connectionRateLimitPerMinute: 10, - cleanupIntervalMs: 1000 // 1 second for faster testing - }); -}); - -tap.test('IP connection tracking', async () => { - const testIP = '192.168.1.100'; - - // Track multiple connections - securityManager.trackConnectionByIP(testIP, 'conn1'); - securityManager.trackConnectionByIP(testIP, 'conn2'); - securityManager.trackConnectionByIP(testIP, 'conn3'); - - // Verify connection count - expect(securityManager.getConnectionCountByIP(testIP)).toEqual(3); - - // Remove a connection - securityManager.removeConnectionByIP(testIP, 'conn2'); - expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2); - - // Remove remaining connections - securityManager.removeConnectionByIP(testIP, 'conn1'); - securityManager.removeConnectionByIP(testIP, 'conn3'); - expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0); -}); - -tap.test('Per-IP connection limits validation', async () => { - const testIP = '192.168.1.101'; - - // Track connections up to limit - for (let i = 1; i <= 5; i++) { - // Validate BEFORE tracking the connection (checking if we can add a new connection) - const result = securityManager.validateIP(testIP); - expect(result.allowed).toBeTrue(); - // Now track the connection - securityManager.trackConnectionByIP(testIP, `conn${i}`); - } - - // Verify we're at the limit - expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5); - - // Next connection should be rejected (we're already at 5) - const result = securityManager.validateIP(testIP); - expect(result.allowed).toBeFalse(); - expect(result.reason).toInclude('Maximum connections per IP'); - - // Clean up - for (let i = 1; i <= 5; i++) { - securityManager.removeConnectionByIP(testIP, `conn${i}`); - } -}); - -tap.test('Connection rate limiting', async () => { - const testIP = '192.168.1.102'; - - // Make connections at the rate limit - // Note: validateIP() already tracks timestamps internally for rate limiting - for (let i = 0; i < 10; i++) { - const result = securityManager.validateIP(testIP); - expect(result.allowed).toBeTrue(); - } - - // Next connection should exceed rate limit - const result = securityManager.validateIP(testIP); - expect(result.allowed).toBeFalse(); - expect(result.reason).toInclude('Connection rate limit'); -}); - -tap.test('Route-level connection limits', async () => { - const route: IRouteConfig = { - name: 'test-route', - match: { ports: 443 }, - action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] }, - security: { - maxConnections: 3 - } - }; - - const context: IRouteContext = { - port: 443, - clientIp: '192.168.1.103', - serverIp: '0.0.0.0', - timestamp: Date.now(), - connectionId: 'test-conn', - isTls: true - }; - - // Test with connection counts below limit - expect(securityManager.isAllowed(route, context, 0)).toBeTrue(); - expect(securityManager.isAllowed(route, context, 2)).toBeTrue(); - - // Test at limit - expect(securityManager.isAllowed(route, context, 3)).toBeFalse(); - - // Test above limit - expect(securityManager.isAllowed(route, context, 5)).toBeFalse(); -}); - -tap.test('IPv4/IPv6 normalization', async () => { - const ipv4 = '127.0.0.1'; - const ipv4Mapped = '::ffff:127.0.0.1'; - - // Track connection with IPv4 - securityManager.trackConnectionByIP(ipv4, 'conn1'); - - // Both representations should show the same connection - expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(1); - expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(1); - - // Track another connection with IPv6 representation - securityManager.trackConnectionByIP(ipv4Mapped, 'conn2'); - - // Both should show 2 connections - expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(2); - expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(2); - - // Clean up - securityManager.removeConnectionByIP(ipv4, 'conn1'); - securityManager.removeConnectionByIP(ipv4Mapped, 'conn2'); -}); - -tap.test('Automatic cleanup of expired data', async (tools) => { - const testIP = '192.168.1.104'; - - // Track a connection and then remove it - securityManager.trackConnectionByIP(testIP, 'temp-conn'); - securityManager.removeConnectionByIP(testIP, 'temp-conn'); - - // Add some rate limit entries (they expire after 1 minute) - for (let i = 0; i < 5; i++) { - securityManager.validateIP(testIP); - } - - // Wait for cleanup interval (set to 1 second in our test) - await tools.delayFor(1500); - - // The IP should be cleaned up since it has no connections - // Note: We can't directly check the internal map, but we can verify - // that a new connection is allowed (fresh rate limit) - const result = securityManager.validateIP(testIP); - expect(result.allowed).toBeTrue(); -}); - -tap.test('Cleanup SharedSecurityManager', async () => { - securityManager.clearIPTracking(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.wrapped-socket.ts b/test/test.wrapped-socket.ts deleted file mode 100644 index 689c0d5..0000000 --- a/test/test.wrapped-socket.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import { WrappedSocket } from '../ts/core/models/wrapped-socket.js'; -import * as net from 'net'; - -tap.test('WrappedSocket - should wrap a regular socket', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - - // Wrap the socket - const wrappedSocket = new WrappedSocket(clientSocket); - - // Test initial state - should use underlying socket values - expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress); - expect(wrappedSocket.remotePort).toEqual(clientSocket.remotePort); - expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress); - expect(wrappedSocket.localPort).toEqual(clientSocket.localPort); - expect(wrappedSocket.isFromTrustedProxy).toBeFalse(); - - // Clean up - clientSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should provide real client info when set', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - - // Wrap the socket with initial proxy info - const wrappedSocket = new WrappedSocket(clientSocket, '192.168.1.100', 54321); - - // Test that real client info is returned - expect(wrappedSocket.remoteAddress).toEqual('192.168.1.100'); - expect(wrappedSocket.remotePort).toEqual(54321); - expect(wrappedSocket.isFromTrustedProxy).toBeTrue(); - - // Local info should still come from underlying socket - expect(wrappedSocket.localAddress).toEqual(clientSocket.localAddress); - expect(wrappedSocket.localPort).toEqual(clientSocket.localPort); - - // Clean up - clientSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should update proxy info via setProxyInfo', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - - // Wrap the socket without initial proxy info - const wrappedSocket = new WrappedSocket(clientSocket); - - // Initially should use underlying socket - expect(wrappedSocket.isFromTrustedProxy).toBeFalse(); - expect(wrappedSocket.remoteAddress).toEqual(clientSocket.remoteAddress); - - // Update proxy info - wrappedSocket.setProxyInfo('10.0.0.5', 12345); - - // Now should return proxy info - expect(wrappedSocket.remoteAddress).toEqual('10.0.0.5'); - expect(wrappedSocket.remotePort).toEqual(12345); - expect(wrappedSocket.isFromTrustedProxy).toBeTrue(); - - // Clean up - clientSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should correctly determine IP family', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - - // Test IPv4 - const wrappedSocketIPv4 = new WrappedSocket(clientSocket, '192.168.1.1', 80); - expect(wrappedSocketIPv4.remoteFamily).toEqual('IPv4'); - - // Test IPv6 - const wrappedSocketIPv6 = new WrappedSocket(clientSocket, '2001:0db8:85a3:0000:0000:8a2e:0370:7334', 443); - expect(wrappedSocketIPv6.remoteFamily).toEqual('IPv6'); - - // Test fallback to underlying socket - const wrappedSocketNoProxy = new WrappedSocket(clientSocket); - expect(wrappedSocketNoProxy.remoteFamily).toEqual(clientSocket.remoteFamily); - - // Clean up - clientSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should forward events correctly', async () => { - // Create a simple echo server - let serverConnection: net.Socket; - const server = net.createServer((socket) => { - serverConnection = socket; - socket.on('data', (data) => { - socket.write(data); // Echo back - }); - }); - - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - - // Wrap the socket - const wrappedSocket = new WrappedSocket(clientSocket); - - // Set up event tracking - let connectReceived = false; - let dataReceived = false; - let endReceived = false; - let closeReceived = false; - - wrappedSocket.on('connect', () => { - connectReceived = true; - }); - - wrappedSocket.on('data', (chunk) => { - dataReceived = true; - expect(chunk.toString()).toEqual('test data'); - }); - - wrappedSocket.on('end', () => { - endReceived = true; - }); - - wrappedSocket.on('close', () => { - closeReceived = true; - }); - - // Wait for connection - await new Promise((resolve) => { - if (clientSocket.readyState === 'open') { - resolve(); - } else { - clientSocket.once('connect', () => resolve()); - } - }); - - // Send data - wrappedSocket.write('test data'); - - // Wait for echo - await new Promise(resolve => setTimeout(resolve, 100)); - - // Close the connection - serverConnection.end(); - - // Wait for events - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify all events were received - expect(dataReceived).toBeTrue(); - expect(endReceived).toBeTrue(); - expect(closeReceived).toBeTrue(); - - // Clean up - server.close(); -}); - -tap.test('WrappedSocket - should pass through socket methods', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - await new Promise((resolve) => { - clientSocket.once('connect', () => resolve()); - }); - - // Wrap the socket - const wrappedSocket = new WrappedSocket(clientSocket); - - // Test various pass-through methods - expect(wrappedSocket.readable).toEqual(clientSocket.readable); - expect(wrappedSocket.writable).toEqual(clientSocket.writable); - expect(wrappedSocket.destroyed).toEqual(clientSocket.destroyed); - expect(wrappedSocket.bytesRead).toEqual(clientSocket.bytesRead); - expect(wrappedSocket.bytesWritten).toEqual(clientSocket.bytesWritten); - - // Test method calls - wrappedSocket.pause(); - expect(clientSocket.isPaused()).toBeTrue(); - - wrappedSocket.resume(); - expect(clientSocket.isPaused()).toBeFalse(); - - // Test setTimeout - let timeoutCalled = false; - wrappedSocket.setTimeout(100, () => { - timeoutCalled = true; - }); - await new Promise(resolve => setTimeout(resolve, 150)); - expect(timeoutCalled).toBeTrue(); - - // Clean up - wrappedSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should handle write and pipe operations', async () => { - // Create a simple echo server - const server = net.createServer((socket) => { - socket.pipe(socket); // Echo everything back - }); - - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - await new Promise((resolve) => { - clientSocket.once('connect', () => resolve()); - }); - - // Wrap the socket - const wrappedSocket = new WrappedSocket(clientSocket); - - // Test write with callback - const writeResult = wrappedSocket.write('test', 'utf8', () => { - // Write completed - }); - expect(typeof writeResult).toEqual('boolean'); - - // Test pipe - const { PassThrough } = await import('stream'); - const passThrough = new PassThrough(); - const piped = wrappedSocket.pipe(passThrough); - expect(piped).toEqual(passThrough); - - // Clean up - wrappedSocket.destroy(); - server.close(); -}); - -tap.test('WrappedSocket - should handle encoding and address methods', async () => { - // Create a simple test server - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, 'localhost', () => resolve()); - }); - - const serverPort = (server.address() as net.AddressInfo).port; - - // Create a client connection - const clientSocket = net.connect(serverPort, 'localhost'); - await new Promise((resolve) => { - clientSocket.once('connect', () => resolve()); - }); - - // Wrap the socket - const wrappedSocket = new WrappedSocket(clientSocket); - - // Test setEncoding - wrappedSocket.setEncoding('utf8'); - - // Test address method - const addr = wrappedSocket.address(); - expect(addr).toEqual(clientSocket.address()); - - // Test cork/uncork (if available) - wrappedSocket.cork(); - wrappedSocket.uncork(); - - // Clean up - wrappedSocket.destroy(); - server.close(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3add3c9..0b27d39 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.17.10', + version: '26.0.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/core/events/index.ts b/ts/core/events/index.ts deleted file mode 100644 index e1cb117..0000000 --- a/ts/core/events/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * Common event definitions - */ diff --git a/ts/core/index.ts b/ts/core/index.ts index 054d745..af165fc 100644 --- a/ts/core/index.ts +++ b/ts/core/index.ts @@ -5,4 +5,3 @@ // Export submodules export * from './models/index.js'; export * from './utils/index.js'; -export * from './events/index.js'; diff --git a/ts/core/models/index.ts b/ts/core/models/index.ts index e6d1d7b..08d0711 100644 --- a/ts/core/models/index.ts +++ b/ts/core/models/index.ts @@ -3,7 +3,6 @@ */ export * from './common-types.js'; -export * from './socket-augmentation.js'; export * from './route-context.js'; export * from './wrapped-socket.js'; export * from './socket-types.js'; diff --git a/ts/core/models/socket-augmentation.ts b/ts/core/models/socket-augmentation.ts deleted file mode 100644 index 2f69914..0000000 --- a/ts/core/models/socket-augmentation.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as plugins from '../../plugins.js'; - -// Augment the Node.js Socket type to include TLS-related properties -// This helps TypeScript understand properties that are dynamically added by Node.js -declare module 'net' { - interface Socket { - // TLS-related properties - encrypted?: boolean; // Indicates if the socket is encrypted (TLS/SSL) - authorizationError?: Error; // Authentication error if TLS handshake failed - - // TLS-related methods - getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3') - getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate - getSession?(): Buffer; // Returns the TLS session data - - // Connection tracking properties (used by HttpProxy) - _connectionId?: string; // Unique identifier for the connection - _remoteIP?: string; // Remote IP address - _realRemoteIP?: string; // Real remote IP (when proxied) - } -} - -// Export a utility function to check if a socket is a TLS socket -export function isTLSSocket(socket: plugins.net.Socket): boolean { - return 'encrypted' in socket && !!socket.encrypted; -} - -// Export a utility function to safely get the TLS version -export function getTLSVersion(socket: plugins.net.Socket): string | null { - if (socket.getTLSVersion) { - try { - return socket.getTLSVersion(); - } catch (e) { - return null; - } - } - return null; -} \ No newline at end of file diff --git a/ts/core/utils/async-utils.ts b/ts/core/utils/async-utils.ts deleted file mode 100644 index 9ec8c47..0000000 --- a/ts/core/utils/async-utils.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Async utility functions for SmartProxy - * Provides non-blocking alternatives to synchronous operations - */ - -/** - * Delays execution for the specified number of milliseconds - * Non-blocking alternative to busy wait loops - * @param ms - Number of milliseconds to delay - * @returns Promise that resolves after the delay - */ -export async function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Retry an async operation with exponential backoff - * @param fn - The async function to retry - * @param options - Retry options - * @returns The result of the function or throws the last error - */ -export async function retryWithBackoff( - fn: () => Promise, - options: { - maxAttempts?: number; - initialDelay?: number; - maxDelay?: number; - factor?: number; - onRetry?: (attempt: number, error: Error) => void; - } = {} -): Promise { - const { - maxAttempts = 3, - initialDelay = 100, - maxDelay = 10000, - factor = 2, - onRetry - } = options; - - let lastError: Error | null = null; - let currentDelay = initialDelay; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await fn(); - } catch (error: any) { - lastError = error; - - if (attempt === maxAttempts) { - throw error; - } - - if (onRetry) { - onRetry(attempt, error); - } - - await delay(currentDelay); - currentDelay = Math.min(currentDelay * factor, maxDelay); - } - } - - throw lastError || new Error('Retry failed'); -} - -/** - * Execute an async operation with a timeout - * @param fn - The async function to execute - * @param timeoutMs - Timeout in milliseconds - * @param timeoutError - Optional custom timeout error - * @returns The result of the function or throws timeout error - */ -export async function withTimeout( - fn: () => Promise, - timeoutMs: number, - timeoutError?: Error -): Promise { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }); - - return Promise.race([fn(), timeoutPromise]); -} - -/** - * Run multiple async operations in parallel with a concurrency limit - * @param items - Array of items to process - * @param fn - Async function to run for each item - * @param concurrency - Maximum number of concurrent operations - * @returns Array of results in the same order as input - */ -export async function parallelLimit( - items: T[], - fn: (item: T, index: number) => Promise, - concurrency: number -): Promise { - const results: R[] = new Array(items.length); - const executing: Set> = new Set(); - - for (let i = 0; i < items.length; i++) { - const promise = fn(items[i], i).then(result => { - results[i] = result; - executing.delete(promise); - }); - - executing.add(promise); - - if (executing.size >= concurrency) { - await Promise.race(executing); - } - } - - await Promise.all(executing); - return results; -} - -/** - * Debounce an async function - * @param fn - The async function to debounce - * @param delayMs - Delay in milliseconds - * @returns Debounced function with cancel method - */ -export function debounceAsync Promise>( - fn: T, - delayMs: number -): T & { cancel: () => void } { - let timeoutId: NodeJS.Timeout | null = null; - let lastPromise: Promise | null = null; - - const debounced = ((...args: Parameters) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - - lastPromise = new Promise((resolve, reject) => { - timeoutId = setTimeout(async () => { - timeoutId = null; - try { - const result = await fn(...args); - resolve(result); - } catch (error) { - reject(error); - } - }, delayMs); - }); - - return lastPromise; - }) as any; - - debounced.cancel = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - return debounced as T & { cancel: () => void }; -} - -/** - * Create a mutex for ensuring exclusive access to a resource - */ -export class AsyncMutex { - private queue: Array<() => void> = []; - private locked = false; - - async acquire(): Promise<() => void> { - if (!this.locked) { - this.locked = true; - return () => this.release(); - } - - return new Promise<() => void>(resolve => { - this.queue.push(() => { - resolve(() => this.release()); - }); - }); - } - - private release(): void { - const next = this.queue.shift(); - if (next) { - next(); - } else { - this.locked = false; - } - } - - async runExclusive(fn: () => Promise): Promise { - const release = await this.acquire(); - try { - return await fn(); - } finally { - release(); - } - } -} - -/** - * Circuit breaker for protecting against cascading failures - */ -export class CircuitBreaker { - private failureCount = 0; - private lastFailureTime = 0; - private state: 'closed' | 'open' | 'half-open' = 'closed'; - - constructor( - private options: { - failureThreshold: number; - resetTimeout: number; - onStateChange?: (state: 'closed' | 'open' | 'half-open') => void; - } - ) {} - - async execute(fn: () => Promise): Promise { - if (this.state === 'open') { - if (Date.now() - this.lastFailureTime > this.options.resetTimeout) { - this.setState('half-open'); - } else { - throw new Error('Circuit breaker is open'); - } - } - - try { - const result = await fn(); - this.onSuccess(); - return result; - } catch (error) { - this.onFailure(); - throw error; - } - } - - private onSuccess(): void { - this.failureCount = 0; - if (this.state !== 'closed') { - this.setState('closed'); - } - } - - private onFailure(): void { - this.failureCount++; - this.lastFailureTime = Date.now(); - - if (this.failureCount >= this.options.failureThreshold) { - this.setState('open'); - } - } - - private setState(state: 'closed' | 'open' | 'half-open'): void { - if (this.state !== state) { - this.state = state; - if (this.options.onStateChange) { - this.options.onStateChange(state); - } - } - } - - isOpen(): boolean { - return this.state === 'open'; - } - - getState(): 'closed' | 'open' | 'half-open' { - return this.state; - } - - recordSuccess(): void { - this.onSuccess(); - } - - recordFailure(): void { - this.onFailure(); - } -} \ No newline at end of file diff --git a/ts/core/utils/binary-heap.ts b/ts/core/utils/binary-heap.ts deleted file mode 100644 index 1f6efbf..0000000 --- a/ts/core/utils/binary-heap.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * A binary heap implementation for efficient priority queue operations - * Supports O(log n) insert and extract operations - */ -export class BinaryHeap { - private heap: T[] = []; - private keyMap?: Map; // For efficient key-based lookups - - constructor( - private compareFn: (a: T, b: T) => number, - private extractKey?: (item: T) => string - ) { - if (extractKey) { - this.keyMap = new Map(); - } - } - - /** - * Get the current size of the heap - */ - public get size(): number { - return this.heap.length; - } - - /** - * Check if the heap is empty - */ - public isEmpty(): boolean { - return this.heap.length === 0; - } - - /** - * Peek at the top element without removing it - */ - public peek(): T | undefined { - return this.heap[0]; - } - - /** - * Insert a new item into the heap - * O(log n) time complexity - */ - public insert(item: T): void { - const index = this.heap.length; - this.heap.push(item); - - if (this.keyMap && this.extractKey) { - const key = this.extractKey(item); - this.keyMap.set(key, index); - } - - this.bubbleUp(index); - } - - /** - * Extract the top element from the heap - * O(log n) time complexity - */ - public extract(): T | undefined { - if (this.heap.length === 0) return undefined; - if (this.heap.length === 1) { - const item = this.heap.pop()!; - if (this.keyMap && this.extractKey) { - this.keyMap.delete(this.extractKey(item)); - } - return item; - } - - const result = this.heap[0]; - const lastItem = this.heap.pop()!; - this.heap[0] = lastItem; - - if (this.keyMap && this.extractKey) { - this.keyMap.delete(this.extractKey(result)); - this.keyMap.set(this.extractKey(lastItem), 0); - } - - this.bubbleDown(0); - return result; - } - - /** - * Extract an element that matches the predicate - * O(n) time complexity for search, O(log n) for extraction - */ - public extractIf(predicate: (item: T) => boolean): T | undefined { - const index = this.heap.findIndex(predicate); - if (index === -1) return undefined; - - return this.extractAt(index); - } - - /** - * Extract an element by its key (if extractKey was provided) - * O(log n) time complexity - */ - public extractByKey(key: string): T | undefined { - if (!this.keyMap || !this.extractKey) { - throw new Error('extractKey function must be provided to use key-based extraction'); - } - - const index = this.keyMap.get(key); - if (index === undefined) return undefined; - - return this.extractAt(index); - } - - /** - * Check if a key exists in the heap - * O(1) time complexity - */ - public hasKey(key: string): boolean { - if (!this.keyMap) return false; - return this.keyMap.has(key); - } - - /** - * Get all elements as an array (does not modify heap) - * O(n) time complexity - */ - public toArray(): T[] { - return [...this.heap]; - } - - /** - * Clear the heap - */ - public clear(): void { - this.heap = []; - if (this.keyMap) { - this.keyMap.clear(); - } - } - - /** - * Extract element at specific index - */ - private extractAt(index: number): T { - const item = this.heap[index]; - - if (this.keyMap && this.extractKey) { - this.keyMap.delete(this.extractKey(item)); - } - - if (index === this.heap.length - 1) { - this.heap.pop(); - return item; - } - - const lastItem = this.heap.pop()!; - this.heap[index] = lastItem; - - if (this.keyMap && this.extractKey) { - this.keyMap.set(this.extractKey(lastItem), index); - } - - // Try bubbling up first - const parentIndex = Math.floor((index - 1) / 2); - if (parentIndex >= 0 && this.compareFn(this.heap[index], this.heap[parentIndex]) < 0) { - this.bubbleUp(index); - } else { - this.bubbleDown(index); - } - - return item; - } - - /** - * Bubble up element at given index to maintain heap property - */ - private bubbleUp(index: number): void { - while (index > 0) { - const parentIndex = Math.floor((index - 1) / 2); - - if (this.compareFn(this.heap[index], this.heap[parentIndex]) >= 0) { - break; - } - - this.swap(index, parentIndex); - index = parentIndex; - } - } - - /** - * Bubble down element at given index to maintain heap property - */ - private bubbleDown(index: number): void { - const length = this.heap.length; - - while (true) { - const leftChild = 2 * index + 1; - const rightChild = 2 * index + 2; - let smallest = index; - - if (leftChild < length && - this.compareFn(this.heap[leftChild], this.heap[smallest]) < 0) { - smallest = leftChild; - } - - if (rightChild < length && - this.compareFn(this.heap[rightChild], this.heap[smallest]) < 0) { - smallest = rightChild; - } - - if (smallest === index) break; - - this.swap(index, smallest); - index = smallest; - } - } - - /** - * Swap two elements in the heap - */ - private swap(i: number, j: number): void { - const temp = this.heap[i]; - this.heap[i] = this.heap[j]; - this.heap[j] = temp; - - if (this.keyMap && this.extractKey) { - this.keyMap.set(this.extractKey(this.heap[i]), i); - this.keyMap.set(this.extractKey(this.heap[j]), j); - } - } -} \ No newline at end of file diff --git a/ts/core/utils/enhanced-connection-pool.ts b/ts/core/utils/enhanced-connection-pool.ts deleted file mode 100644 index 30a2639..0000000 --- a/ts/core/utils/enhanced-connection-pool.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { LifecycleComponent } from './lifecycle-component.js'; -import { BinaryHeap } from './binary-heap.js'; -import { AsyncMutex } from './async-utils.js'; -import { EventEmitter } from 'node:events'; - -/** - * Interface for pooled connection - */ -export interface IPooledConnection { - id: string; - connection: T; - createdAt: number; - lastUsedAt: number; - useCount: number; - inUse: boolean; - metadata?: any; -} - -/** - * Configuration options for the connection pool - */ -export interface IConnectionPoolOptions { - minSize?: number; - maxSize?: number; - acquireTimeout?: number; - idleTimeout?: number; - maxUseCount?: number; - validateOnAcquire?: boolean; - validateOnReturn?: boolean; - queueTimeout?: number; - connectionFactory: () => Promise; - connectionValidator?: (connection: T) => Promise; - connectionDestroyer?: (connection: T) => Promise; - onConnectionError?: (error: Error, connection?: T) => void; -} - -/** - * Interface for queued acquire request - */ -interface IAcquireRequest { - id: string; - priority: number; - timestamp: number; - resolve: (connection: IPooledConnection) => void; - reject: (error: Error) => void; - timeoutHandle?: NodeJS.Timeout; -} - -/** - * Enhanced connection pool with priority queue, backpressure, and lifecycle management - */ -export class EnhancedConnectionPool extends LifecycleComponent { - private readonly options: Required, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>> & Pick, 'connectionValidator' | 'connectionDestroyer' | 'onConnectionError'>; - private readonly availableConnections: IPooledConnection[] = []; - private readonly activeConnections: Map> = new Map(); - private readonly waitQueue: BinaryHeap>; - private readonly mutex = new AsyncMutex(); - private readonly eventEmitter = new EventEmitter(); - - private connectionIdCounter = 0; - private requestIdCounter = 0; - private isClosing = false; - - // Metrics - private metrics = { - connectionsCreated: 0, - connectionsDestroyed: 0, - connectionsAcquired: 0, - connectionsReleased: 0, - acquireTimeouts: 0, - validationFailures: 0, - queueHighWaterMark: 0, - }; - - constructor(options: IConnectionPoolOptions) { - super(); - - this.options = { - minSize: 0, - maxSize: 10, - acquireTimeout: 30000, - idleTimeout: 300000, // 5 minutes - maxUseCount: Infinity, - validateOnAcquire: true, - validateOnReturn: false, - queueTimeout: 60000, - ...options, - }; - - // Initialize priority queue (higher priority = extracted first) - this.waitQueue = new BinaryHeap>( - (a, b) => b.priority - a.priority || a.timestamp - b.timestamp, - (item) => item.id - ); - - // Start maintenance cycle - this.startMaintenance(); - - // Initialize minimum connections - this.initializeMinConnections(); - } - - /** - * Initialize minimum number of connections - */ - private async initializeMinConnections(): Promise { - const promises: Promise[] = []; - - for (let i = 0; i < this.options.minSize; i++) { - promises.push( - this.createConnection() - .then(conn => { - this.availableConnections.push(conn); - }) - .catch(err => { - if (this.options.onConnectionError) { - this.options.onConnectionError(err); - } - }) - ); - } - - await Promise.all(promises); - } - - /** - * Start maintenance timer for idle connection cleanup - */ - private startMaintenance(): void { - this.setInterval(() => { - this.performMaintenance(); - }, 30000); // Every 30 seconds - } - - /** - * Perform maintenance tasks - */ - private async performMaintenance(): Promise { - await this.mutex.runExclusive(async () => { - const now = Date.now(); - const toRemove: IPooledConnection[] = []; - - // Check for idle connections beyond minimum size - for (let i = this.availableConnections.length - 1; i >= 0; i--) { - const conn = this.availableConnections[i]; - - // Keep minimum connections - if (this.availableConnections.length <= this.options.minSize) { - break; - } - - // Remove idle connections - if (now - conn.lastUsedAt > this.options.idleTimeout) { - toRemove.push(conn); - this.availableConnections.splice(i, 1); - } - } - - // Destroy idle connections - for (const conn of toRemove) { - await this.destroyConnection(conn); - } - }); - } - - /** - * Acquire a connection from the pool - */ - public async acquire(priority: number = 0, timeout?: number): Promise> { - if (this.isClosing) { - throw new Error('Connection pool is closing'); - } - - return this.mutex.runExclusive(async () => { - // Try to get an available connection - const connection = await this.tryAcquireConnection(); - if (connection) { - return connection; - } - - // Check if we can create a new connection - const totalConnections = this.availableConnections.length + this.activeConnections.size; - if (totalConnections < this.options.maxSize) { - try { - const newConnection = await this.createConnection(); - return this.checkoutConnection(newConnection); - } catch (err) { - // Fall through to queue if creation fails - } - } - - // Add to wait queue - return this.queueAcquireRequest(priority, timeout); - }); - } - - /** - * Try to acquire an available connection - */ - private async tryAcquireConnection(): Promise | null> { - while (this.availableConnections.length > 0) { - const connection = this.availableConnections.shift()!; - - // Check if connection exceeded max use count - if (connection.useCount >= this.options.maxUseCount) { - await this.destroyConnection(connection); - continue; - } - - // Validate connection if required - if (this.options.validateOnAcquire && this.options.connectionValidator) { - try { - const isValid = await this.options.connectionValidator(connection.connection); - if (!isValid) { - this.metrics.validationFailures++; - await this.destroyConnection(connection); - continue; - } - } catch (err) { - this.metrics.validationFailures++; - await this.destroyConnection(connection); - continue; - } - } - - return this.checkoutConnection(connection); - } - - return null; - } - - /** - * Checkout a connection for use - */ - private checkoutConnection(connection: IPooledConnection): IPooledConnection { - connection.inUse = true; - connection.lastUsedAt = Date.now(); - connection.useCount++; - - this.activeConnections.set(connection.id, connection); - this.metrics.connectionsAcquired++; - - this.eventEmitter.emit('acquire', connection); - return connection; - } - - /** - * Queue an acquire request - */ - private queueAcquireRequest(priority: number, timeout?: number): Promise> { - return new Promise>((resolve, reject) => { - const request: IAcquireRequest = { - id: `req-${this.requestIdCounter++}`, - priority, - timestamp: Date.now(), - resolve, - reject, - }; - - // Set timeout - const timeoutMs = timeout || this.options.queueTimeout; - request.timeoutHandle = this.setTimeout(() => { - if (this.waitQueue.extractByKey(request.id)) { - this.metrics.acquireTimeouts++; - reject(new Error(`Connection acquire timeout after ${timeoutMs}ms`)); - } - }, timeoutMs); - - this.waitQueue.insert(request); - this.metrics.queueHighWaterMark = Math.max( - this.metrics.queueHighWaterMark, - this.waitQueue.size - ); - - this.eventEmitter.emit('enqueue', { queueSize: this.waitQueue.size }); - }); - } - - /** - * Release a connection back to the pool - */ - public async release(connection: IPooledConnection): Promise { - return this.mutex.runExclusive(async () => { - if (!connection.inUse || !this.activeConnections.has(connection.id)) { - throw new Error('Connection is not active'); - } - - this.activeConnections.delete(connection.id); - connection.inUse = false; - connection.lastUsedAt = Date.now(); - this.metrics.connectionsReleased++; - - // Check if connection should be destroyed - if (connection.useCount >= this.options.maxUseCount) { - await this.destroyConnection(connection); - return; - } - - // Validate on return if required - if (this.options.validateOnReturn && this.options.connectionValidator) { - try { - const isValid = await this.options.connectionValidator(connection.connection); - if (!isValid) { - await this.destroyConnection(connection); - return; - } - } catch (err) { - await this.destroyConnection(connection); - return; - } - } - - // Check if there are waiting requests - const request = this.waitQueue.extract(); - if (request) { - this.clearTimeout(request.timeoutHandle!); - request.resolve(this.checkoutConnection(connection)); - this.eventEmitter.emit('dequeue', { queueSize: this.waitQueue.size }); - } else { - // Return to available pool - this.availableConnections.push(connection); - this.eventEmitter.emit('release', connection); - } - }); - } - - /** - * Create a new connection - */ - private async createConnection(): Promise> { - const rawConnection = await this.options.connectionFactory(); - - const connection: IPooledConnection = { - id: `conn-${this.connectionIdCounter++}`, - connection: rawConnection, - createdAt: Date.now(), - lastUsedAt: Date.now(), - useCount: 0, - inUse: false, - }; - - this.metrics.connectionsCreated++; - this.eventEmitter.emit('create', connection); - - return connection; - } - - /** - * Destroy a connection - */ - private async destroyConnection(connection: IPooledConnection): Promise { - try { - if (this.options.connectionDestroyer) { - await this.options.connectionDestroyer(connection.connection); - } - - this.metrics.connectionsDestroyed++; - this.eventEmitter.emit('destroy', connection); - } catch (err) { - if (this.options.onConnectionError) { - this.options.onConnectionError(err as Error, connection.connection); - } - } - } - - /** - * Get current pool statistics - */ - public getStats() { - return { - available: this.availableConnections.length, - active: this.activeConnections.size, - waiting: this.waitQueue.size, - total: this.availableConnections.length + this.activeConnections.size, - ...this.metrics, - }; - } - - /** - * Subscribe to pool events - */ - public on(event: string, listener: Function): void { - this.addEventListener(this.eventEmitter, event, listener); - } - - /** - * Close the pool and cleanup resources - */ - protected async onCleanup(): Promise { - this.isClosing = true; - - // Clear the wait queue - while (!this.waitQueue.isEmpty()) { - const request = this.waitQueue.extract(); - if (request) { - this.clearTimeout(request.timeoutHandle!); - request.reject(new Error('Connection pool is closing')); - } - } - - // Wait for active connections to be released (with timeout) - const timeout = 30000; - const startTime = Date.now(); - - while (this.activeConnections.size > 0 && Date.now() - startTime < timeout) { - await new Promise(resolve => { - const timer = setTimeout(resolve, 100); - if (typeof timer.unref === 'function') { - timer.unref(); - } - }); - } - - // Destroy all connections - const allConnections = [ - ...this.availableConnections, - ...this.activeConnections.values(), - ]; - - await Promise.all(allConnections.map(conn => this.destroyConnection(conn))); - - this.availableConnections.length = 0; - this.activeConnections.clear(); - } -} \ No newline at end of file diff --git a/ts/core/utils/fs-utils.ts b/ts/core/utils/fs-utils.ts deleted file mode 100644 index ba52e3d..0000000 --- a/ts/core/utils/fs-utils.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Async filesystem utilities for SmartProxy - * Provides non-blocking alternatives to synchronous filesystem operations - */ - -import * as plugins from '../../plugins.js'; - -export class AsyncFileSystem { - /** - * Check if a file or directory exists - * @param path - Path to check - * @returns Promise resolving to true if exists, false otherwise - */ - static async exists(path: string): Promise { - try { - await plugins.fs.promises.access(path); - return true; - } catch { - return false; - } - } - - /** - * Ensure a directory exists, creating it if necessary - * @param dirPath - Directory path to ensure - * @returns Promise that resolves when directory is ensured - */ - static async ensureDir(dirPath: string): Promise { - await plugins.fs.promises.mkdir(dirPath, { recursive: true }); - } - - /** - * Read a file as string - * @param filePath - Path to the file - * @param encoding - File encoding (default: utf8) - * @returns Promise resolving to file contents - */ - static async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise { - return plugins.fs.promises.readFile(filePath, encoding); - } - - /** - * Read a file as buffer - * @param filePath - Path to the file - * @returns Promise resolving to file buffer - */ - static async readFileBuffer(filePath: string): Promise { - return plugins.fs.promises.readFile(filePath); - } - - /** - * Write string data to a file - * @param filePath - Path to the file - * @param data - String data to write - * @param encoding - File encoding (default: utf8) - * @returns Promise that resolves when file is written - */ - static async writeFile(filePath: string, data: string, encoding: BufferEncoding = 'utf8'): Promise { - // Ensure directory exists - const dir = plugins.path.dirname(filePath); - await this.ensureDir(dir); - await plugins.fs.promises.writeFile(filePath, data, encoding); - } - - /** - * Write buffer data to a file - * @param filePath - Path to the file - * @param data - Buffer data to write - * @returns Promise that resolves when file is written - */ - static async writeFileBuffer(filePath: string, data: Buffer): Promise { - const dir = plugins.path.dirname(filePath); - await this.ensureDir(dir); - await plugins.fs.promises.writeFile(filePath, data); - } - - /** - * Remove a file - * @param filePath - Path to the file - * @returns Promise that resolves when file is removed - */ - static async remove(filePath: string): Promise { - try { - await plugins.fs.promises.unlink(filePath); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - // File doesn't exist, which is fine - } - } - - /** - * Remove a directory and all its contents - * @param dirPath - Path to the directory - * @returns Promise that resolves when directory is removed - */ - static async removeDir(dirPath: string): Promise { - try { - await plugins.fs.promises.rm(dirPath, { recursive: true, force: true }); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } - } - - /** - * Read JSON from a file - * @param filePath - Path to the JSON file - * @returns Promise resolving to parsed JSON - */ - static async readJSON(filePath: string): Promise { - const content = await this.readFile(filePath); - return JSON.parse(content); - } - - /** - * Write JSON to a file - * @param filePath - Path to the file - * @param data - Data to write as JSON - * @param pretty - Whether to pretty-print JSON (default: true) - * @returns Promise that resolves when file is written - */ - static async writeJSON(filePath: string, data: any, pretty = true): Promise { - const jsonString = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data); - await this.writeFile(filePath, jsonString); - } - - /** - * Copy a file from source to destination - * @param source - Source file path - * @param destination - Destination file path - * @returns Promise that resolves when file is copied - */ - static async copyFile(source: string, destination: string): Promise { - const destDir = plugins.path.dirname(destination); - await this.ensureDir(destDir); - await plugins.fs.promises.copyFile(source, destination); - } - - /** - * Move/rename a file - * @param source - Source file path - * @param destination - Destination file path - * @returns Promise that resolves when file is moved - */ - static async moveFile(source: string, destination: string): Promise { - const destDir = plugins.path.dirname(destination); - await this.ensureDir(destDir); - await plugins.fs.promises.rename(source, destination); - } - - /** - * Get file stats - * @param filePath - Path to the file - * @returns Promise resolving to file stats or null if doesn't exist - */ - static async getStats(filePath: string): Promise { - try { - return await plugins.fs.promises.stat(filePath); - } catch (error: any) { - if (error.code === 'ENOENT') { - return null; - } - throw error; - } - } - - /** - * List files in a directory - * @param dirPath - Directory path - * @returns Promise resolving to array of filenames - */ - static async listFiles(dirPath: string): Promise { - try { - return await plugins.fs.promises.readdir(dirPath); - } catch (error: any) { - if (error.code === 'ENOENT') { - return []; - } - throw error; - } - } - - /** - * List files in a directory with full paths - * @param dirPath - Directory path - * @returns Promise resolving to array of full file paths - */ - static async listFilesFullPath(dirPath: string): Promise { - const files = await this.listFiles(dirPath); - return files.map(file => plugins.path.join(dirPath, file)); - } - - /** - * Recursively list all files in a directory - * @param dirPath - Directory path - * @param fileList - Accumulator for file list (used internally) - * @returns Promise resolving to array of all file paths - */ - static async listFilesRecursive(dirPath: string, fileList: string[] = []): Promise { - const files = await this.listFiles(dirPath); - - for (const file of files) { - const filePath = plugins.path.join(dirPath, file); - const stats = await this.getStats(filePath); - - if (stats?.isDirectory()) { - await this.listFilesRecursive(filePath, fileList); - } else if (stats?.isFile()) { - fileList.push(filePath); - } - } - - return fileList; - } - - /** - * Create a read stream for a file - * @param filePath - Path to the file - * @param options - Stream options - * @returns Read stream - */ - static createReadStream(filePath: string, options?: Parameters[1]): plugins.fs.ReadStream { - return plugins.fs.createReadStream(filePath, options); - } - - /** - * Create a write stream for a file - * @param filePath - Path to the file - * @param options - Stream options - * @returns Write stream - */ - static createWriteStream(filePath: string, options?: Parameters[1]): plugins.fs.WriteStream { - return plugins.fs.createWriteStream(filePath, options); - } - - /** - * Ensure a file exists, creating an empty file if necessary - * @param filePath - Path to the file - * @returns Promise that resolves when file is ensured - */ - static async ensureFile(filePath: string): Promise { - const exists = await this.exists(filePath); - if (!exists) { - await this.writeFile(filePath, ''); - } - } - - /** - * Check if a path is a directory - * @param path - Path to check - * @returns Promise resolving to true if directory, false otherwise - */ - static async isDirectory(path: string): Promise { - const stats = await this.getStats(path); - return stats?.isDirectory() ?? false; - } - - /** - * Check if a path is a file - * @param path - Path to check - * @returns Promise resolving to true if file, false otherwise - */ - static async isFile(path: string): Promise { - const stats = await this.getStats(path); - return stats?.isFile() ?? false; - } -} \ No newline at end of file diff --git a/ts/core/utils/index.ts b/ts/core/utils/index.ts index 08f7360..fa7eddc 100644 --- a/ts/core/utils/index.ts +++ b/ts/core/utils/index.ts @@ -2,16 +2,4 @@ * Core utility functions */ -export * from './validation-utils.js'; -export * from './ip-utils.js'; -export * from './template-utils.js'; -export * from './security-utils.js'; -export * from './shared-security-manager.js'; -export * from './websocket-utils.js'; export * from './logger.js'; -export * from './async-utils.js'; -export * from './fs-utils.js'; -export * from './lifecycle-component.js'; -export * from './binary-heap.js'; -export * from './enhanced-connection-pool.js'; -export * from './socket-utils.js'; diff --git a/ts/core/utils/ip-utils.ts b/ts/core/utils/ip-utils.ts deleted file mode 100644 index e441545..0000000 --- a/ts/core/utils/ip-utils.ts +++ /dev/null @@ -1,303 +0,0 @@ -import * as plugins from '../../plugins.js'; - -/** - * Utility class for IP address operations - */ -export class IpUtils { - /** - * Check if the IP matches any of the glob patterns - * - * This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization. - * It's used to implement IP filtering based on security configurations. - * - * @param ip - The IP address to check - * @param patterns - Array of glob patterns - * @returns true if IP matches any pattern, false otherwise - */ - public static isGlobIPMatch(ip: string, patterns: string[]): boolean { - if (!ip || !patterns || patterns.length === 0) return false; - - // Normalize the IP being checked - const normalizedIPVariants = this.normalizeIP(ip); - if (normalizedIPVariants.length === 0) return false; - - // Check each pattern - for (const pattern of patterns) { - // Handle CIDR notation - if (pattern.includes('/')) { - if (this.matchCIDR(ip, pattern)) { - return true; - } - continue; - } - - // Handle range notation - if (pattern.includes('-') && !pattern.includes('*')) { - if (this.matchIPRange(ip, pattern)) { - return true; - } - continue; - } - - // Expand shorthand patterns for glob matching - let expandedPattern = pattern; - if (pattern.includes('*') && !pattern.includes(':')) { - const parts = pattern.split('.'); - while (parts.length < 4) { - parts.push('*'); - } - expandedPattern = parts.join('.'); - } - - // Normalize and check with minimatch - const normalizedPatterns = this.normalizeIP(expandedPattern); - - for (const ipVariant of normalizedIPVariants) { - for (const normalizedPattern of normalizedPatterns) { - if (plugins.minimatch(ipVariant, normalizedPattern)) { - return true; - } - } - } - } - - return false; - } - - /** - * Normalize IP addresses for consistent comparison - * - * @param ip The IP address to normalize - * @returns Array of normalized IP forms - */ - public static normalizeIP(ip: string): string[] { - if (!ip) return []; - - // Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) - if (ip.startsWith('::ffff:')) { - const ipv4 = ip.slice(7); - return [ip, ipv4]; - } - - // Handle IPv4 addresses by also checking IPv4-mapped form - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { - return [ip, `::ffff:${ip}`]; - } - - return [ip]; - } - - /** - * Check if an IP is authorized using security rules - * - * @param ip - The IP address to check - * @param allowedIPs - Array of allowed IP patterns - * @param blockedIPs - Array of blocked IP patterns - * @returns true if IP is authorized, false if blocked - */ - public static isIPAuthorized(ip: string, allowedIPs: string[] = [], blockedIPs: string[] = []): boolean { - // Skip IP validation if no rules are defined - if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) { - return true; - } - - // First check if IP is blocked - blocked IPs take precedence - if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) { - return false; - } - - // Then check if IP is allowed (if no allowed IPs are specified, all non-blocked IPs are allowed) - return allowedIPs.length === 0 || this.isGlobIPMatch(ip, allowedIPs); - } - - /** - * Check if an IP address is a private network address - * - * @param ip The IP address to check - * @returns true if the IP is a private network address, false otherwise - */ - public static isPrivateIP(ip: string): boolean { - if (!ip) return false; - - // Handle IPv4-mapped IPv6 addresses - if (ip.startsWith('::ffff:')) { - ip = ip.slice(7); - } - - // Check IPv4 private ranges - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { - const parts = ip.split('.').map(Number); - - // Check common private ranges - // 10.0.0.0/8 - if (parts[0] === 10) return true; - - // 172.16.0.0/12 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; - - // 192.168.0.0/16 - if (parts[0] === 192 && parts[1] === 168) return true; - - // 127.0.0.0/8 (localhost) - if (parts[0] === 127) return true; - - return false; - } - - // IPv6 local addresses - return ip === '::1' || ip.startsWith('fc00:') || ip.startsWith('fd00:') || ip.startsWith('fe80:'); - } - - /** - * Check if an IP address is a public network address - * - * @param ip The IP address to check - * @returns true if the IP is a public network address, false otherwise - */ - public static isPublicIP(ip: string): boolean { - return !this.isPrivateIP(ip); - } - - /** - * Check if an IP matches a CIDR notation - * - * @param ip The IP address to check - * @param cidr The CIDR notation (e.g., "192.168.1.0/24") - * @returns true if IP is within the CIDR range - */ - private static matchCIDR(ip: string, cidr: string): boolean { - if (!cidr.includes('/')) return false; - - const [networkAddr, prefixStr] = cidr.split('/'); - const prefix = parseInt(prefixStr, 10); - - // Handle IPv4-mapped IPv6 in the IP being checked - let checkIP = ip; - if (checkIP.startsWith('::ffff:')) { - checkIP = checkIP.slice(7); - } - - // Handle IPv6 CIDR - if (networkAddr.includes(':')) { - // TODO: Implement IPv6 CIDR matching - return false; - } - - // IPv4 CIDR matching - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false; - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(networkAddr)) return false; - if (isNaN(prefix) || prefix < 0 || prefix > 32) return false; - - const ipParts = checkIP.split('.').map(Number); - const netParts = networkAddr.split('.').map(Number); - - // Validate IP parts - for (const part of [...ipParts, ...netParts]) { - if (part < 0 || part > 255) return false; - } - - // Convert to 32-bit integers - const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; - const netNum = (netParts[0] << 24) | (netParts[1] << 16) | (netParts[2] << 8) | netParts[3]; - - // Create mask - const mask = (-1 << (32 - prefix)) >>> 0; - - // Check if IP is in network range - return (ipNum & mask) === (netNum & mask); - } - - /** - * Check if an IP matches a range notation - * - * @param ip The IP address to check - * @param range The range notation (e.g., "192.168.1.1-192.168.1.100") - * @returns true if IP is within the range - */ - private static matchIPRange(ip: string, range: string): boolean { - if (!range.includes('-')) return false; - - const [startIP, endIP] = range.split('-').map(s => s.trim()); - - // Handle IPv4-mapped IPv6 in the IP being checked - let checkIP = ip; - if (checkIP.startsWith('::ffff:')) { - checkIP = checkIP.slice(7); - } - - // Only handle IPv4 for now - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false; - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(startIP)) return false; - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(endIP)) return false; - - const ipParts = checkIP.split('.').map(Number); - const startParts = startIP.split('.').map(Number); - const endParts = endIP.split('.').map(Number); - - // Validate parts - for (const part of [...ipParts, ...startParts, ...endParts]) { - if (part < 0 || part > 255) return false; - } - - // Convert to 32-bit integers for comparison - const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; - const startNum = (startParts[0] << 24) | (startParts[1] << 16) | (startParts[2] << 8) | startParts[3]; - const endNum = (endParts[0] << 24) | (endParts[1] << 16) | (endParts[2] << 8) | endParts[3]; - - // Convert to unsigned for proper comparison - const ipUnsigned = ipNum >>> 0; - const startUnsigned = startNum >>> 0; - const endUnsigned = endNum >>> 0; - - return ipUnsigned >= startUnsigned && ipUnsigned <= endUnsigned; - } - - /** - * Convert a subnet CIDR to an IP range for filtering - * - * @param cidr The CIDR notation (e.g., "192.168.1.0/24") - * @returns Array of glob patterns that match the CIDR range - */ - public static cidrToGlobPatterns(cidr: string): string[] { - if (!cidr || !cidr.includes('/')) return []; - - const [ipPart, prefixPart] = cidr.split('/'); - const prefix = parseInt(prefixPart, 10); - - if (isNaN(prefix) || prefix < 0 || prefix > 32) return []; - - // For IPv4 only for now - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ipPart)) return []; - - const ipParts = ipPart.split('.').map(Number); - const fullMask = Math.pow(2, 32 - prefix) - 1; - - // Convert IP to a numeric value - const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; - - // Calculate network address (IP & ~fullMask) - const networkNum = ipNum & ~fullMask; - - // For large ranges, return wildcard patterns - if (prefix <= 8) { - return [`${(networkNum >>> 24) & 255}.*.*.*`]; - } else if (prefix <= 16) { - return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.*.*`]; - } else if (prefix <= 24) { - return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.${(networkNum >>> 8) & 255}.*`]; - } - - // For small ranges, create individual IP patterns - const patterns = []; - const maxAddresses = Math.min(256, Math.pow(2, 32 - prefix)); - - for (let i = 0; i < maxAddresses; i++) { - const currentIpNum = networkNum + i; - patterns.push( - `${(currentIpNum >>> 24) & 255}.${(currentIpNum >>> 16) & 255}.${(currentIpNum >>> 8) & 255}.${currentIpNum & 255}` - ); - } - - return patterns; - } -} \ No newline at end of file diff --git a/ts/core/utils/lifecycle-component.ts b/ts/core/utils/lifecycle-component.ts deleted file mode 100644 index 753dc6a..0000000 --- a/ts/core/utils/lifecycle-component.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Base class for components that need proper resource lifecycle management - * Provides automatic cleanup of timers and event listeners to prevent memory leaks - */ -export abstract class LifecycleComponent { - private timers: Set = new Set(); - private intervals: Set = new Set(); - private listeners: Array<{ - target: any; - event: string; - handler: Function; - actualHandler?: Function; // The actual handler registered (may be wrapped) - once?: boolean; - }> = []; - private childComponents: Set = new Set(); - protected isShuttingDown = false; - private cleanupPromise?: Promise; - - /** - * Create a managed setTimeout that will be automatically cleaned up - */ - protected setTimeout(handler: Function, timeout: number): NodeJS.Timeout { - if (this.isShuttingDown) { - // Return a dummy timer if shutting down - const dummyTimer = setTimeout(() => {}, 0); - if (typeof dummyTimer.unref === 'function') { - dummyTimer.unref(); - } - return dummyTimer; - } - - const wrappedHandler = () => { - this.timers.delete(timer); - if (!this.isShuttingDown) { - handler(); - } - }; - - const timer = setTimeout(wrappedHandler, timeout); - this.timers.add(timer); - - // Allow process to exit even with timer - if (typeof timer.unref === 'function') { - timer.unref(); - } - - return timer; - } - - /** - * Create a managed setInterval that will be automatically cleaned up - */ - protected setInterval(handler: Function, interval: number): NodeJS.Timeout { - if (this.isShuttingDown) { - // Return a dummy timer if shutting down - const dummyTimer = setInterval(() => {}, interval); - if (typeof dummyTimer.unref === 'function') { - dummyTimer.unref(); - } - clearInterval(dummyTimer); // Clear immediately since we don't need it - return dummyTimer; - } - - const wrappedHandler = () => { - if (!this.isShuttingDown) { - handler(); - } - }; - - const timer = setInterval(wrappedHandler, interval); - this.intervals.add(timer); - - // Allow process to exit even with timer - if (typeof timer.unref === 'function') { - timer.unref(); - } - - return timer; - } - - /** - * Clear a managed timeout - */ - protected clearTimeout(timer: NodeJS.Timeout): void { - clearTimeout(timer); - this.timers.delete(timer); - } - - /** - * Clear a managed interval - */ - protected clearInterval(timer: NodeJS.Timeout): void { - clearInterval(timer); - this.intervals.delete(timer); - } - - /** - * Add a managed event listener that will be automatically removed on cleanup - */ - protected addEventListener( - target: any, - event: string, - handler: Function, - options?: { once?: boolean } - ): void { - if (this.isShuttingDown) { - return; - } - - // For 'once' listeners, we need to wrap the handler to remove it from our tracking - let actualHandler = handler; - if (options?.once) { - actualHandler = (...args: any[]) => { - // Call the original handler - handler(...args); - - // Remove from our internal tracking - const index = this.listeners.findIndex( - l => l.target === target && l.event === event && l.handler === handler - ); - if (index !== -1) { - this.listeners.splice(index, 1); - } - }; - } - - // Support both EventEmitter and DOM-style event targets - if (typeof target.on === 'function') { - if (options?.once) { - target.once(event, actualHandler); - } else { - target.on(event, actualHandler); - } - } else if (typeof target.addEventListener === 'function') { - target.addEventListener(event, actualHandler, options); - } else { - throw new Error('Target must support on() or addEventListener()'); - } - - // Store both the original handler and the actual handler registered - this.listeners.push({ - target, - event, - handler, - actualHandler, // The handler that was actually registered (may be wrapped) - once: options?.once - }); - } - - /** - * Remove a specific event listener - */ - protected removeEventListener(target: any, event: string, handler: Function): void { - // Remove from target - if (typeof target.removeListener === 'function') { - target.removeListener(event, handler); - } else if (typeof target.removeEventListener === 'function') { - target.removeEventListener(event, handler); - } - - // Remove from our tracking - const index = this.listeners.findIndex( - l => l.target === target && l.event === event && l.handler === handler - ); - if (index !== -1) { - this.listeners.splice(index, 1); - } - } - - /** - * Register a child component that should be cleaned up when this component is cleaned up - */ - protected registerChildComponent(component: LifecycleComponent): void { - this.childComponents.add(component); - } - - /** - * Unregister a child component - */ - protected unregisterChildComponent(component: LifecycleComponent): void { - this.childComponents.delete(component); - } - - /** - * Override this method to implement component-specific cleanup logic - */ - protected async onCleanup(): Promise { - // Override in subclasses - } - - /** - * Clean up all managed resources - */ - public async cleanup(): Promise { - // Return existing cleanup promise if already cleaning up - if (this.cleanupPromise) { - return this.cleanupPromise; - } - - this.cleanupPromise = this.performCleanup(); - return this.cleanupPromise; - } - - private async performCleanup(): Promise { - this.isShuttingDown = true; - - // First, clean up child components - const childCleanupPromises: Promise[] = []; - for (const child of this.childComponents) { - childCleanupPromises.push(child.cleanup()); - } - await Promise.all(childCleanupPromises); - this.childComponents.clear(); - - // Clear all timers - for (const timer of this.timers) { - clearTimeout(timer); - } - this.timers.clear(); - - // Clear all intervals - for (const timer of this.intervals) { - clearInterval(timer); - } - this.intervals.clear(); - - // Remove all event listeners - for (const { target, event, handler, actualHandler } of this.listeners) { - // Use actualHandler if available (for wrapped handlers), otherwise use the original handler - const handlerToRemove = actualHandler || handler; - - // All listeners need to be removed, including 'once' listeners that might not have fired - if (typeof target.removeListener === 'function') { - target.removeListener(event, handlerToRemove); - } else if (typeof target.removeEventListener === 'function') { - target.removeEventListener(event, handlerToRemove); - } - } - this.listeners = []; - - // Call subclass cleanup - await this.onCleanup(); - } - - /** - * Check if the component is shutting down - */ - protected isShuttingDownState(): boolean { - return this.isShuttingDown; - } -} \ No newline at end of file diff --git a/ts/core/utils/log-deduplicator.ts b/ts/core/utils/log-deduplicator.ts deleted file mode 100644 index dafa965..0000000 --- a/ts/core/utils/log-deduplicator.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { logger } from './logger.js'; - -interface ILogEvent { - level: 'info' | 'warn' | 'error' | 'debug'; - message: string; - data?: any; - count: number; - firstSeen: number; - lastSeen: number; -} - -interface IAggregatedEvent { - key: string; - events: Map; - flushTimer?: NodeJS.Timeout; -} - -/** - * Log deduplication utility to reduce log spam for repetitive events - */ -export class LogDeduplicator { - private globalFlushTimer?: NodeJS.Timeout; - private aggregatedEvents: Map = new Map(); - private flushInterval: number = 5000; // 5 seconds - private maxBatchSize: number = 100; - private rapidEventThreshold: number = 50; // Flush early if this many events in 1 second - private lastRapidCheck: number = Date.now(); - - constructor(flushInterval?: number) { - if (flushInterval) { - this.flushInterval = flushInterval; - } - - // Set up global periodic flush to ensure logs are emitted regularly - this.globalFlushTimer = setInterval(() => { - this.flushAll(); - }, this.flushInterval * 2); // Flush everything every 2x the normal interval - - if (this.globalFlushTimer.unref) { - this.globalFlushTimer.unref(); - } - } - - /** - * Log a deduplicated event - * @param key - Aggregation key (e.g., 'connection-rejected', 'cleanup-batch') - * @param level - Log level - * @param message - Log message template - * @param data - Additional data - * @param dedupeKey - Deduplication key within the aggregation (e.g., IP address, reason) - */ - public log( - key: string, - level: 'info' | 'warn' | 'error' | 'debug', - message: string, - data?: any, - dedupeKey?: string - ): void { - const eventKey = dedupeKey || message; - const now = Date.now(); - - if (!this.aggregatedEvents.has(key)) { - this.aggregatedEvents.set(key, { - key, - events: new Map(), - flushTimer: undefined - }); - } - - const aggregated = this.aggregatedEvents.get(key)!; - - if (aggregated.events.has(eventKey)) { - const event = aggregated.events.get(eventKey)!; - event.count++; - event.lastSeen = now; - if (data) { - event.data = { ...event.data, ...data }; - } - } else { - aggregated.events.set(eventKey, { - level, - message, - data, - count: 1, - firstSeen: now, - lastSeen: now - }); - } - - // Check for rapid events (many events in short time) - const totalEvents = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0); - - // If we're getting flooded with events, flush more frequently - if (now - this.lastRapidCheck < 1000 && totalEvents >= this.rapidEventThreshold) { - this.flush(key); - this.lastRapidCheck = now; - } else if (aggregated.events.size >= this.maxBatchSize) { - // Check if we should flush due to size - this.flush(key); - } else if (!aggregated.flushTimer) { - // Schedule flush - aggregated.flushTimer = setTimeout(() => { - this.flush(key); - }, this.flushInterval); - - if (aggregated.flushTimer.unref) { - aggregated.flushTimer.unref(); - } - } - - // Update rapid check time - if (now - this.lastRapidCheck >= 1000) { - this.lastRapidCheck = now; - } - } - - /** - * Flush aggregated events for a specific key - */ - public flush(key: string): void { - const aggregated = this.aggregatedEvents.get(key); - if (!aggregated || aggregated.events.size === 0) { - return; - } - - if (aggregated.flushTimer) { - clearTimeout(aggregated.flushTimer); - aggregated.flushTimer = undefined; - } - - // Emit aggregated log based on the key - switch (key) { - case 'connection-rejected': - this.flushConnectionRejections(aggregated); - break; - case 'connection-cleanup': - this.flushConnectionCleanups(aggregated); - break; - case 'connection-terminated': - this.flushConnectionTerminations(aggregated); - break; - case 'ip-rejected': - this.flushIPRejections(aggregated); - break; - default: - this.flushGeneric(aggregated); - } - - // Clear events - aggregated.events.clear(); - } - - /** - * Flush all pending events - */ - public flushAll(): void { - for (const key of this.aggregatedEvents.keys()) { - this.flush(key); - } - } - - private flushConnectionRejections(aggregated: IAggregatedEvent): void { - const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0); - const byReason = new Map(); - - for (const [, event] of aggregated.events) { - const reason = event.data?.reason || 'unknown'; - byReason.set(reason, (byReason.get(reason) || 0) + event.count); - } - - const reasonSummary = Array.from(byReason.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([reason, count]) => `${reason}: ${count}`) - .join(', '); - - const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)); - logger.log('warn', `[SUMMARY] Rejected ${totalCount} connections in ${Math.round(duration/1000)}s`, { - reasons: reasonSummary, - uniqueIPs: aggregated.events.size, - component: 'connection-dedup' - }); - } - - private flushConnectionCleanups(aggregated: IAggregatedEvent): void { - const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0); - const byReason = new Map(); - - for (const [, event] of aggregated.events) { - const reason = event.data?.reason || 'normal'; - byReason.set(reason, (byReason.get(reason) || 0) + event.count); - } - - const reasonSummary = Array.from(byReason.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) // Top 5 reasons - .map(([reason, count]) => `${reason}: ${count}`) - .join(', '); - - logger.log('info', `Cleaned up ${totalCount} connections`, { - reasons: reasonSummary, - duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)), - component: 'connection-dedup' - }); - } - - private flushConnectionTerminations(aggregated: IAggregatedEvent): void { - const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0); - const byReason = new Map(); - const byIP = new Map(); - let lastActiveCount = 0; - - for (const [, event] of aggregated.events) { - const reason = event.data?.reason || 'unknown'; - const ip = event.data?.remoteIP || 'unknown'; - - byReason.set(reason, (byReason.get(reason) || 0) + event.count); - - // Track by IP - if (ip !== 'unknown') { - byIP.set(ip, (byIP.get(ip) || 0) + event.count); - } - - // Track the last active connection count - if (event.data?.activeConnections !== undefined) { - lastActiveCount = event.data.activeConnections; - } - } - - const reasonSummary = Array.from(byReason.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) // Top 5 reasons - .map(([reason, count]) => `${reason}: ${count}`) - .join(', '); - - // Show top IPs if there are many different ones - let ipInfo = ''; - if (byIP.size > 3) { - const topIPs = Array.from(byIP.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([ip, count]) => `${ip} (${count})`) - .join(', '); - ipInfo = `, from ${byIP.size} IPs (top: ${topIPs})`; - } else if (byIP.size > 0) { - ipInfo = `, IPs: ${Array.from(byIP.keys()).join(', ')}`; - } - - const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)); - - // Special handling for localhost connections (HttpProxy) - const localhostCount = byIP.get('::ffff:127.0.0.1') || 0; - if (localhostCount > 0 && byIP.size === 1) { - // All connections are from localhost (HttpProxy) - logger.log('info', `[SUMMARY] ${totalCount} HttpProxy connections terminated in ${Math.round(duration/1000)}s`, { - reasons: reasonSummary, - activeConnections: lastActiveCount, - component: 'connection-dedup' - }); - } else { - logger.log('info', `[SUMMARY] ${totalCount} connections terminated in ${Math.round(duration/1000)}s`, { - reasons: reasonSummary, - activeConnections: lastActiveCount, - uniqueReasons: byReason.size, - ...(ipInfo ? { ips: ipInfo } : {}), - component: 'connection-dedup' - }); - } - } - - private flushIPRejections(aggregated: IAggregatedEvent): void { - const byIP = new Map }>(); - const allReasons = new Map(); - - for (const [ip, event] of aggregated.events) { - if (!byIP.has(ip)) { - byIP.set(ip, { count: 0, reasons: new Set() }); - } - const ipData = byIP.get(ip)!; - ipData.count += event.count; - if (event.data?.reason) { - ipData.reasons.add(event.data.reason); - // Track overall reason counts - allReasons.set(event.data.reason, (allReasons.get(event.data.reason) || 0) + event.count); - } - } - - // Create reason summary - const reasonSummary = Array.from(allReasons.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([reason, count]) => `${reason}: ${count}`) - .join(', '); - - // Log top offenders - const topOffenders = Array.from(byIP.entries()) - .sort((a, b) => b[1].count - a[1].count) - .slice(0, 10) - .map(([ip, data]) => `${ip} (${data.count}x, ${Array.from(data.reasons).join('/')})`) - .join(', '); - - const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0); - - const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)); - logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s (${reasonSummary})`, { - topOffenders, - component: 'ip-dedup' - }); - } - - private flushGeneric(aggregated: IAggregatedEvent): void { - const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0); - const level = aggregated.events.values().next().value?.level || 'info'; - - // Special handling for IP cleanup events - if (aggregated.key === 'ip-cleanup') { - const totalCleaned = Array.from(aggregated.events.values()).reduce((sum, e) => { - return sum + (e.data?.cleanedIPs || 0) + (e.data?.cleanedRateLimits || 0); - }, 0); - - if (totalCleaned > 0) { - logger.log(level as any, `IP tracking cleanup: removed ${totalCleaned} entries across ${totalCount} cleanup cycles`, { - duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)), - component: 'log-dedup' - }); - } - } else { - logger.log(level as any, `${aggregated.key}: ${totalCount} events`, { - uniqueEvents: aggregated.events.size, - duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)), - component: 'log-dedup' - }); - } - } - - /** - * Cleanup and stop deduplication - */ - public cleanup(): void { - this.flushAll(); - - if (this.globalFlushTimer) { - clearInterval(this.globalFlushTimer); - this.globalFlushTimer = undefined; - } - - for (const aggregated of this.aggregatedEvents.values()) { - if (aggregated.flushTimer) { - clearTimeout(aggregated.flushTimer); - } - } - this.aggregatedEvents.clear(); - } -} - -// Global instance for connection-related log deduplication -export const connectionLogDeduplicator = new LogDeduplicator(5000); // 5 second batches - -// Ensure logs are flushed on process exit. -// Only use beforeExit — do NOT call process.exit() from SIGINT/SIGTERM handlers -// as that kills the host process's graceful shutdown (e.g., dcrouter connection draining). -process.on('beforeExit', () => { - connectionLogDeduplicator.flushAll(); -}); - -process.on('SIGINT', () => { - connectionLogDeduplicator.cleanup(); -}); - -process.on('SIGTERM', () => { - connectionLogDeduplicator.cleanup(); -}); \ No newline at end of file diff --git a/ts/core/utils/security-utils.ts b/ts/core/utils/security-utils.ts deleted file mode 100644 index 97af39e..0000000 --- a/ts/core/utils/security-utils.ts +++ /dev/null @@ -1,305 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { IpMatcher } from '../routing/matchers/ip.js'; - -/** - * Security utilities for IP validation, rate limiting, - * authentication, and other security features - */ - -/** - * Result of IP validation - */ -export interface IIpValidationResult { - allowed: boolean; - reason?: string; -} - -/** - * IP connection tracking information - */ -export interface IIpConnectionInfo { - connections: Set; // ConnectionIDs - timestamps: number[]; // Connection timestamps - ipVariants: string[]; // Normalized IP variants (e.g., ::ffff:127.0.0.1 and 127.0.0.1) -} - -/** - * Rate limit tracking - */ -export interface IRateLimitInfo { - count: number; - expiry: number; -} - -/** - * Logger interface for security utilities - */ -export interface ISecurityLogger { - info: (message: string, ...args: any[]) => void; - warn: (message: string, ...args: any[]) => void; - error: (message: string, ...args: any[]) => void; - debug?: (message: string, ...args: any[]) => void; -} - -/** - * Normalize IP addresses for comparison - * Handles IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) - * - * @param ip IP address to normalize - * @returns Array of equivalent IP representations - */ -export function normalizeIP(ip: string): string[] { - if (!ip) return []; - - // Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) - if (ip.startsWith('::ffff:')) { - const ipv4 = ip.slice(7); - return [ip, ipv4]; - } - - // Handle IPv4 addresses by also checking IPv4-mapped form - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { - return [ip, `::ffff:${ip}`]; - } - - return [ip]; -} - -/** - * Check if an IP is authorized based on allow and block lists - * - * @param ip - The IP address to check - * @param allowedIPs - Array of allowed IP patterns - * @param blockedIPs - Array of blocked IP patterns - * @returns Whether the IP is authorized - */ -export function isIPAuthorized( - ip: string, - allowedIPs: string[] = ['*'], - blockedIPs: string[] = [] -): boolean { - // Skip IP validation if no rules - if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) { - return true; - } - - // First check if IP is blocked - blocked IPs take precedence - if (blockedIPs.length > 0) { - for (const pattern of blockedIPs) { - if (IpMatcher.match(pattern, ip)) { - return false; - } - } - } - - // If allowed IPs list has wildcard, all non-blocked IPs are allowed - if (allowedIPs.includes('*')) { - return true; - } - - // Then check if IP is allowed in the explicit allow list - if (allowedIPs.length > 0) { - for (const pattern of allowedIPs) { - if (IpMatcher.match(pattern, ip)) { - return true; - } - } - // If allowedIPs is specified but no match, deny access - return false; - } - - // Default allow if no explicit allow list - return true; -} - -/** - * Check if an IP exceeds maximum connections - * - * @param ip - The IP address to check - * @param ipConnectionsMap - Map of IPs to connection info - * @param maxConnectionsPerIP - Maximum allowed connections per IP - * @returns Result with allowed status and reason if blocked - */ -export function checkMaxConnections( - ip: string, - ipConnectionsMap: Map, - maxConnectionsPerIP: number -): IIpValidationResult { - if (!ipConnectionsMap.has(ip)) { - return { allowed: true }; - } - - const connectionCount = ipConnectionsMap.get(ip)!.connections.size; - - if (connectionCount >= maxConnectionsPerIP) { - return { - allowed: false, - reason: `Maximum connections per IP (${maxConnectionsPerIP}) exceeded` - }; - } - - return { allowed: true }; -} - -/** - * Check if an IP exceeds connection rate limit - * - * @param ip - The IP address to check - * @param ipConnectionsMap - Map of IPs to connection info - * @param rateLimit - Maximum connections per minute - * @returns Result with allowed status and reason if blocked - */ -export function checkConnectionRate( - ip: string, - ipConnectionsMap: Map, - rateLimit: number -): IIpValidationResult { - const now = Date.now(); - const minute = 60 * 1000; - - // Get or create connection info - if (!ipConnectionsMap.has(ip)) { - const info: IIpConnectionInfo = { - connections: new Set(), - timestamps: [now], - ipVariants: normalizeIP(ip) - }; - ipConnectionsMap.set(ip, info); - return { allowed: true }; - } - - // Get timestamps and filter out entries older than 1 minute - const info = ipConnectionsMap.get(ip)!; - const timestamps = info.timestamps.filter(time => now - time < minute); - timestamps.push(now); - info.timestamps = timestamps; - - // Check if rate exceeds limit - if (timestamps.length > rateLimit) { - return { - allowed: false, - reason: `Connection rate limit (${rateLimit}/min) exceeded` - }; - } - - return { allowed: true }; -} - -/** - * Track a connection for an IP - * - * @param ip - The IP address - * @param connectionId - The connection ID to track - * @param ipConnectionsMap - Map of IPs to connection info - */ -export function trackConnection( - ip: string, - connectionId: string, - ipConnectionsMap: Map -): void { - if (!ipConnectionsMap.has(ip)) { - ipConnectionsMap.set(ip, { - connections: new Set([connectionId]), - timestamps: [Date.now()], - ipVariants: normalizeIP(ip) - }); - return; - } - - const info = ipConnectionsMap.get(ip)!; - info.connections.add(connectionId); -} - -/** - * Remove connection tracking for an IP - * - * @param ip - The IP address - * @param connectionId - The connection ID to remove - * @param ipConnectionsMap - Map of IPs to connection info - */ -export function removeConnection( - ip: string, - connectionId: string, - ipConnectionsMap: Map -): void { - if (!ipConnectionsMap.has(ip)) return; - - const info = ipConnectionsMap.get(ip)!; - info.connections.delete(connectionId); - - if (info.connections.size === 0) { - ipConnectionsMap.delete(ip); - } -} - -/** - * Clean up expired rate limits - * - * @param rateLimits - Map of rate limits to clean up - * @param logger - Logger for debug messages - */ -export function cleanupExpiredRateLimits( - rateLimits: Map>, - logger?: ISecurityLogger -): void { - const now = Date.now(); - let totalRemoved = 0; - - for (const [routeId, routeLimits] of rateLimits.entries()) { - let removed = 0; - for (const [key, limit] of routeLimits.entries()) { - if (limit.expiry < now) { - routeLimits.delete(key); - removed++; - totalRemoved++; - } - } - - if (removed > 0 && logger?.debug) { - logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`); - } - } - - if (totalRemoved > 0 && logger?.info) { - logger.info(`Cleaned up ${totalRemoved} expired rate limits total`); - } -} - -/** - * Generate basic auth header value from username and password - * - * @param username - The username - * @param password - The password - * @returns Base64 encoded basic auth string - */ -export function generateBasicAuthHeader(username: string, password: string): string { - return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; -} - -/** - * Parse basic auth header - * - * @param authHeader - The Authorization header value - * @returns Username and password, or null if invalid - */ -export function parseBasicAuthHeader( - authHeader: string -): { username: string; password: string } | null { - if (!authHeader || !authHeader.startsWith('Basic ')) { - return null; - } - - try { - const base64 = authHeader.slice(6); // Remove 'Basic ' - const decoded = Buffer.from(base64, 'base64').toString(); - const [username, password] = decoded.split(':'); - - if (!username || !password) { - return null; - } - - return { username, password }; - } catch (err) { - return null; - } -} \ No newline at end of file diff --git a/ts/core/utils/shared-security-manager.ts b/ts/core/utils/shared-security-manager.ts deleted file mode 100644 index 6a77809..0000000 --- a/ts/core/utils/shared-security-manager.ts +++ /dev/null @@ -1,470 +0,0 @@ -import * as plugins from '../../plugins.js'; -import type { IRouteConfig, IRouteContext } from '../../proxies/smart-proxy/models/route-types.js'; -import type { - IIpValidationResult, - IIpConnectionInfo, - ISecurityLogger, - IRateLimitInfo -} from './security-utils.js'; -import { - isIPAuthorized, - checkMaxConnections, - checkConnectionRate, - trackConnection, - removeConnection, - cleanupExpiredRateLimits, - parseBasicAuthHeader, - normalizeIP -} from './security-utils.js'; - -/** - * Shared SecurityManager for use across proxy components - * Handles IP tracking, rate limiting, and authentication - */ -export class SharedSecurityManager { - // IP connection tracking - private connectionsByIP: Map = new Map(); - - // Route-specific rate limiting - private rateLimits: Map> = new Map(); - - // Cache IP filtering results to avoid constant regex matching - private ipFilterCache: Map> = new Map(); - - // Default limits - private maxConnectionsPerIP: number; - private connectionRateLimitPerMinute: number; - - // Cache cleanup interval - private cleanupInterval: NodeJS.Timeout | null = null; - - /** - * Create a new SharedSecurityManager - * - * @param options - Configuration options - * @param logger - Logger instance - */ - constructor(options: { - maxConnectionsPerIP?: number; - connectionRateLimitPerMinute?: number; - cleanupIntervalMs?: number; - routes?: IRouteConfig[]; - }, private logger?: ISecurityLogger) { - this.maxConnectionsPerIP = options.maxConnectionsPerIP || 100; - this.connectionRateLimitPerMinute = options.connectionRateLimitPerMinute || 300; - - // Set up logger with defaults if not provided - this.logger = logger || { - info: console.log, - warn: console.warn, - error: console.error - }; - - // Set up cache cleanup interval - const cleanupInterval = options.cleanupIntervalMs || 60000; // Default: 1 minute - this.cleanupInterval = setInterval(() => { - this.cleanupCaches(); - }, cleanupInterval); - - // Don't keep the process alive just for cleanup - if (this.cleanupInterval.unref) { - this.cleanupInterval.unref(); - } - } - - /** - * Get connections count by IP - * - * @param ip - The IP address to check - * @returns Number of connections from this IP - */ - public getConnectionCountByIP(ip: string): number { - // Check all normalized variants of the IP - const variants = normalizeIP(ip); - for (const variant of variants) { - const info = this.connectionsByIP.get(variant); - if (info) { - return info.connections.size; - } - } - return 0; - } - - /** - * Track connection by IP - * - * @param ip - The IP address to track - * @param connectionId - The connection ID to associate - */ - public trackConnectionByIP(ip: string, connectionId: string): void { - // Check if any variant already exists - const variants = normalizeIP(ip); - let existingKey: string | null = null; - - for (const variant of variants) { - if (this.connectionsByIP.has(variant)) { - existingKey = variant; - break; - } - } - - // Use existing key or the original IP - trackConnection(existingKey || ip, connectionId, this.connectionsByIP); - } - - /** - * Remove connection tracking for an IP - * - * @param ip - The IP address to update - * @param connectionId - The connection ID to remove - */ - public removeConnectionByIP(ip: string, connectionId: string): void { - // Check all variants to find where the connection is tracked - const variants = normalizeIP(ip); - - for (const variant of variants) { - if (this.connectionsByIP.has(variant)) { - removeConnection(variant, connectionId, this.connectionsByIP); - break; - } - } - } - - /** - * Check if IP is authorized based on route security settings - * - * @param ip - The IP address to check - * @param allowedIPs - List of allowed IP patterns - * @param blockedIPs - List of blocked IP patterns - * @returns Whether the IP is authorized - */ - public isIPAuthorized( - ip: string, - allowedIPs: string[] = ['*'], - blockedIPs: string[] = [] - ): boolean { - return isIPAuthorized(ip, allowedIPs, blockedIPs); - } - - /** - * Validate IP against rate limits and connection limits - * - * @param ip - The IP address to validate - * @returns Result with allowed status and reason if blocked - */ - public validateIP(ip: string): IIpValidationResult { - // Check connection count limit - const connectionResult = checkMaxConnections( - ip, - this.connectionsByIP, - this.maxConnectionsPerIP - ); - if (!connectionResult.allowed) { - return connectionResult; - } - - // Check connection rate limit - const rateResult = checkConnectionRate( - ip, - this.connectionsByIP, - this.connectionRateLimitPerMinute - ); - if (!rateResult.allowed) { - return rateResult; - } - - return { allowed: true }; - } - - /** - * Atomically validate an IP and track the connection if allowed. - * This prevents race conditions where concurrent connections could bypass per-IP limits. - * - * @param ip - The IP address to validate - * @param connectionId - The connection ID to track if validation passes - * @returns Object with validation result and reason - */ - public validateAndTrackIP(ip: string, connectionId: string): IIpValidationResult { - // Check connection count limit BEFORE tracking - const connectionResult = checkMaxConnections( - ip, - this.connectionsByIP, - this.maxConnectionsPerIP - ); - if (!connectionResult.allowed) { - return connectionResult; - } - - // Check connection rate limit - const rateResult = checkConnectionRate( - ip, - this.connectionsByIP, - this.connectionRateLimitPerMinute - ); - if (!rateResult.allowed) { - return rateResult; - } - - // Validation passed - immediately track to prevent race conditions - this.trackConnectionByIP(ip, connectionId); - - return { allowed: true }; - } - - /** - * Check if a client is allowed to access a specific route - * - * @param route - The route to check - * @param context - The request context - * @param routeConnectionCount - Current connection count for this route (optional) - * @returns Whether access is allowed - */ - public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean { - if (!route.security) { - return true; // No security restrictions - } - - // --- IP filtering --- - if (!this.isClientIpAllowed(route, context.clientIp)) { - this.logger?.debug?.(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`); - return false; - } - - // --- Route-level connection limit --- - if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) { - if (routeConnectionCount >= route.security.maxConnections) { - this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`); - return false; - } - } - - // --- Rate limiting --- - if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) { - this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`); - return false; - } - - return true; - } - - /** - * Check if a client IP is allowed for a route - * - * @param route - The route to check - * @param clientIp - The client IP - * @returns Whether the IP is allowed - */ - private isClientIpAllowed(route: IRouteConfig, clientIp: string): boolean { - if (!route.security) { - return true; // No security restrictions - } - - const routeId = route.id || route.name || 'unnamed'; - - // Check cache first - if (!this.ipFilterCache.has(routeId)) { - this.ipFilterCache.set(routeId, new Map()); - } - - const routeCache = this.ipFilterCache.get(routeId)!; - if (routeCache.has(clientIp)) { - return routeCache.get(clientIp)!; - } - - // Check IP against route security settings - const ipAllowList = route.security.ipAllowList; - const ipBlockList = route.security.ipBlockList; - - const allowed = this.isIPAuthorized(clientIp, ipAllowList, ipBlockList); - - // Cache the result - routeCache.set(clientIp, allowed); - - return allowed; - } - - /** - * Check if request is within rate limit - * - * @param route - The route to check - * @param context - The request context - * @returns Whether the request is within rate limit - */ - private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean { - if (!route.security?.rateLimit?.enabled) { - return true; - } - - const rateLimit = route.security.rateLimit; - const routeId = route.id || route.name || 'unnamed'; - - // Determine rate limit key (by IP, path, or header) - let key = context.clientIp; // Default to IP - - if (rateLimit.keyBy === 'path' && context.path) { - key = `${context.clientIp}:${context.path}`; - } else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) { - const headerValue = context.headers[rateLimit.headerName.toLowerCase()]; - if (headerValue) { - key = `${context.clientIp}:${headerValue}`; - } - } - - // Get or create rate limit tracking for this route - if (!this.rateLimits.has(routeId)) { - this.rateLimits.set(routeId, new Map()); - } - - const routeLimits = this.rateLimits.get(routeId)!; - const now = Date.now(); - - // Get or create rate limit tracking for this key - let limit = routeLimits.get(key); - if (!limit || limit.expiry < now) { - // Create new rate limit or reset expired one - limit = { - count: 1, - expiry: now + (rateLimit.window * 1000) - }; - routeLimits.set(key, limit); - return true; - } - - // Increment the counter - limit.count++; - - // Check if rate limit is exceeded - return limit.count <= rateLimit.maxRequests; - } - - /** - * Validate HTTP Basic Authentication - * - * @param route - The route to check - * @param authHeader - The Authorization header - * @returns Whether authentication is valid - */ - public validateBasicAuth(route: IRouteConfig, authHeader?: string): boolean { - // Skip if basic auth not enabled for route - if (!route.security?.basicAuth?.enabled) { - return true; - } - - // No auth header means auth failed - if (!authHeader) { - return false; - } - - // Parse auth header - const credentials = parseBasicAuthHeader(authHeader); - if (!credentials) { - return false; - } - - // Check credentials against configured users - const { username, password } = credentials; - const users = route.security.basicAuth.users; - - return users.some(user => - user.username === username && user.password === password - ); - } - - /** - * Verify a JWT token against route configuration - * - * @param route - The route to verify the token for - * @param token - The JWT token to verify - * @returns True if the token is valid, false otherwise - */ - public verifyJwtToken(route: IRouteConfig, token: string): boolean { - if (!route.security?.jwtAuth?.enabled) { - return true; - } - - try { - const jwtAuth = route.security.jwtAuth; - - // Verify structure (header.payload.signature) - const parts = token.split('.'); - if (parts.length !== 3) { - return false; - } - - // Decode payload - const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); - - // Check expiration - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { - return false; - } - - // Check issuer - if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) { - return false; - } - - // Check audience - if (jwtAuth.audience && payload.aud !== jwtAuth.audience) { - return false; - } - - // Note: In a real implementation, you'd also verify the signature - // using the secret and algorithm specified in jwtAuth. - // This requires a proper JWT library for cryptographic verification. - - return true; - } catch (err) { - this.logger?.error?.(`Error verifying JWT: ${err}`); - return false; - } - } - - /** - * Clean up caches to prevent memory leaks - */ - private cleanupCaches(): void { - // Clean up rate limits - cleanupExpiredRateLimits(this.rateLimits, this.logger); - - // Clean up IP connection tracking - let cleanedIPs = 0; - for (const [ip, info] of this.connectionsByIP.entries()) { - // Remove IPs with no active connections and no recent timestamps - if (info.connections.size === 0 && info.timestamps.length === 0) { - this.connectionsByIP.delete(ip); - cleanedIPs++; - } - } - - if (cleanedIPs > 0 && this.logger?.debug) { - this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`); - } - - // IP filter cache doesn't need cleanup (tied to routes) - } - - /** - * Clear all IP tracking data (for shutdown) - */ - public clearIPTracking(): void { - this.connectionsByIP.clear(); - this.rateLimits.clear(); - this.ipFilterCache.clear(); - - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - } - - /** - * Update routes for security checking - * - * @param routes - New routes to use - */ - public setRoutes(routes: IRouteConfig[]): void { - // Only clear the IP filter cache - route-specific - this.ipFilterCache.clear(); - } -} \ No newline at end of file diff --git a/ts/core/utils/socket-utils.ts b/ts/core/utils/socket-utils.ts deleted file mode 100644 index fc5f58a..0000000 --- a/ts/core/utils/socket-utils.ts +++ /dev/null @@ -1,322 +0,0 @@ -import * as plugins from '../../plugins.js'; - -export interface CleanupOptions { - immediate?: boolean; // Force immediate destruction - allowDrain?: boolean; // Allow write buffer to drain - gracePeriod?: number; // Ms to wait before force close -} - -export interface SafeSocketOptions { - port: number; - host: string; - onError?: (error: Error) => void; - onConnect?: () => void; - timeout?: number; -} - -/** - * Safely cleanup a socket by removing all listeners and destroying it - * @param socket The socket to cleanup - * @param socketName Optional name for logging - * @param options Cleanup options - */ -export function cleanupSocket( - socket: plugins.net.Socket | plugins.tls.TLSSocket | null, - socketName?: string, - options: CleanupOptions = {} -): Promise { - if (!socket || socket.destroyed) return Promise.resolve(); - - return new Promise((resolve) => { - const cleanup = () => { - try { - // Remove all event listeners - socket.removeAllListeners(); - - // Destroy if not already destroyed - if (!socket.destroyed) { - socket.destroy(); - } - } catch (err) { - console.error(`Error cleaning up socket${socketName ? ` (${socketName})` : ''}: ${err}`); - } - resolve(); - }; - - if (options.immediate) { - // Immediate cleanup (old behavior) - socket.unpipe(); - cleanup(); - } else if (options.allowDrain && socket.writable) { - // Allow pending writes to complete - socket.end(() => cleanup()); - - // Force cleanup after grace period - if (options.gracePeriod) { - setTimeout(() => { - if (!socket.destroyed) { - cleanup(); - } - }, options.gracePeriod); - } - } else { - // Default: immediate cleanup - socket.unpipe(); - cleanup(); - } - }); -} - - -/** - * Create independent cleanup handlers for paired sockets that support half-open connections - * @param clientSocket The client socket - * @param serverSocket The server socket - * @param onBothClosed Callback when both sockets are closed - * @returns Independent cleanup functions for each socket - */ -export function createIndependentSocketHandlers( - clientSocket: plugins.net.Socket | plugins.tls.TLSSocket, - serverSocket: plugins.net.Socket | plugins.tls.TLSSocket, - onBothClosed: (reason: string) => void, - options: { enableHalfOpen?: boolean } = {} -): { cleanupClient: (reason: string) => Promise, cleanupServer: (reason: string) => Promise } { - let clientClosed = false; - let serverClosed = false; - let clientReason = ''; - let serverReason = ''; - - const checkBothClosed = () => { - if (clientClosed && serverClosed) { - onBothClosed(`client: ${clientReason}, server: ${serverReason}`); - } - }; - - const cleanupClient = async (reason: string) => { - if (clientClosed) return; - clientClosed = true; - clientReason = reason; - - // Default behavior: close both sockets when one closes (required for proxy chains) - if (!serverClosed && !options.enableHalfOpen) { - serverSocket.destroy(); - } - - // Half-open support (opt-in only) - if (!serverClosed && serverSocket.writable && options.enableHalfOpen) { - // Half-close: stop reading from client, let server finish - clientSocket.pause(); - clientSocket.unpipe(serverSocket); - await cleanupSocket(clientSocket, 'client', { allowDrain: true, gracePeriod: 5000 }); - } else { - await cleanupSocket(clientSocket, 'client', { immediate: true }); - } - - checkBothClosed(); - }; - - const cleanupServer = async (reason: string) => { - if (serverClosed) return; - serverClosed = true; - serverReason = reason; - - // Default behavior: close both sockets when one closes (required for proxy chains) - if (!clientClosed && !options.enableHalfOpen) { - clientSocket.destroy(); - } - - // Half-open support (opt-in only) - if (!clientClosed && clientSocket.writable && options.enableHalfOpen) { - // Half-close: stop reading from server, let client finish - serverSocket.pause(); - serverSocket.unpipe(clientSocket); - await cleanupSocket(serverSocket, 'server', { allowDrain: true, gracePeriod: 5000 }); - } else { - await cleanupSocket(serverSocket, 'server', { immediate: true }); - } - - checkBothClosed(); - }; - - return { cleanupClient, cleanupServer }; -} - -/** - * Setup socket error and close handlers with proper cleanup - * @param socket The socket to setup handlers for - * @param handleClose The cleanup function to call - * @param handleTimeout Optional custom timeout handler - * @param errorPrefix Optional prefix for error messages - */ -export function setupSocketHandlers( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - handleClose: (reason: string) => void, - handleTimeout?: (socket: plugins.net.Socket | plugins.tls.TLSSocket) => void, - errorPrefix?: string -): void { - socket.on('error', (error) => { - const prefix = errorPrefix || 'Socket'; - handleClose(`${prefix}_error: ${error.message}`); - }); - - socket.on('close', () => { - const prefix = errorPrefix || 'socket'; - handleClose(`${prefix}_closed`); - }); - - socket.on('timeout', () => { - if (handleTimeout) { - handleTimeout(socket); // Custom timeout handling - } else { - // Default: just log, don't close - console.warn(`Socket timeout: ${errorPrefix || 'socket'}`); - } - }); -} - -/** - * Setup bidirectional data forwarding between two sockets with proper cleanup - * @param clientSocket The client/incoming socket - * @param serverSocket The server/outgoing socket - * @param handlers Object containing optional handlers for data and cleanup - * @returns Cleanup functions for both sockets - */ -export function setupBidirectionalForwarding( - clientSocket: plugins.net.Socket | plugins.tls.TLSSocket, - serverSocket: plugins.net.Socket | plugins.tls.TLSSocket, - handlers: { - onClientData?: (chunk: Buffer) => void; - onServerData?: (chunk: Buffer) => void; - onCleanup: (reason: string) => void; - enableHalfOpen?: boolean; - } -): { cleanupClient: (reason: string) => Promise, cleanupServer: (reason: string) => Promise } { - // Set up cleanup handlers - const { cleanupClient, cleanupServer } = createIndependentSocketHandlers( - clientSocket, - serverSocket, - handlers.onCleanup, - { enableHalfOpen: handlers.enableHalfOpen } - ); - - // Set up error and close handlers - setupSocketHandlers(clientSocket, cleanupClient, undefined, 'client'); - setupSocketHandlers(serverSocket, cleanupServer, undefined, 'server'); - - // Set up data forwarding with backpressure handling - clientSocket.on('data', (chunk: Buffer) => { - if (handlers.onClientData) { - handlers.onClientData(chunk); - } - - if (serverSocket.writable) { - const flushed = serverSocket.write(chunk); - - // Handle backpressure - if (!flushed) { - clientSocket.pause(); - serverSocket.once('drain', () => { - if (!clientSocket.destroyed) { - clientSocket.resume(); - } - }); - } - } - }); - - serverSocket.on('data', (chunk: Buffer) => { - if (handlers.onServerData) { - handlers.onServerData(chunk); - } - - if (clientSocket.writable) { - const flushed = clientSocket.write(chunk); - - // Handle backpressure - if (!flushed) { - serverSocket.pause(); - clientSocket.once('drain', () => { - if (!serverSocket.destroyed) { - serverSocket.resume(); - } - }); - } - } - }); - - return { cleanupClient, cleanupServer }; -} - -/** - * Create a socket with immediate error handling to prevent crashes - * @param options Socket creation options - * @returns The created socket - */ -export function createSocketWithErrorHandler(options: SafeSocketOptions): plugins.net.Socket { - const { port, host, onError, onConnect, timeout } = options; - - // Create socket with immediate error handler attachment - const socket = new plugins.net.Socket(); - - // Track if connected - let connected = false; - let connectionTimeout: NodeJS.Timeout | null = null; - - // Attach error handler BEFORE connecting to catch immediate errors - socket.on('error', (error) => { - console.error(`Socket connection error to ${host}:${port}: ${error.message}`); - // Clear the connection timeout if it exists - if (connectionTimeout) { - clearTimeout(connectionTimeout); - connectionTimeout = null; - } - if (onError) { - onError(error); - } - }); - - // Attach connect handler - const handleConnect = () => { - connected = true; - // Clear the connection timeout - if (connectionTimeout) { - clearTimeout(connectionTimeout); - connectionTimeout = null; - } - // Set inactivity timeout if provided (after connection is established) - if (timeout) { - socket.setTimeout(timeout); - } - if (onConnect) { - onConnect(); - } - }; - - socket.on('connect', handleConnect); - - // Implement connection establishment timeout - if (timeout) { - connectionTimeout = setTimeout(() => { - if (!connected && !socket.destroyed) { - // Connection timed out - destroy the socket - const error = new Error(`Connection timeout after ${timeout}ms to ${host}:${port}`); - (error as any).code = 'ETIMEDOUT'; - - console.error(`Socket connection timeout to ${host}:${port} after ${timeout}ms`); - - // Destroy the socket - socket.destroy(); - - // Call error handler - if (onError) { - onError(error); - } - } - }, timeout); - } - - // Now attempt to connect - any immediate errors will be caught - socket.connect(port, host); - - return socket; -} \ No newline at end of file diff --git a/ts/core/utils/template-utils.ts b/ts/core/utils/template-utils.ts deleted file mode 100644 index e683864..0000000 --- a/ts/core/utils/template-utils.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { IRouteContext } from '../models/route-context.js'; - -/** - * Utility class for resolving template variables in strings - */ -export class TemplateUtils { - /** - * Resolve template variables in a string using the route context - * Supports variables like {domain}, {path}, {clientIp}, etc. - * - * @param template The template string with {variables} - * @param context The route context with values - * @returns The resolved string - */ - public static resolveTemplateVariables(template: string, context: IRouteContext): string { - if (!template) { - return template; - } - - // Replace variables with values from context - return template.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (match, varName) => { - // Handle nested properties with dot notation (e.g., {headers.host}) - if (varName.includes('.')) { - const parts = varName.split('.'); - let current: any = context; - - // Traverse nested object structure - for (const part of parts) { - if (current === undefined || current === null) { - return match; // Return original if path doesn't exist - } - current = current[part]; - } - - // Return the resolved value if it exists - if (current !== undefined && current !== null) { - return TemplateUtils.convertToString(current); - } - - return match; - } - - // Direct property access - const value = context[varName as keyof IRouteContext]; - if (value === undefined) { - return match; // Keep the original {variable} if not found - } - - // Convert value to string - return TemplateUtils.convertToString(value); - }); - } - - /** - * Safely convert a value to a string - * - * @param value Any value to convert to string - * @returns String representation or original match for complex objects - */ - private static convertToString(value: any): string { - if (value === null || value === undefined) { - return ''; - } - - if (typeof value === 'string') { - return value; - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return value.toString(); - } - - if (Array.isArray(value)) { - return value.join(','); - } - - if (typeof value === 'object') { - try { - return JSON.stringify(value); - } catch (e) { - return '[Object]'; - } - } - - return String(value); - } - - /** - * Resolve template variables in header values - * - * @param headers Header object with potential template variables - * @param context Route context for variable resolution - * @returns New header object with resolved values - */ - public static resolveHeaderTemplates( - headers: Record, - context: IRouteContext - ): Record { - const result: Record = {}; - - for (const [key, value] of Object.entries(headers)) { - // Skip special directive headers (starting with !) - if (value.startsWith('!')) { - result[key] = value; - continue; - } - - // Resolve template variables in the header value - result[key] = TemplateUtils.resolveTemplateVariables(value, context); - } - - return result; - } - - /** - * Check if a string contains template variables - * - * @param str String to check for template variables - * @returns True if string contains template variables - */ - public static containsTemplateVariables(str: string): boolean { - return !!str && /\{([a-zA-Z0-9_\.]+)\}/g.test(str); - } -} \ No newline at end of file diff --git a/ts/core/utils/validation-utils.ts b/ts/core/utils/validation-utils.ts deleted file mode 100644 index bd0055b..0000000 --- a/ts/core/utils/validation-utils.ts +++ /dev/null @@ -1,177 +0,0 @@ -import * as plugins from '../../plugins.js'; -import type { IDomainOptions, IAcmeOptions } from '../models/common-types.js'; - -/** - * Collection of validation utilities for configuration and domain options - */ -export class ValidationUtils { - /** - * Validates domain configuration options - * - * @param domainOptions The domain options to validate - * @returns An object with validation result and error message if invalid - */ - public static validateDomainOptions(domainOptions: IDomainOptions): { isValid: boolean; error?: string } { - if (!domainOptions) { - return { isValid: false, error: 'Domain options cannot be null or undefined' }; - } - - if (!domainOptions.domainName) { - return { isValid: false, error: 'Domain name is required' }; - } - - // Check domain pattern - if (!this.isValidDomainName(domainOptions.domainName)) { - return { isValid: false, error: `Invalid domain name: ${domainOptions.domainName}` }; - } - - // Validate forward config if provided - if (domainOptions.forward) { - if (!domainOptions.forward.ip) { - return { isValid: false, error: 'Forward IP is required when forward is specified' }; - } - - if (!domainOptions.forward.port) { - return { isValid: false, error: 'Forward port is required when forward is specified' }; - } - - if (!this.isValidPort(domainOptions.forward.port)) { - return { isValid: false, error: `Invalid forward port: ${domainOptions.forward.port}` }; - } - } - - // Validate ACME forward config if provided - if (domainOptions.acmeForward) { - if (!domainOptions.acmeForward.ip) { - return { isValid: false, error: 'ACME forward IP is required when acmeForward is specified' }; - } - - if (!domainOptions.acmeForward.port) { - return { isValid: false, error: 'ACME forward port is required when acmeForward is specified' }; - } - - if (!this.isValidPort(domainOptions.acmeForward.port)) { - return { isValid: false, error: `Invalid ACME forward port: ${domainOptions.acmeForward.port}` }; - } - } - - return { isValid: true }; - } - - /** - * Validates ACME configuration options - * - * @param acmeOptions The ACME options to validate - * @returns An object with validation result and error message if invalid - */ - public static validateAcmeOptions(acmeOptions: IAcmeOptions): { isValid: boolean; error?: string } { - if (!acmeOptions) { - return { isValid: false, error: 'ACME options cannot be null or undefined' }; - } - - if (acmeOptions.enabled) { - if (!acmeOptions.accountEmail) { - return { isValid: false, error: 'Account email is required when ACME is enabled' }; - } - - if (!this.isValidEmail(acmeOptions.accountEmail)) { - return { isValid: false, error: `Invalid email: ${acmeOptions.accountEmail}` }; - } - - if (acmeOptions.port && !this.isValidPort(acmeOptions.port)) { - return { isValid: false, error: `Invalid ACME port: ${acmeOptions.port}` }; - } - - if (acmeOptions.httpsRedirectPort && !this.isValidPort(acmeOptions.httpsRedirectPort)) { - return { isValid: false, error: `Invalid HTTPS redirect port: ${acmeOptions.httpsRedirectPort}` }; - } - - if (acmeOptions.renewThresholdDays && acmeOptions.renewThresholdDays < 1) { - return { isValid: false, error: 'Renew threshold days must be greater than 0' }; - } - - if (acmeOptions.renewCheckIntervalHours && acmeOptions.renewCheckIntervalHours < 1) { - return { isValid: false, error: 'Renew check interval hours must be greater than 0' }; - } - } - - return { isValid: true }; - } - - /** - * Validates a port number - * - * @param port The port to validate - * @returns true if the port is valid, false otherwise - */ - public static isValidPort(port: number): boolean { - return typeof port === 'number' && port > 0 && port <= 65535 && Number.isInteger(port); - } - - /** - * Validates a domain name - * - * @param domain The domain name to validate - * @returns true if the domain name is valid, false otherwise - */ - public static isValidDomainName(domain: string): boolean { - if (!domain || typeof domain !== 'string') { - return false; - } - - // Wildcard domain check (*.example.com) - if (domain.startsWith('*.')) { - domain = domain.substring(2); - } - - // Simple domain validation pattern - const domainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; - return domainPattern.test(domain); - } - - /** - * Validates an email address - * - * @param email The email to validate - * @returns true if the email is valid, false otherwise - */ - public static isValidEmail(email: string): boolean { - if (!email || typeof email !== 'string') { - return false; - } - - // Basic email validation pattern - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailPattern.test(email); - } - - /** - * Validates a certificate format (PEM) - * - * @param cert The certificate content to validate - * @returns true if the certificate appears to be in PEM format, false otherwise - */ - public static isValidCertificate(cert: string): boolean { - if (!cert || typeof cert !== 'string') { - return false; - } - - return cert.includes('-----BEGIN CERTIFICATE-----') && - cert.includes('-----END CERTIFICATE-----'); - } - - /** - * Validates a private key format (PEM) - * - * @param key The private key content to validate - * @returns true if the key appears to be in PEM format, false otherwise - */ - public static isValidPrivateKey(key: string): boolean { - if (!key || typeof key !== 'string') { - return false; - } - - return key.includes('-----BEGIN PRIVATE KEY-----') && - key.includes('-----END PRIVATE KEY-----'); - } -} \ No newline at end of file diff --git a/ts/core/utils/websocket-utils.ts b/ts/core/utils/websocket-utils.ts deleted file mode 100644 index 93e5bed..0000000 --- a/ts/core/utils/websocket-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * WebSocket utility functions - * - * This module provides smartproxy-specific WebSocket utilities - * and re-exports protocol utilities from the protocols module - */ - -// Import and re-export from protocols -import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js'; -export type { RawData } from '../../protocols/websocket/index.js'; - -/** - * Get the length of a WebSocket message regardless of its type - * (handles all possible WebSocket message data types) - * - * @param data - The data message from WebSocket (could be any RawData type) - * @returns The length of the data in bytes - */ -export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number { - // Delegate to protocol implementation - return protocolGetMessageSize(data); -} - -/** - * Convert any raw WebSocket data to Buffer for consistent handling - * - * @param data - The data message from WebSocket (could be any RawData type) - * @returns A Buffer containing the data - */ -export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer { - // Delegate to protocol implementation - return protocolToBuffer(data); -} \ No newline at end of file diff --git a/ts/detection/detectors/http-detector.ts b/ts/detection/detectors/http-detector.ts deleted file mode 100644 index 9b9851a..0000000 --- a/ts/detection/detectors/http-detector.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * HTTP Protocol Detector - * - * Simplified HTTP detection using the new architecture - */ - -import type { IProtocolDetector } from '../models/interfaces.js'; -import type { IDetectionResult, IDetectionOptions } from '../models/detection-types.js'; -import type { IProtocolDetectionResult, IConnectionContext } from '../../protocols/common/types.js'; -import type { THttpMethod } from '../../protocols/http/index.js'; -import { QuickProtocolDetector } from './quick-detector.js'; -import { RoutingExtractor } from './routing-extractor.js'; -import { DetectionFragmentManager } from '../utils/fragment-manager.js'; -import { HttpParser } from '../../protocols/http/parser.js'; - -/** - * Simplified HTTP detector - */ -export class HttpDetector implements IProtocolDetector { - private quickDetector = new QuickProtocolDetector(); - private fragmentManager: DetectionFragmentManager; - - constructor(fragmentManager?: DetectionFragmentManager) { - this.fragmentManager = fragmentManager || new DetectionFragmentManager(); - } - - /** - * Check if buffer can be handled by this detector - */ - canHandle(buffer: Buffer): boolean { - const result = this.quickDetector.quickDetect(buffer); - return result.protocol === 'http' && result.confidence > 50; - } - - /** - * Get minimum bytes needed for detection - */ - getMinimumBytes(): number { - return 4; // "GET " minimum - } - - /** - * Detect HTTP protocol from buffer - */ - detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null { - // Quick detection first - const quickResult = this.quickDetector.quickDetect(buffer); - - if (quickResult.protocol !== 'http' || quickResult.confidence < 50) { - return null; - } - - // Check if we have complete headers first - const headersEnd = buffer.indexOf('\r\n\r\n'); - const isComplete = headersEnd !== -1; - - // Extract routing information - const routing = RoutingExtractor.extract(buffer, 'http'); - - // Extract headers if requested and we have complete headers - let headers: Record | undefined; - if (options?.extractFullHeaders && isComplete) { - const headerSection = buffer.slice(0, headersEnd).toString(); - const lines = headerSection.split('\r\n'); - if (lines.length > 1) { - // Skip the request line and parse headers - headers = HttpParser.parseHeaders(lines.slice(1)); - } - } - - // If we don't need full headers and we have complete headers, we can return early - if (quickResult.confidence >= 95 && !options?.extractFullHeaders && isComplete) { - return { - protocol: 'http', - connectionInfo: { - protocol: 'http', - method: quickResult.metadata?.method as THttpMethod, - domain: routing?.domain, - path: routing?.path - }, - isComplete: true - }; - } - - return { - protocol: 'http', - connectionInfo: { - protocol: 'http', - domain: routing?.domain, - path: routing?.path, - method: quickResult.metadata?.method as THttpMethod, - headers: headers - }, - isComplete, - bytesNeeded: isComplete ? undefined : buffer.length + 512 // Need more for headers - }; - } - - /** - * Handle fragmented detection - */ - detectWithContext( - buffer: Buffer, - context: IConnectionContext, - options?: IDetectionOptions - ): IDetectionResult | null { - const handler = this.fragmentManager.getHandler('http'); - const connectionId = DetectionFragmentManager.createConnectionId(context); - - // Add fragment - const result = handler.addFragment(connectionId, buffer); - - if (result.error) { - handler.complete(connectionId); - return null; - } - - // Try detection on accumulated buffer - const detectResult = this.detect(result.buffer!, options); - - if (detectResult && detectResult.isComplete) { - handler.complete(connectionId); - } - - return detectResult; - } -} \ No newline at end of file diff --git a/ts/detection/detectors/quick-detector.ts b/ts/detection/detectors/quick-detector.ts deleted file mode 100644 index f9dedab..0000000 --- a/ts/detection/detectors/quick-detector.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Quick Protocol Detector - * - * Lightweight protocol identification based on minimal bytes - * No parsing, just identification - */ - -import type { IProtocolDetector, IProtocolDetectionResult } from '../../protocols/common/types.js'; -import { TlsRecordType } from '../../protocols/tls/index.js'; -import { HttpParser } from '../../protocols/http/index.js'; - -/** - * Quick protocol detector for fast identification - */ -export class QuickProtocolDetector implements IProtocolDetector { - /** - * Check if this detector can handle the data - */ - canHandle(data: Buffer): boolean { - return data.length >= 1; - } - - /** - * Perform quick detection based on first few bytes - */ - quickDetect(data: Buffer): IProtocolDetectionResult { - if (data.length === 0) { - return { - protocol: 'unknown', - confidence: 0, - requiresMoreData: true - }; - } - - // Check for TLS - const tlsResult = this.checkTls(data); - if (tlsResult.confidence > 80) { - return tlsResult; - } - - // Check for HTTP - const httpResult = this.checkHttp(data); - if (httpResult.confidence > 80) { - return httpResult; - } - - // Need more data or unknown - return { - protocol: 'unknown', - confidence: 0, - requiresMoreData: data.length < 20 - }; - } - - /** - * Check if data looks like TLS - */ - private checkTls(data: Buffer): IProtocolDetectionResult { - if (data.length < 3) { - return { - protocol: 'tls', - confidence: 0, - requiresMoreData: true - }; - } - - const firstByte = data[0]; - const secondByte = data[1]; - - // Check for valid TLS record type - const validRecordTypes = [ - TlsRecordType.CHANGE_CIPHER_SPEC, - TlsRecordType.ALERT, - TlsRecordType.HANDSHAKE, - TlsRecordType.APPLICATION_DATA, - TlsRecordType.HEARTBEAT - ]; - - if (!validRecordTypes.includes(firstByte)) { - return { - protocol: 'tls', - confidence: 0 - }; - } - - // Check TLS version byte (0x03 for all TLS/SSL versions) - if (secondByte !== 0x03) { - return { - protocol: 'tls', - confidence: 0 - }; - } - - // High confidence it's TLS - return { - protocol: 'tls', - confidence: 95, - metadata: { - recordType: firstByte - } - }; - } - - /** - * Check if data looks like HTTP - */ - private checkHttp(data: Buffer): IProtocolDetectionResult { - if (data.length < 3) { - return { - protocol: 'http', - confidence: 0, - requiresMoreData: true - }; - } - - // Quick check for HTTP methods - const start = data.subarray(0, Math.min(10, data.length)).toString('ascii'); - - // Check common HTTP methods - const httpMethods = ['GET ', 'POST ', 'PUT ', 'DELETE ', 'HEAD ', 'OPTIONS', 'PATCH ', 'CONNECT', 'TRACE ']; - for (const method of httpMethods) { - if (start.startsWith(method)) { - return { - protocol: 'http', - confidence: 95, - metadata: { - method: method.trim() - } - }; - } - } - - // Check if it might be HTTP but need more data - if (HttpParser.isPrintableAscii(data, Math.min(20, data.length))) { - // Could be HTTP, but not sure - return { - protocol: 'http', - confidence: 30, - requiresMoreData: data.length < 20 - }; - } - - return { - protocol: 'http', - confidence: 0 - }; - } -} \ No newline at end of file diff --git a/ts/detection/detectors/routing-extractor.ts b/ts/detection/detectors/routing-extractor.ts deleted file mode 100644 index a05ca26..0000000 --- a/ts/detection/detectors/routing-extractor.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Routing Information Extractor - * - * Extracts minimal routing information from protocols - * without full parsing - */ - -import type { IRoutingInfo, IConnectionContext, TProtocolType } from '../../protocols/common/types.js'; -import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js'; -import { HttpParser } from '../../protocols/http/index.js'; - -/** - * Extracts routing information from protocol data - */ -export class RoutingExtractor { - /** - * Extract routing info based on protocol type - */ - static extract( - data: Buffer, - protocol: TProtocolType, - context?: IConnectionContext - ): IRoutingInfo | null { - switch (protocol) { - case 'tls': - case 'https': - return this.extractTlsRouting(data, context); - - case 'http': - return this.extractHttpRouting(data); - - default: - return null; - } - } - - /** - * Extract routing from TLS ClientHello (SNI) - */ - private static extractTlsRouting( - data: Buffer, - context?: IConnectionContext - ): IRoutingInfo | null { - try { - // Quick SNI extraction without full parsing - const sni = SniExtraction.extractSNI(data); - - if (sni) { - return { - domain: sni, - protocol: 'tls', - port: 443 // Default HTTPS port - }; - } - - return null; - } catch (error) { - // Extraction failed, return null - return null; - } - } - - /** - * Extract routing from HTTP headers (Host header) - */ - private static extractHttpRouting(data: Buffer): IRoutingInfo | null { - try { - // Look for first line - const firstLineEnd = data.indexOf('\n'); - if (firstLineEnd === -1) { - return null; - } - - // Parse request line - const firstLine = data.subarray(0, firstLineEnd).toString('ascii').trim(); - const requestLine = HttpParser.parseRequestLine(firstLine); - - if (!requestLine) { - return null; - } - - // Look for Host header - let pos = firstLineEnd + 1; - const maxSearch = Math.min(data.length, 4096); // Don't search too far - - while (pos < maxSearch) { - const lineEnd = data.indexOf('\n', pos); - if (lineEnd === -1) break; - - const line = data.subarray(pos, lineEnd).toString('ascii').trim(); - - // Empty line means end of headers - if (line.length === 0) break; - - // Check for Host header - if (line.toLowerCase().startsWith('host:')) { - const hostValue = line.substring(5).trim(); - const domain = HttpParser.extractDomainFromHost(hostValue); - - return { - domain, - path: requestLine.path, - protocol: 'http', - port: 80 // Default HTTP port - }; - } - - pos = lineEnd + 1; - } - - // No Host header found, but we have the path - return { - path: requestLine.path, - protocol: 'http', - port: 80 - }; - } catch (error) { - // Extraction failed - return null; - } - } - - /** - * Try to extract domain from any protocol - */ - static extractDomain(data: Buffer, hint?: TProtocolType): string | null { - // If we have a hint, use it - if (hint) { - const routing = this.extract(data, hint); - return routing?.domain || null; - } - - // Try TLS first (more specific) - const tlsRouting = this.extractTlsRouting(data); - if (tlsRouting?.domain) { - return tlsRouting.domain; - } - - // Try HTTP - const httpRouting = this.extractHttpRouting(data); - if (httpRouting?.domain) { - return httpRouting.domain; - } - - return null; - } -} \ No newline at end of file diff --git a/ts/detection/detectors/tls-detector.ts b/ts/detection/detectors/tls-detector.ts deleted file mode 100644 index 4b5f9d0..0000000 --- a/ts/detection/detectors/tls-detector.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * TLS protocol detector - */ - -// TLS detector doesn't need plugins imports -import type { IProtocolDetector } from '../models/interfaces.js'; -import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js'; -import { readUInt16BE } from '../utils/buffer-utils.js'; -import { tlsVersionToString } from '../utils/parser-utils.js'; - -// Import from protocols -import { TlsRecordType, TlsHandshakeType, TlsExtensionType } from '../../protocols/tls/index.js'; - -// Import TLS utilities for SNI extraction from protocols -import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js'; -import { ClientHelloParser } from '../../protocols/tls/sni/client-hello-parser.js'; - -/** - * TLS detector implementation - */ -export class TlsDetector implements IProtocolDetector { - /** - * Minimum bytes needed to identify TLS (record header) - */ - private static readonly MIN_TLS_HEADER_SIZE = 5; - - - /** - * Detect TLS protocol from buffer - */ - detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null { - // Check if buffer is too small - if (buffer.length < TlsDetector.MIN_TLS_HEADER_SIZE) { - return null; - } - - // Check if this is a TLS record - if (!this.isTlsRecord(buffer)) { - return null; - } - - // Extract basic TLS info - const recordType = buffer[0]; - const tlsMajor = buffer[1]; - const tlsMinor = buffer[2]; - const recordLength = readUInt16BE(buffer, 3); - - // Initialize connection info - const connectionInfo: IConnectionInfo = { - protocol: 'tls', - tlsVersion: tlsVersionToString(tlsMajor, tlsMinor) || undefined - }; - - // If it's a handshake, try to extract more info - if (recordType === TlsRecordType.HANDSHAKE && buffer.length >= 6) { - const handshakeType = buffer[5]; - - // For ClientHello, extract SNI and other info - if (handshakeType === TlsHandshakeType.CLIENT_HELLO) { - // Check if we have the complete handshake - const totalRecordLength = recordLength + 5; // Including TLS header - if (buffer.length >= totalRecordLength) { - // Extract SNI using existing logic - const sni = SniExtraction.extractSNI(buffer); - if (sni) { - connectionInfo.domain = sni; - connectionInfo.sni = sni; - } - - // Parse ClientHello for additional info - const parseResult = ClientHelloParser.parseClientHello(buffer); - if (parseResult.isValid) { - // Extract ALPN if present - const alpnExtension = parseResult.extensions.find( - ext => ext.type === TlsExtensionType.APPLICATION_LAYER_PROTOCOL_NEGOTIATION - ); - - if (alpnExtension) { - connectionInfo.alpn = this.parseAlpnExtension(alpnExtension.data); - } - - // Store cipher suites if needed - if (parseResult.cipherSuites && options?.extractFullHeaders) { - connectionInfo.cipherSuites = this.parseCipherSuites(parseResult.cipherSuites); - } - } - - // Return complete result - return { - protocol: 'tls', - connectionInfo, - remainingBuffer: buffer.length > totalRecordLength - ? buffer.subarray(totalRecordLength) - : undefined, - isComplete: true - }; - } else { - // Incomplete handshake - return { - protocol: 'tls', - connectionInfo, - isComplete: false, - bytesNeeded: totalRecordLength - }; - } - } - } - - // For other TLS record types, just return basic info - return { - protocol: 'tls', - connectionInfo, - isComplete: true, - remainingBuffer: buffer.length > recordLength + 5 - ? buffer.subarray(recordLength + 5) - : undefined - }; - } - - /** - * Check if buffer can be handled by this detector - */ - canHandle(buffer: Buffer): boolean { - return buffer.length >= TlsDetector.MIN_TLS_HEADER_SIZE && - this.isTlsRecord(buffer); - } - - /** - * Get minimum bytes needed for detection - */ - getMinimumBytes(): number { - return TlsDetector.MIN_TLS_HEADER_SIZE; - } - - /** - * Check if buffer contains a valid TLS record - */ - private isTlsRecord(buffer: Buffer): boolean { - const recordType = buffer[0]; - - // Check for valid record type - const validTypes = [ - TlsRecordType.CHANGE_CIPHER_SPEC, - TlsRecordType.ALERT, - TlsRecordType.HANDSHAKE, - TlsRecordType.APPLICATION_DATA, - TlsRecordType.HEARTBEAT - ]; - - if (!validTypes.includes(recordType)) { - return false; - } - - // Check TLS version bytes (should be 0x03 0x0X) - if (buffer[1] !== 0x03) { - return false; - } - - // Check record length is reasonable - const recordLength = readUInt16BE(buffer, 3); - if (recordLength > 16384) { // Max TLS record size - return false; - } - - return true; - } - - /** - * Parse ALPN extension data - */ - private parseAlpnExtension(data: Buffer): string[] { - const protocols: string[] = []; - - if (data.length < 2) { - return protocols; - } - - const listLength = readUInt16BE(data, 0); - let offset = 2; - - while (offset < Math.min(2 + listLength, data.length)) { - const protoLength = data[offset]; - offset++; - - if (offset + protoLength <= data.length) { - const protocol = data.subarray(offset, offset + protoLength).toString('ascii'); - protocols.push(protocol); - offset += protoLength; - } else { - break; - } - } - - return protocols; - } - - /** - * Parse cipher suites - */ - private parseCipherSuites(cipherData: Buffer): number[] { - const suites: number[] = []; - - for (let i = 0; i < cipherData.length - 1; i += 2) { - const suite = readUInt16BE(cipherData, i); - suites.push(suite); - } - - return suites; - } - - /** - * Detect with context for fragmented data - */ - detectWithContext( - buffer: Buffer, - _context: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number }, - options?: IDetectionOptions - ): IDetectionResult | null { - // This method is deprecated - TLS detection should use the fragment manager - // from the parent detector system, not maintain its own fragments - return this.detect(buffer, options); - } -} \ No newline at end of file diff --git a/ts/detection/index.ts b/ts/detection/index.ts deleted file mode 100644 index a16a0a4..0000000 --- a/ts/detection/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Centralized Protocol Detection Module - * - * This module provides unified protocol detection capabilities for - * both TLS and HTTP protocols, extracting connection information - * without consuming the data stream. - */ - -// Main detector -export * from './protocol-detector.js'; - -// Models -export * from './models/detection-types.js'; -export * from './models/interfaces.js'; - -// Individual detectors -export * from './detectors/tls-detector.js'; -export * from './detectors/http-detector.js'; -export * from './detectors/quick-detector.js'; -export * from './detectors/routing-extractor.js'; - -// Utilities -export * from './utils/buffer-utils.js'; -export * from './utils/parser-utils.js'; -export * from './utils/fragment-manager.js'; \ No newline at end of file diff --git a/ts/detection/models/detection-types.ts b/ts/detection/models/detection-types.ts deleted file mode 100644 index 68930f0..0000000 --- a/ts/detection/models/detection-types.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Type definitions for protocol detection - */ - -/** - * Supported protocol types that can be detected - */ -export type TProtocolType = 'tls' | 'http' | 'unknown'; - -/** - * HTTP method types - */ -export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE'; - -/** - * TLS version identifiers - */ -export type TTlsVersion = 'SSLv3' | 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3'; - -/** - * Connection information extracted from protocol detection - */ -export interface IConnectionInfo { - /** - * The detected protocol type - */ - protocol: TProtocolType; - - /** - * Domain/hostname extracted from the connection - * - For TLS: from SNI extension - * - For HTTP: from Host header - */ - domain?: string; - - /** - * HTTP-specific fields - */ - method?: THttpMethod; - path?: string; - httpVersion?: string; - headers?: Record; - - /** - * TLS-specific fields - */ - tlsVersion?: TTlsVersion; - sni?: string; - alpn?: string[]; - cipherSuites?: number[]; -} - -/** - * Result of protocol detection - */ -export interface IDetectionResult { - /** - * The detected protocol type - */ - protocol: TProtocolType; - - /** - * Extracted connection information - */ - connectionInfo: IConnectionInfo; - - /** - * Any remaining buffer data after detection headers - * This can be used to continue processing the stream - */ - remainingBuffer?: Buffer; - - /** - * Whether the detection is complete or needs more data - */ - isComplete: boolean; - - /** - * Minimum bytes needed for complete detection (if incomplete) - */ - bytesNeeded?: number; -} - -/** - * Options for protocol detection - */ -export interface IDetectionOptions { - /** - * Maximum bytes to buffer for detection (default: 8192) - */ - maxBufferSize?: number; - - /** - * Timeout for detection in milliseconds (default: 5000) - */ - timeout?: number; - - /** - * Whether to extract full headers or just essential info - */ - extractFullHeaders?: boolean; -} \ No newline at end of file diff --git a/ts/detection/models/interfaces.ts b/ts/detection/models/interfaces.ts deleted file mode 100644 index 1299f9e..0000000 --- a/ts/detection/models/interfaces.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Interface definitions for protocol detection components - */ - -import type { IDetectionResult, IDetectionOptions } from './detection-types.js'; - -/** - * Interface for protocol detectors - */ -export interface IProtocolDetector { - /** - * Detect protocol from buffer data - * @param buffer The buffer to analyze - * @param options Detection options - * @returns Detection result or null if protocol cannot be determined - */ - detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null; - - /** - * Check if buffer potentially contains this protocol - * @param buffer The buffer to check - * @returns True if buffer might contain this protocol - */ - canHandle(buffer: Buffer): boolean; - - /** - * Get the minimum bytes needed for detection - */ - getMinimumBytes(): number; -} - -/** - * Interface for connection tracking during fragmented detection - */ -export interface IConnectionTracker { - /** - * Connection identifier - */ - id: string; - - /** - * Accumulated buffer data - */ - buffer: Buffer; - - /** - * Timestamp of first data - */ - startTime: number; - - /** - * Current detection state - */ - state: 'detecting' | 'complete' | 'failed'; - - /** - * Partial detection result (if any) - */ - partialResult?: Partial; -} - -/** - * Interface for buffer accumulator (handles fragmented data) - */ -export interface IBufferAccumulator { - /** - * Add data to accumulator - */ - append(data: Buffer): void; - - /** - * Get accumulated buffer - */ - getBuffer(): Buffer; - - /** - * Get buffer length - */ - length(): number; - - /** - * Clear accumulated data - */ - clear(): void; - - /** - * Check if accumulator has enough data - */ - hasMinimumBytes(minBytes: number): boolean; -} - -/** - * Detection events - */ -export interface IDetectionEvents { - /** - * Emitted when protocol is successfully detected - */ - detected: (result: IDetectionResult) => void; - - /** - * Emitted when detection fails - */ - failed: (error: Error) => void; - - /** - * Emitted when detection times out - */ - timeout: () => void; - - /** - * Emitted when more data is needed - */ - needMoreData: (bytesNeeded: number) => void; -} \ No newline at end of file diff --git a/ts/detection/protocol-detector.ts b/ts/detection/protocol-detector.ts deleted file mode 100644 index 7708f93..0000000 --- a/ts/detection/protocol-detector.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Protocol Detector - * - * Simplified protocol detection using the new architecture - */ - -import type { IDetectionResult, IDetectionOptions } from './models/detection-types.js'; -import type { IConnectionContext } from '../protocols/common/types.js'; -import { TlsDetector } from './detectors/tls-detector.js'; -import { HttpDetector } from './detectors/http-detector.js'; -import { DetectionFragmentManager } from './utils/fragment-manager.js'; - -/** - * Main protocol detector class - */ -export class ProtocolDetector { - private static instance: ProtocolDetector; - private fragmentManager: DetectionFragmentManager; - private tlsDetector: TlsDetector; - private httpDetector: HttpDetector; - private connectionProtocols: Map = new Map(); - - constructor() { - this.fragmentManager = new DetectionFragmentManager(); - this.tlsDetector = new TlsDetector(); - this.httpDetector = new HttpDetector(this.fragmentManager); - } - - private static getInstance(): ProtocolDetector { - if (!this.instance) { - this.instance = new ProtocolDetector(); - } - return this.instance; - } - - /** - * Detect protocol from buffer data - */ - static async detect(buffer: Buffer, options?: IDetectionOptions): Promise { - return this.getInstance().detectInstance(buffer, options); - } - - private async detectInstance(buffer: Buffer, options?: IDetectionOptions): Promise { - // Quick sanity check - if (!buffer || buffer.length === 0) { - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: true - }; - } - - // Try TLS detection first (more specific) - if (this.tlsDetector.canHandle(buffer)) { - const tlsResult = this.tlsDetector.detect(buffer, options); - if (tlsResult) { - return tlsResult; - } - } - - // Try HTTP detection - if (this.httpDetector.canHandle(buffer)) { - const httpResult = this.httpDetector.detect(buffer, options); - if (httpResult) { - return httpResult; - } - } - - // Neither TLS nor HTTP - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: true - }; - } - - /** - * Detect protocol with connection tracking for fragmented data - * @deprecated Use detectWithContext instead - */ - static async detectWithConnectionTracking( - buffer: Buffer, - connectionId: string, - options?: IDetectionOptions - ): Promise { - // Convert connection ID to context - const context: IConnectionContext = { - id: connectionId, - sourceIp: 'unknown', - sourcePort: 0, - destIp: 'unknown', - destPort: 0, - timestamp: Date.now() - }; - - return this.getInstance().detectWithContextInstance(buffer, context, options); - } - - /** - * Detect protocol with connection context for fragmented data - */ - static async detectWithContext( - buffer: Buffer, - context: IConnectionContext, - options?: IDetectionOptions - ): Promise { - return this.getInstance().detectWithContextInstance(buffer, context, options); - } - - private async detectWithContextInstance( - buffer: Buffer, - context: IConnectionContext, - options?: IDetectionOptions - ): Promise { - // Quick sanity check - if (!buffer || buffer.length === 0) { - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: true - }; - } - - const connectionId = DetectionFragmentManager.createConnectionId(context); - - // Check if we already know the protocol for this connection - const knownEntry = this.connectionProtocols.get(connectionId); - const knownProtocol = knownEntry?.protocol; - - if (knownProtocol === 'http') { - const result = this.httpDetector.detectWithContext(buffer, context, options); - if (result) { - if (result.isComplete) { - this.connectionProtocols.delete(connectionId); - } - return result; - } - } else if (knownProtocol === 'tls') { - // Handle TLS with fragment accumulation - const handler = this.fragmentManager.getHandler('tls'); - const fragmentResult = handler.addFragment(connectionId, buffer); - - if (fragmentResult.error) { - handler.complete(connectionId); - this.connectionProtocols.delete(connectionId); - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: true - }; - } - - const result = this.tlsDetector.detect(fragmentResult.buffer!, options); - if (result) { - if (result.isComplete) { - handler.complete(connectionId); - this.connectionProtocols.delete(connectionId); - } - return result; - } - } - - // If we don't know the protocol yet, try to detect it - if (!knownProtocol) { - // First peek to determine protocol type - if (this.tlsDetector.canHandle(buffer)) { - this.connectionProtocols.set(connectionId, { protocol: 'tls', createdAt: Date.now() }); - // Handle TLS with fragment accumulation - const handler = this.fragmentManager.getHandler('tls'); - const fragmentResult = handler.addFragment(connectionId, buffer); - - if (fragmentResult.error) { - handler.complete(connectionId); - this.connectionProtocols.delete(connectionId); - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: true - }; - } - - const result = this.tlsDetector.detect(fragmentResult.buffer!, options); - if (result) { - if (result.isComplete) { - handler.complete(connectionId); - this.connectionProtocols.delete(connectionId); - } - return result; - } - } - - if (this.httpDetector.canHandle(buffer)) { - this.connectionProtocols.set(connectionId, { protocol: 'http', createdAt: Date.now() }); - const result = this.httpDetector.detectWithContext(buffer, context, options); - if (result) { - if (result.isComplete) { - this.connectionProtocols.delete(connectionId); - } - return result; - } - } - } - - // Can't determine protocol - return { - protocol: 'unknown', - connectionInfo: { protocol: 'unknown' }, - isComplete: false, - bytesNeeded: Math.max( - this.tlsDetector.getMinimumBytes(), - this.httpDetector.getMinimumBytes() - ) - }; - } - - /** - * Clean up resources - */ - static cleanup(): void { - this.getInstance().cleanupInstance(); - } - - private cleanupInstance(): void { - this.fragmentManager.cleanup(); - // Remove stale connectionProtocols entries (abandoned handshakes, port scanners) - const maxAge = 30_000; // 30 seconds - const now = Date.now(); - for (const [id, entry] of this.connectionProtocols) { - if (now - entry.createdAt > maxAge) { - this.connectionProtocols.delete(id); - } - } - } - - /** - * Destroy detector instance - */ - static destroy(): void { - this.getInstance().destroyInstance(); - this.instance = null as any; - } - - private destroyInstance(): void { - this.fragmentManager.destroy(); - this.connectionProtocols.clear(); - } - - /** - * Clean up old connection tracking entries - * - * @param _maxAge Maximum age in milliseconds (default: 30 seconds) - */ - static cleanupConnections(_maxAge: number = 30000): void { - this.getInstance().cleanupInstance(); - } - - /** - * Clean up fragments for a specific connection - */ - static cleanupConnection(context: IConnectionContext): void { - const instance = this.getInstance(); - const connectionId = DetectionFragmentManager.createConnectionId(context); - - // Clean up both TLS and HTTP fragments for this connection - instance.fragmentManager.getHandler('tls').complete(connectionId); - instance.fragmentManager.getHandler('http').complete(connectionId); - - // Remove from connection protocols tracking - instance.connectionProtocols.delete(connectionId); - } - - /** - * Extract domain from connection info - */ - static extractDomain(connectionInfo: any): string | undefined { - return connectionInfo.domain || connectionInfo.sni || connectionInfo.host; - } - - /** - * Create a connection ID from connection parameters - * @deprecated Use createConnectionContext instead - */ - static createConnectionId(params: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - socketId?: string; - }): string { - // If socketId is provided, use it - if (params.socketId) { - return params.socketId; - } - - // Otherwise create from connection tuple - const { sourceIp = 'unknown', sourcePort = 0, destIp = 'unknown', destPort = 0 } = params; - return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; - } - - /** - * Create a connection context from parameters - */ - static createConnectionContext(params: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - socketId?: string; - }): IConnectionContext { - return { - id: params.socketId, - sourceIp: params.sourceIp || 'unknown', - sourcePort: params.sourcePort || 0, - destIp: params.destIp || 'unknown', - destPort: params.destPort || 0, - timestamp: Date.now() - }; - } -} \ No newline at end of file diff --git a/ts/detection/utils/buffer-utils.ts b/ts/detection/utils/buffer-utils.ts deleted file mode 100644 index 880a5f7..0000000 --- a/ts/detection/utils/buffer-utils.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Buffer manipulation utilities for protocol detection - */ - -// Import from protocols -import { HttpParser } from '../../protocols/http/index.js'; - -/** - * BufferAccumulator class for handling fragmented data - */ -export class BufferAccumulator { - private chunks: Buffer[] = []; - private totalLength = 0; - - /** - * Append data to the accumulator - */ - append(data: Buffer): void { - this.chunks.push(data); - this.totalLength += data.length; - } - - /** - * Get the accumulated buffer - */ - getBuffer(): Buffer { - if (this.chunks.length === 0) { - return Buffer.alloc(0); - } - if (this.chunks.length === 1) { - return this.chunks[0]; - } - return Buffer.concat(this.chunks, this.totalLength); - } - - /** - * Get current buffer length - */ - length(): number { - return this.totalLength; - } - - /** - * Clear all accumulated data - */ - clear(): void { - this.chunks = []; - this.totalLength = 0; - } - - /** - * Check if accumulator has minimum bytes - */ - hasMinimumBytes(minBytes: number): boolean { - return this.totalLength >= minBytes; - } -} - -/** - * Read a big-endian 16-bit integer from buffer - */ -export function readUInt16BE(buffer: Buffer, offset: number): number { - if (offset + 2 > buffer.length) { - throw new Error('Buffer too short for UInt16BE read'); - } - return (buffer[offset] << 8) | buffer[offset + 1]; -} - -/** - * Read a big-endian 24-bit integer from buffer - */ -export function readUInt24BE(buffer: Buffer, offset: number): number { - if (offset + 3 > buffer.length) { - throw new Error('Buffer too short for UInt24BE read'); - } - return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2]; -} - -/** - * Find a byte sequence in a buffer - */ -export function findSequence(buffer: Buffer, sequence: Buffer, startOffset = 0): number { - if (sequence.length === 0) { - return startOffset; - } - - const searchLength = buffer.length - sequence.length + 1; - for (let i = startOffset; i < searchLength; i++) { - let found = true; - for (let j = 0; j < sequence.length; j++) { - if (buffer[i + j] !== sequence[j]) { - found = false; - break; - } - } - if (found) { - return i; - } - } - return -1; -} - -/** - * Extract a line from buffer (up to CRLF or LF) - */ -export function extractLine(buffer: Buffer, startOffset = 0): { line: string; nextOffset: number } | null { - // Delegate to protocol parser - return HttpParser.extractLine(buffer, startOffset); -} - -/** - * Check if buffer starts with a string (case-insensitive) - */ -export function startsWithString(buffer: Buffer, str: string, offset = 0): boolean { - if (offset + str.length > buffer.length) { - return false; - } - - const bufferStr = buffer.slice(offset, offset + str.length).toString('utf8'); - return bufferStr.toLowerCase() === str.toLowerCase(); -} - -/** - * Safe buffer slice that doesn't throw on out-of-bounds - */ -export function safeSlice(buffer: Buffer, start: number, end?: number): Buffer { - const safeStart = Math.max(0, Math.min(start, buffer.length)); - const safeEnd = end === undefined - ? buffer.length - : Math.max(safeStart, Math.min(end, buffer.length)); - - return buffer.slice(safeStart, safeEnd); -} - -/** - * Check if buffer contains printable ASCII - */ -export function isPrintableAscii(buffer: Buffer, length?: number): boolean { - // Delegate to protocol parser - return HttpParser.isPrintableAscii(buffer, length); -} \ No newline at end of file diff --git a/ts/detection/utils/fragment-manager.ts b/ts/detection/utils/fragment-manager.ts deleted file mode 100644 index bab3f78..0000000 --- a/ts/detection/utils/fragment-manager.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Fragment Manager for Detection Module - * - * Manages fragmented protocol data using the shared fragment handler - */ - -import { FragmentHandler, type IFragmentOptions } from '../../protocols/common/fragment-handler.js'; -import type { IConnectionContext } from '../../protocols/common/types.js'; - -/** - * Detection-specific fragment manager - */ -export class DetectionFragmentManager { - private tlsFragments: FragmentHandler; - private httpFragments: FragmentHandler; - - constructor() { - // Configure fragment handlers with appropriate limits - const tlsOptions: IFragmentOptions = { - maxBufferSize: 16384, // TLS record max size - timeout: 5000, - cleanupInterval: 30000 - }; - - const httpOptions: IFragmentOptions = { - maxBufferSize: 8192, // HTTP header reasonable limit - timeout: 5000, - cleanupInterval: 30000 - }; - - this.tlsFragments = new FragmentHandler(tlsOptions); - this.httpFragments = new FragmentHandler(httpOptions); - } - - /** - * Get fragment handler for protocol type - */ - getHandler(protocol: 'tls' | 'http'): FragmentHandler { - return protocol === 'tls' ? this.tlsFragments : this.httpFragments; - } - - /** - * Create connection ID from context - */ - static createConnectionId(context: IConnectionContext): string { - return context.id || `${context.sourceIp}:${context.sourcePort}-${context.destIp}:${context.destPort}`; - } - - /** - * Clean up all handlers - */ - cleanup(): void { - this.tlsFragments.cleanup(); - this.httpFragments.cleanup(); - } - - /** - * Destroy all handlers - */ - destroy(): void { - this.tlsFragments.destroy(); - this.httpFragments.destroy(); - } -} \ No newline at end of file diff --git a/ts/detection/utils/parser-utils.ts b/ts/detection/utils/parser-utils.ts deleted file mode 100644 index b2887ee..0000000 --- a/ts/detection/utils/parser-utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Parser utilities for protocol detection - * Now delegates to protocol modules for actual parsing - */ - -import type { THttpMethod, TTlsVersion } from '../models/detection-types.js'; -import { HttpParser, HTTP_METHODS, HTTP_VERSIONS } from '../../protocols/http/index.js'; -import { tlsVersionToString as protocolTlsVersionToString } from '../../protocols/tls/index.js'; - -// Re-export constants for backward compatibility -export { HTTP_METHODS, HTTP_VERSIONS }; - -/** - * Parse HTTP request line - */ -export function parseHttpRequestLine(line: string): { - method: THttpMethod; - path: string; - version: string; -} | null { - // Delegate to protocol parser - const result = HttpParser.parseRequestLine(line); - return result ? { - method: result.method as THttpMethod, - path: result.path, - version: result.version - } : null; -} - -/** - * Parse HTTP header line - */ -export function parseHttpHeader(line: string): { name: string; value: string } | null { - // Delegate to protocol parser - return HttpParser.parseHeaderLine(line); -} - -/** - * Parse HTTP headers from lines - */ -export function parseHttpHeaders(lines: string[]): Record { - // Delegate to protocol parser - return HttpParser.parseHeaders(lines); -} - -/** - * Convert TLS version bytes to version string - */ -export function tlsVersionToString(major: number, minor: number): TTlsVersion | null { - // Delegate to protocol parser - return protocolTlsVersionToString(major, minor) as TTlsVersion; -} - -/** - * Extract domain from Host header value - */ -export function extractDomainFromHost(hostHeader: string): string { - // Delegate to protocol parser - return HttpParser.extractDomainFromHost(hostHeader); -} - -/** - * Validate domain name - */ -export function isValidDomain(domain: string): boolean { - // Delegate to protocol parser - return HttpParser.isValidDomain(domain); -} - -/** - * Check if string is a valid HTTP method - */ -export function isHttpMethod(str: string): str is THttpMethod { - // Delegate to protocol parser - return HttpParser.isHttpMethod(str) && (str as THttpMethod) !== undefined; -} - diff --git a/ts/index.ts b/ts/index.ts index 4f7feb8..1af742d 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -11,18 +11,12 @@ export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js'; export * from './proxies/smart-proxy/utils/index.js'; -// Original: export * from './smartproxy/classes.pp.snihandler.js' -// Now we export from the new module -export { SniHandler } from './tls/sni/sni-handler.js'; - // Core types and utilities export * from './core/models/common-types.js'; // Export IAcmeOptions from one place only export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js'; -// Modular exports for new architecture -export * as tls from './tls/index.js'; +// Modular exports export * as routing from './routing/index.js'; -export * as detection from './detection/index.js'; export * as protocols from './protocols/index.js'; diff --git a/ts/protocols/common/fragment-handler.ts b/ts/protocols/common/fragment-handler.ts deleted file mode 100644 index 714effe..0000000 --- a/ts/protocols/common/fragment-handler.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Shared Fragment Handler for Protocol Detection - * - * Provides unified fragment buffering and reassembly for protocols - * that may span multiple TCP packets. - */ - -import { Buffer } from 'node:buffer'; - -/** - * Fragment tracking information - */ -export interface IFragmentInfo { - buffer: Buffer; - timestamp: number; - connectionId: string; -} - -/** - * Options for fragment handling - */ -export interface IFragmentOptions { - maxBufferSize?: number; - timeout?: number; - cleanupInterval?: number; -} - -/** - * Result of fragment processing - */ -export interface IFragmentResult { - isComplete: boolean; - buffer?: Buffer; - needsMoreData: boolean; - error?: string; -} - -/** - * Shared fragment handler for protocol detection - */ -export class FragmentHandler { - private fragments = new Map(); - private cleanupTimer?: NodeJS.Timeout; - - constructor(private options: IFragmentOptions = {}) { - // Start cleanup timer if not already running - if (options.cleanupInterval && !this.cleanupTimer) { - this.cleanupTimer = setInterval( - () => this.cleanup(), - options.cleanupInterval - ); - // Don't let this timer prevent process exit - if (this.cleanupTimer.unref) { - this.cleanupTimer.unref(); - } - } - } - - /** - * Add a fragment for a connection - */ - addFragment(connectionId: string, fragment: Buffer): IFragmentResult { - const existing = this.fragments.get(connectionId); - - if (existing) { - // Append to existing buffer - const newBuffer = Buffer.concat([existing.buffer, fragment]); - - // Check size limit - const maxSize = this.options.maxBufferSize || 65536; - if (newBuffer.length > maxSize) { - this.fragments.delete(connectionId); - return { - isComplete: false, - needsMoreData: false, - error: 'Buffer size exceeded maximum allowed' - }; - } - - // Update fragment info - this.fragments.set(connectionId, { - buffer: newBuffer, - timestamp: Date.now(), - connectionId - }); - - return { - isComplete: false, - buffer: newBuffer, - needsMoreData: true - }; - } else { - // New fragment - this.fragments.set(connectionId, { - buffer: fragment, - timestamp: Date.now(), - connectionId - }); - - return { - isComplete: false, - buffer: fragment, - needsMoreData: true - }; - } - } - - /** - * Get the current buffer for a connection - */ - getBuffer(connectionId: string): Buffer | undefined { - return this.fragments.get(connectionId)?.buffer; - } - - /** - * Mark a connection as complete and clean up - */ - complete(connectionId: string): void { - this.fragments.delete(connectionId); - } - - /** - * Check if we're tracking a connection - */ - hasConnection(connectionId: string): boolean { - return this.fragments.has(connectionId); - } - - /** - * Clean up expired fragments - */ - cleanup(): void { - const now = Date.now(); - const timeout = this.options.timeout || 5000; - - for (const [connectionId, info] of this.fragments.entries()) { - if (now - info.timestamp > timeout) { - this.fragments.delete(connectionId); - } - } - } - - /** - * Clear all fragments - */ - clear(): void { - this.fragments.clear(); - } - - /** - * Destroy the handler and clean up resources - */ - destroy(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = undefined; - } - this.clear(); - } - - /** - * Get the number of tracked connections - */ - get size(): number { - return this.fragments.size; - } -} \ No newline at end of file diff --git a/ts/protocols/common/index.ts b/ts/protocols/common/index.ts deleted file mode 100644 index 3586bd9..0000000 --- a/ts/protocols/common/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Common Protocol Infrastructure - * - * Shared utilities and types for protocol handling - */ - -export * from './fragment-handler.js'; -export * from './types.js'; \ No newline at end of file diff --git a/ts/protocols/common/types.ts b/ts/protocols/common/types.ts deleted file mode 100644 index 1e50273..0000000 --- a/ts/protocols/common/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Common Protocol Types - * - * Shared types used across different protocol implementations - */ - -/** - * Supported protocol types - */ -export type TProtocolType = 'tls' | 'http' | 'https' | 'websocket' | 'unknown'; - -/** - * Protocol detection result - */ -export interface IProtocolDetectionResult { - protocol: TProtocolType; - confidence: number; // 0-100 - requiresMoreData?: boolean; - metadata?: { - version?: string; - method?: string; - [key: string]: any; - }; -} - -/** - * Routing information extracted from protocols - */ -export interface IRoutingInfo { - domain?: string; - port?: number; - path?: string; - protocol: TProtocolType; -} - -/** - * Connection context for protocol operations - */ -export interface IConnectionContext { - id: string; - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - timestamp?: number; -} - -/** - * Protocol detection options - */ -export interface IProtocolDetectionOptions { - quickMode?: boolean; // Only do minimal detection - extractRouting?: boolean; // Extract routing information - maxWaitTime?: number; // Max time to wait for complete data - maxBufferSize?: number; // Max buffer size for fragmented data -} - -/** - * Base interface for protocol detectors - */ -export interface IProtocolDetector { - /** - * Check if this detector can handle the data - */ - canHandle(data: Buffer): boolean; - - /** - * Perform quick detection (first few bytes only) - */ - quickDetect(data: Buffer): IProtocolDetectionResult; - - /** - * Extract routing information if possible - */ - extractRouting?(data: Buffer, context?: IConnectionContext): IRoutingInfo | null; -} \ No newline at end of file diff --git a/ts/protocols/http/index.ts b/ts/protocols/http/index.ts index 8f602ff..898fafb 100644 --- a/ts/protocols/http/index.ts +++ b/ts/protocols/http/index.ts @@ -4,5 +4,4 @@ */ export * from './constants.js'; -export * from './types.js'; -export * from './parser.js'; \ No newline at end of file +export * from './types.js'; \ No newline at end of file diff --git a/ts/protocols/http/parser.ts b/ts/protocols/http/parser.ts deleted file mode 100644 index 22aa3bf..0000000 --- a/ts/protocols/http/parser.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * HTTP Protocol Parser - * Generic HTTP parsing utilities - */ - -import { HTTP_METHODS, type THttpMethod, type THttpVersion } from './constants.js'; -import type { IHttpRequestLine, IHttpHeader } from './types.js'; - -/** - * HTTP parser utilities - */ -export class HttpParser { - /** - * Check if string is a valid HTTP method - */ - static isHttpMethod(str: string): str is THttpMethod { - return HTTP_METHODS.includes(str as THttpMethod); - } - - /** - * Parse HTTP request line - */ - static parseRequestLine(line: string): IHttpRequestLine | null { - const parts = line.trim().split(' '); - - if (parts.length !== 3) { - return null; - } - - const [method, path, version] = parts; - - // Validate method - if (!this.isHttpMethod(method)) { - return null; - } - - // Validate version - if (!version.startsWith('HTTP/')) { - return null; - } - - return { - method: method as THttpMethod, - path, - version: version as THttpVersion - }; - } - - /** - * Parse HTTP header line - */ - static parseHeaderLine(line: string): IHttpHeader | null { - const colonIndex = line.indexOf(':'); - - if (colonIndex === -1) { - return null; - } - - const name = line.slice(0, colonIndex).trim(); - const value = line.slice(colonIndex + 1).trim(); - - if (!name) { - return null; - } - - return { name, value }; - } - - /** - * Parse HTTP headers from lines - */ - static parseHeaders(lines: string[]): Record { - const headers: Record = {}; - - for (const line of lines) { - const header = this.parseHeaderLine(line); - if (header) { - // Convert header names to lowercase for consistency - headers[header.name.toLowerCase()] = header.value; - } - } - - return headers; - } - - /** - * Extract domain from Host header value - */ - static extractDomainFromHost(hostHeader: string): string { - // Remove port if present - const colonIndex = hostHeader.lastIndexOf(':'); - if (colonIndex !== -1) { - // Check if it's not part of IPv6 address - const beforeColon = hostHeader.slice(0, colonIndex); - if (!beforeColon.includes(']')) { - return beforeColon; - } - } - return hostHeader; - } - - /** - * Validate domain name - */ - static isValidDomain(domain: string): boolean { - // Basic domain validation - if (!domain || domain.length > 253) { - return false; - } - - // Check for valid characters and structure - const domainRegex = /^(?!-)[A-Za-z0-9-]{1,63}(? 126) { - if (byte !== 9 && byte !== 10 && byte !== 13) { - return false; - } - } - } - - return true; - } - - /** - * Quick check if buffer starts with HTTP method - */ - static quickCheck(buffer: Buffer): boolean { - if (buffer.length < 3) { - return false; - } - - // Check common HTTP methods - const start = buffer.slice(0, 7).toString('ascii'); - return start.startsWith('GET ') || - start.startsWith('POST ') || - start.startsWith('PUT ') || - start.startsWith('DELETE ') || - start.startsWith('HEAD ') || - start.startsWith('OPTIONS') || - start.startsWith('PATCH ') || - start.startsWith('CONNECT') || - start.startsWith('TRACE '); - } - - /** - * Parse query string - */ - static parseQueryString(queryString: string): Record { - const params: Record = {}; - - if (!queryString) { - return params; - } - - // Remove leading '?' if present - if (queryString.startsWith('?')) { - queryString = queryString.slice(1); - } - - const pairs = queryString.split('&'); - for (const pair of pairs) { - const [key, value] = pair.split('='); - if (key) { - params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; - } - } - - return params; - } - - /** - * Build query string from params - */ - static buildQueryString(params: Record): string { - const pairs: string[] = []; - - for (const [key, value] of Object.entries(params)) { - pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } - - return pairs.length > 0 ? '?' + pairs.join('&') : ''; - } -} \ No newline at end of file diff --git a/ts/protocols/index.ts b/ts/protocols/index.ts index fbbec38..0f77c71 100644 --- a/ts/protocols/index.ts +++ b/ts/protocols/index.ts @@ -1,12 +1,5 @@ /** * Protocol-specific modules for smartproxy - * - * This directory contains generic protocol knowledge separated from - * smartproxy-specific implementation details. */ -export * as common from './common/index.js'; -export * as tls from './tls/index.js'; export * as http from './http/index.js'; -export * as proxy from './proxy/index.js'; -export * as websocket from './websocket/index.js'; \ No newline at end of file diff --git a/ts/protocols/proxy/index.ts b/ts/protocols/proxy/index.ts deleted file mode 100644 index c4938a0..0000000 --- a/ts/protocols/proxy/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * PROXY Protocol Module - * Type definitions for HAProxy PROXY protocol v1/v2 - */ - -export * from './types.js'; \ No newline at end of file diff --git a/ts/protocols/proxy/types.ts b/ts/protocols/proxy/types.ts deleted file mode 100644 index 94315a9..0000000 --- a/ts/protocols/proxy/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * PROXY Protocol Type Definitions - * Based on HAProxy PROXY protocol specification - */ - -/** - * PROXY protocol version - */ -export type TProxyProtocolVersion = 'v1' | 'v2'; - -/** - * Connection protocol type - */ -export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UDP4' | 'UDP6' | 'UNKNOWN'; - -/** - * Interface representing parsed PROXY protocol information - */ -export interface IProxyInfo { - protocol: TProxyProtocol; - sourceIP: string; - sourcePort: number; - destinationIP: string; - destinationPort: number; -} - -/** - * Interface for parse result including remaining data - */ -export interface IProxyParseResult { - proxyInfo: IProxyInfo | null; - remainingData: Buffer; -} - -/** - * PROXY protocol v2 header format - */ -export interface IProxyV2Header { - signature: Buffer; - versionCommand: number; - family: number; - length: number; -} - -/** - * Connection information for PROXY protocol - */ -export interface IProxyConnectionInfo { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; -} \ No newline at end of file diff --git a/ts/protocols/tls/alerts/index.ts b/ts/protocols/tls/alerts/index.ts deleted file mode 100644 index 4681bb4..0000000 --- a/ts/protocols/tls/alerts/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * TLS alerts - */ diff --git a/ts/protocols/tls/alerts/tls-alert.ts b/ts/protocols/tls/alerts/tls-alert.ts deleted file mode 100644 index aac4d84..0000000 --- a/ts/protocols/tls/alerts/tls-alert.ts +++ /dev/null @@ -1,259 +0,0 @@ -import * as plugins from '../../../plugins.js'; -import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js'; - -/** - * TlsAlert class for creating and sending TLS alert messages - */ -export class TlsAlert { - // Use enum values from TlsAlertLevel - static readonly LEVEL_WARNING = TlsAlertLevel.WARNING; - static readonly LEVEL_FATAL = TlsAlertLevel.FATAL; - - // Use enum values from TlsAlertDescription - static readonly CLOSE_NOTIFY = TlsAlertDescription.CLOSE_NOTIFY; - static readonly UNEXPECTED_MESSAGE = TlsAlertDescription.UNEXPECTED_MESSAGE; - static readonly BAD_RECORD_MAC = TlsAlertDescription.BAD_RECORD_MAC; - static readonly DECRYPTION_FAILED = TlsAlertDescription.DECRYPTION_FAILED; - static readonly RECORD_OVERFLOW = TlsAlertDescription.RECORD_OVERFLOW; - static readonly DECOMPRESSION_FAILURE = TlsAlertDescription.DECOMPRESSION_FAILURE; - static readonly HANDSHAKE_FAILURE = TlsAlertDescription.HANDSHAKE_FAILURE; - static readonly NO_CERTIFICATE = TlsAlertDescription.NO_CERTIFICATE; - static readonly BAD_CERTIFICATE = TlsAlertDescription.BAD_CERTIFICATE; - static readonly UNSUPPORTED_CERTIFICATE = TlsAlertDescription.UNSUPPORTED_CERTIFICATE; - static readonly CERTIFICATE_REVOKED = TlsAlertDescription.CERTIFICATE_REVOKED; - static readonly CERTIFICATE_EXPIRED = TlsAlertDescription.CERTIFICATE_EXPIRED; - static readonly CERTIFICATE_UNKNOWN = TlsAlertDescription.CERTIFICATE_UNKNOWN; - static readonly ILLEGAL_PARAMETER = TlsAlertDescription.ILLEGAL_PARAMETER; - static readonly UNKNOWN_CA = TlsAlertDescription.UNKNOWN_CA; - static readonly ACCESS_DENIED = TlsAlertDescription.ACCESS_DENIED; - static readonly DECODE_ERROR = TlsAlertDescription.DECODE_ERROR; - static readonly DECRYPT_ERROR = TlsAlertDescription.DECRYPT_ERROR; - static readonly EXPORT_RESTRICTION = TlsAlertDescription.EXPORT_RESTRICTION; - static readonly PROTOCOL_VERSION = TlsAlertDescription.PROTOCOL_VERSION; - static readonly INSUFFICIENT_SECURITY = TlsAlertDescription.INSUFFICIENT_SECURITY; - static readonly INTERNAL_ERROR = TlsAlertDescription.INTERNAL_ERROR; - static readonly INAPPROPRIATE_FALLBACK = TlsAlertDescription.INAPPROPRIATE_FALLBACK; - static readonly USER_CANCELED = TlsAlertDescription.USER_CANCELED; - static readonly NO_RENEGOTIATION = TlsAlertDescription.NO_RENEGOTIATION; - static readonly MISSING_EXTENSION = TlsAlertDescription.MISSING_EXTENSION; - static readonly UNSUPPORTED_EXTENSION = TlsAlertDescription.UNSUPPORTED_EXTENSION; - static readonly CERTIFICATE_REQUIRED = TlsAlertDescription.CERTIFICATE_REQUIRED; - static readonly UNRECOGNIZED_NAME = TlsAlertDescription.UNRECOGNIZED_NAME; - static readonly BAD_CERTIFICATE_STATUS_RESPONSE = TlsAlertDescription.BAD_CERTIFICATE_STATUS_RESPONSE; - static readonly BAD_CERTIFICATE_HASH_VALUE = TlsAlertDescription.BAD_CERTIFICATE_HASH_VALUE; - static readonly UNKNOWN_PSK_IDENTITY = TlsAlertDescription.UNKNOWN_PSK_IDENTITY; - static readonly CERTIFICATE_REQUIRED_1_3 = TlsAlertDescription.CERTIFICATE_REQUIRED_1_3; - static readonly NO_APPLICATION_PROTOCOL = TlsAlertDescription.NO_APPLICATION_PROTOCOL; - - /** - * Create a TLS alert buffer with the specified level and description code - * - * @param level Alert level (warning or fatal) - * @param description Alert description code - * @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303) - * @returns Buffer containing the TLS alert message - */ - static create( - level: number, - description: number, - tlsVersion: [number, number] = [TlsVersion.TLS1_2[0], TlsVersion.TLS1_2[1]] - ): Buffer { - return Buffer.from([ - 0x15, // Alert record type - tlsVersion[0], - tlsVersion[1], // TLS version (default to TLS 1.2: 0x0303) - 0x00, - 0x02, // Length - level, // Alert level - description, // Alert description - ]); - } - - /** - * Create a warning-level TLS alert - * - * @param description Alert description code - * @returns Buffer containing the warning-level TLS alert message - */ - static createWarning(description: number): Buffer { - return this.create(this.LEVEL_WARNING, description); - } - - /** - * Create a fatal-level TLS alert - * - * @param description Alert description code - * @returns Buffer containing the fatal-level TLS alert message - */ - static createFatal(description: number): Buffer { - return this.create(this.LEVEL_FATAL, description); - } - - /** - * Send a TLS alert to a socket and optionally close the connection - * - * @param socket The socket to send the alert to - * @param level Alert level (warning or fatal) - * @param description Alert description code - * @param closeAfterSend Whether to close the connection after sending the alert - * @param closeDelay Milliseconds to wait before closing the connection (default: 200ms) - * @returns Promise that resolves when the alert has been sent - */ - static async send( - socket: plugins.net.Socket, - level: number, - description: number, - closeAfterSend: boolean = false, - closeDelay: number = 200 - ): Promise { - const alert = this.create(level, description); - - return new Promise((resolve, reject) => { - try { - // Ensure the alert is written as a single packet - socket.cork(); - const writeSuccessful = socket.write(alert, (err) => { - if (err) { - reject(err); - return; - } - - if (closeAfterSend) { - setTimeout(() => { - socket.end(); - resolve(); - }, closeDelay); - } else { - resolve(); - } - }); - socket.uncork(); - - // If write wasn't successful immediately, wait for drain - if (!writeSuccessful && !closeAfterSend) { - socket.once('drain', () => { - resolve(); - }); - } - } catch (err) { - reject(err); - } - }); - } - - /** - * Pre-defined TLS alert messages - */ - static readonly alerts = { - // Warning level alerts - closeNotify: TlsAlert.createWarning(TlsAlert.CLOSE_NOTIFY), - unsupportedExtension: TlsAlert.createWarning(TlsAlert.UNSUPPORTED_EXTENSION), - certificateRequired: TlsAlert.createWarning(TlsAlert.CERTIFICATE_REQUIRED), - unrecognizedName: TlsAlert.createWarning(TlsAlert.UNRECOGNIZED_NAME), - noRenegotiation: TlsAlert.createWarning(TlsAlert.NO_RENEGOTIATION), - userCanceled: TlsAlert.createWarning(TlsAlert.USER_CANCELED), - - // Warning level alerts for session resumption - certificateExpiredWarning: TlsAlert.createWarning(TlsAlert.CERTIFICATE_EXPIRED), - handshakeFailureWarning: TlsAlert.createWarning(TlsAlert.HANDSHAKE_FAILURE), - insufficientSecurityWarning: TlsAlert.createWarning(TlsAlert.INSUFFICIENT_SECURITY), - - // Fatal level alerts - unexpectedMessage: TlsAlert.createFatal(TlsAlert.UNEXPECTED_MESSAGE), - badRecordMac: TlsAlert.createFatal(TlsAlert.BAD_RECORD_MAC), - recordOverflow: TlsAlert.createFatal(TlsAlert.RECORD_OVERFLOW), - handshakeFailure: TlsAlert.createFatal(TlsAlert.HANDSHAKE_FAILURE), - badCertificate: TlsAlert.createFatal(TlsAlert.BAD_CERTIFICATE), - certificateExpired: TlsAlert.createFatal(TlsAlert.CERTIFICATE_EXPIRED), - certificateUnknown: TlsAlert.createFatal(TlsAlert.CERTIFICATE_UNKNOWN), - illegalParameter: TlsAlert.createFatal(TlsAlert.ILLEGAL_PARAMETER), - unknownCA: TlsAlert.createFatal(TlsAlert.UNKNOWN_CA), - accessDenied: TlsAlert.createFatal(TlsAlert.ACCESS_DENIED), - decodeError: TlsAlert.createFatal(TlsAlert.DECODE_ERROR), - decryptError: TlsAlert.createFatal(TlsAlert.DECRYPT_ERROR), - protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION), - insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY), - internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR), - unrecognizedNameFatal: TlsAlert.createFatal(TlsAlert.UNRECOGNIZED_NAME), - }; - - /** - * Utility method to send a warning-level unrecognized_name alert - * Specifically designed for SNI issues to encourage the client to retry with SNI - * - * @param socket The socket to send the alert to - * @returns Promise that resolves when the alert has been sent - */ - static async sendSniRequired(socket: plugins.net.Socket): Promise { - return this.send(socket, this.LEVEL_WARNING, this.UNRECOGNIZED_NAME); - } - - /** - * Utility method to send a close_notify alert and close the connection - * - * @param socket The socket to send the alert to - * @param closeDelay Milliseconds to wait before closing the connection (default: 200ms) - * @returns Promise that resolves when the alert has been sent and the connection closed - */ - static async sendCloseNotify(socket: plugins.net.Socket, closeDelay: number = 200): Promise { - return this.send(socket, this.LEVEL_WARNING, this.CLOSE_NOTIFY, true, closeDelay); - } - - /** - * Utility method to send a certificate_expired alert to force new TLS session - * - * @param socket The socket to send the alert to - * @param fatal Whether to send as a fatal alert (default: false) - * @param closeAfterSend Whether to close the connection after sending the alert (default: true) - * @param closeDelay Milliseconds to wait before closing the connection (default: 200ms) - * @returns Promise that resolves when the alert has been sent - */ - static async sendCertificateExpired( - socket: plugins.net.Socket, - fatal: boolean = false, - closeAfterSend: boolean = true, - closeDelay: number = 200 - ): Promise { - const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING; - return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay); - } - - /** - * Send a sequence of alerts to force SNI from clients - * This combines multiple alerts to ensure maximum browser compatibility - * - * @param socket The socket to send the alerts to - * @returns Promise that resolves when all alerts have been sent - */ - static async sendForceSniSequence(socket: plugins.net.Socket): Promise { - try { - // Send unrecognized_name (warning) - socket.cork(); - socket.write(this.alerts.unrecognizedName); - socket.uncork(); - - // Give the socket time to send the alert - return new Promise((resolve) => { - setTimeout(resolve, 50); - }); - } catch (err) { - return Promise.reject(err); - } - } - - /** - * Send a fatal level alert that immediately terminates the connection - * - * @param socket The socket to send the alert to - * @param description Alert description code - * @param closeDelay Milliseconds to wait before closing the connection (default: 100ms) - * @returns Promise that resolves when the alert has been sent and the connection closed - */ - static async sendFatalAndClose( - socket: plugins.net.Socket, - description: number, - closeDelay: number = 100 - ): Promise { - return this.send(socket, this.LEVEL_FATAL, description, true, closeDelay); - } -} \ No newline at end of file diff --git a/ts/protocols/tls/index.ts b/ts/protocols/tls/index.ts deleted file mode 100644 index 55e2341..0000000 --- a/ts/protocols/tls/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * TLS Protocol Module - * Contains generic TLS protocol knowledge including parsers, constants, and utilities - */ - -// Export all sub-modules -export * from './alerts/index.js'; -export * from './sni/index.js'; -export * from './utils/index.js'; - -// Re-export main utilities and types for convenience -export { - TlsUtils, - TlsRecordType, - TlsHandshakeType, - TlsExtensionType, - TlsAlertLevel, - TlsAlertDescription, - TlsVersion -} from './utils/tls-utils.js'; -export { TlsAlert } from './alerts/tls-alert.js'; -export { ClientHelloParser } from './sni/client-hello-parser.js'; -export { SniExtraction } from './sni/sni-extraction.js'; - -// Export tlsVersionToString helper -export function tlsVersionToString(major: number, minor: number): string | null { - if (major === 0x03) { - switch (minor) { - case 0x00: return 'SSLv3'; - case 0x01: return 'TLSv1.0'; - case 0x02: return 'TLSv1.1'; - case 0x03: return 'TLSv1.2'; - case 0x04: return 'TLSv1.3'; - } - } - return null; -} \ No newline at end of file diff --git a/ts/protocols/tls/sni/client-hello-parser.ts b/ts/protocols/tls/sni/client-hello-parser.ts deleted file mode 100644 index b9b08ac..0000000 --- a/ts/protocols/tls/sni/client-hello-parser.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { - TlsRecordType, - TlsHandshakeType, - TlsExtensionType, - TlsUtils -} from '../utils/tls-utils.js'; - -/** - * Interface for logging functions used by the parser - */ -export type LoggerFunction = (message: string) => void; - -/** - * Result of a session resumption check - */ -export interface SessionResumptionResult { - isResumption: boolean; - hasSNI: boolean; -} - -/** - * Information about parsed TLS extensions - */ -export interface ExtensionInfo { - type: number; - length: number; - data: Buffer; -} - -/** - * Result of a ClientHello parse operation - */ -export interface ClientHelloParseResult { - isValid: boolean; - version?: [number, number]; - random?: Buffer; - sessionId?: Buffer; - hasSessionId: boolean; - cipherSuites?: Buffer; - compressionMethods?: Buffer; - extensions: ExtensionInfo[]; - serverNameList?: string[]; - hasSessionTicket: boolean; - hasPsk: boolean; - hasEarlyData: boolean; - error?: string; -} - -/** - * Fragment tracking information - */ -export interface FragmentTrackingInfo { - buffer: Buffer; - timestamp: number; - connectionId: string; -} - -/** - * Class for parsing TLS ClientHello messages - */ -export class ClientHelloParser { - // Buffer for handling fragmented ClientHello messages - private static fragmentedBuffers: Map = new Map(); - private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup - - /** - * Clean up expired fragments - */ - private static cleanupExpiredFragments(): void { - const now = Date.now(); - for (const [connectionId, info] of this.fragmentedBuffers.entries()) { - if (now - info.timestamp > this.fragmentTimeout) { - this.fragmentedBuffers.delete(connectionId); - } - } - } - - /** - * Handles potential fragmented ClientHello messages by buffering and reassembling - * TLS record fragments that might span multiple TCP packets. - * - * @param buffer The current buffer fragment - * @param connectionId Unique identifier for the connection - * @param logger Optional logging function - * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed - */ - public static handleFragmentedClientHello( - buffer: Buffer, - connectionId: string, - logger?: LoggerFunction - ): Buffer | undefined { - const log = logger || (() => {}); - - // Periodically clean up expired fragments - this.cleanupExpiredFragments(); - - // Check if we've seen this connection before - if (!this.fragmentedBuffers.has(connectionId)) { - // New connection, start with this buffer - this.fragmentedBuffers.set(connectionId, { - buffer, - timestamp: Date.now(), - connectionId - }); - - // Evaluate if this buffer already contains a complete ClientHello - try { - if (buffer.length >= 5) { - // Get the record length from TLS header - const recordLength = (buffer[3] << 8) + buffer[4] + 5; // +5 for the TLS record header itself - log(`Initial buffer size: ${buffer.length}, expected record length: ${recordLength}`); - - // Check if this buffer already contains a complete TLS record - if (buffer.length >= recordLength) { - log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`); - return buffer; - } - } else { - log( - `Initial buffer too small (${buffer.length} bytes), needs at least 5 bytes for TLS header` - ); - } - } catch (e) { - log(`Error checking initial buffer completeness: ${e}`); - } - - log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`); - return undefined; // Need more fragments - } else { - // Existing connection, append this buffer - const existingInfo = this.fragmentedBuffers.get(connectionId)!; - const newBuffer = Buffer.concat([existingInfo.buffer, buffer]); - - // Update the buffer and timestamp - this.fragmentedBuffers.set(connectionId, { - ...existingInfo, - buffer: newBuffer, - timestamp: Date.now() - }); - - log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`); - - // Check if we now have a complete ClientHello - try { - if (newBuffer.length >= 5) { - // Get the record length from TLS header - const recordLength = (newBuffer[3] << 8) + newBuffer[4] + 5; // +5 for the TLS record header itself - log( - `Reassembled buffer size: ${newBuffer.length}, expected record length: ${recordLength}` - ); - - // Check if we have a complete TLS record now - if (newBuffer.length >= recordLength) { - log( - `Assembled complete ClientHello, length: ${newBuffer.length}, needed: ${recordLength}` - ); - - // Extract the complete TLS record (might be followed by more data) - const completeRecord = newBuffer.slice(0, recordLength); - - // Check if this record is indeed a ClientHello (type 1) at position 5 - if ( - completeRecord.length > 5 && - completeRecord[5] === TlsHandshakeType.CLIENT_HELLO - ) { - log(`Verified record is a ClientHello handshake message`); - - // Complete message received, remove from tracking - this.fragmentedBuffers.delete(connectionId); - return completeRecord; - } else { - log(`Record is complete but not a ClientHello handshake, continuing to buffer`); - // This might be another TLS record type preceding the ClientHello - - // Try checking for a ClientHello starting at the end of this record - if (newBuffer.length > recordLength + 5) { - const nextRecordType = newBuffer[recordLength]; - log( - `Next record type: ${nextRecordType} (looking for ${TlsRecordType.HANDSHAKE})` - ); - - if (nextRecordType === TlsRecordType.HANDSHAKE) { - const handshakeType = newBuffer[recordLength + 5]; - log( - `Next handshake type: ${handshakeType} (looking for ${TlsHandshakeType.CLIENT_HELLO})` - ); - - if (handshakeType === TlsHandshakeType.CLIENT_HELLO) { - // Found a ClientHello in the next record, return the entire buffer - log(`Found ClientHello in subsequent record, returning full buffer`); - this.fragmentedBuffers.delete(connectionId); - return newBuffer; - } - } - } - } - } - } - } catch (e) { - log(`Error checking reassembled buffer completeness: ${e}`); - } - - return undefined; // Still need more fragments - } - } - - /** - * Parses a TLS ClientHello message and extracts all components - * - * @param buffer The buffer containing the ClientHello message - * @param logger Optional logging function - * @returns Parsed ClientHello or undefined if parsing failed - */ - public static parseClientHello( - buffer: Buffer, - logger?: LoggerFunction - ): ClientHelloParseResult { - const log = logger || (() => {}); - const result: ClientHelloParseResult = { - isValid: false, - hasSessionId: false, - extensions: [], - hasSessionTicket: false, - hasPsk: false, - hasEarlyData: false - }; - - try { - // Check basic validity - if (buffer.length < 5) { - result.error = 'Buffer too small for TLS record header'; - return result; - } - - // Check record type (must be HANDSHAKE) - if (buffer[0] !== TlsRecordType.HANDSHAKE) { - result.error = `Not a TLS handshake record: ${buffer[0]}`; - return result; - } - - // Get TLS version from record header - const majorVersion = buffer[1]; - const minorVersion = buffer[2]; - result.version = [majorVersion, minorVersion]; - log(`TLS record version: ${majorVersion}.${minorVersion}`); - - // Parse record length (bytes 3-4, big-endian) - const recordLength = (buffer[3] << 8) + buffer[4]; - log(`Record length: ${recordLength}`); - - // Validate record length against buffer size - if (buffer.length < recordLength + 5) { - result.error = 'Buffer smaller than expected record length'; - return result; - } - - // Start of handshake message in the buffer - let pos = 5; - - // Check handshake type (must be CLIENT_HELLO) - if (buffer[pos] !== TlsHandshakeType.CLIENT_HELLO) { - result.error = `Not a ClientHello message: ${buffer[pos]}`; - return result; - } - - // Skip handshake type (1 byte) - pos += 1; - - // Parse handshake length (3 bytes, big-endian) - const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2]; - log(`Handshake length: ${handshakeLength}`); - - // Skip handshake length (3 bytes) - pos += 3; - - // Check client version (2 bytes) - const clientMajorVersion = buffer[pos]; - const clientMinorVersion = buffer[pos + 1]; - log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`); - - // Skip client version (2 bytes) - pos += 2; - - // Extract client random (32 bytes) - if (pos + 32 > buffer.length) { - result.error = 'Buffer too small for client random'; - return result; - } - - result.random = buffer.slice(pos, pos + 32); - log(`Client random: ${result.random.toString('hex')}`); - - // Skip client random (32 bytes) - pos += 32; - - // Parse session ID - if (pos + 1 > buffer.length) { - result.error = 'Buffer too small for session ID length'; - return result; - } - - const sessionIdLength = buffer[pos]; - log(`Session ID length: ${sessionIdLength}`); - pos += 1; - - result.hasSessionId = sessionIdLength > 0; - - if (sessionIdLength > 0) { - if (pos + sessionIdLength > buffer.length) { - result.error = 'Buffer too small for session ID'; - return result; - } - - result.sessionId = buffer.slice(pos, pos + sessionIdLength); - log(`Session ID: ${result.sessionId.toString('hex')}`); - } - - // Skip session ID - pos += sessionIdLength; - - // Check if we have enough bytes left for cipher suites - if (pos + 2 > buffer.length) { - result.error = 'Buffer too small for cipher suites length'; - return result; - } - - // Parse cipher suites length (2 bytes, big-endian) - const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; - log(`Cipher suites length: ${cipherSuitesLength}`); - pos += 2; - - // Extract cipher suites - if (pos + cipherSuitesLength > buffer.length) { - result.error = 'Buffer too small for cipher suites'; - return result; - } - - result.cipherSuites = buffer.slice(pos, pos + cipherSuitesLength); - - // Skip cipher suites - pos += cipherSuitesLength; - - // Check if we have enough bytes left for compression methods - if (pos + 1 > buffer.length) { - result.error = 'Buffer too small for compression methods length'; - return result; - } - - // Parse compression methods length (1 byte) - const compressionMethodsLength = buffer[pos]; - log(`Compression methods length: ${compressionMethodsLength}`); - pos += 1; - - // Extract compression methods - if (pos + compressionMethodsLength > buffer.length) { - result.error = 'Buffer too small for compression methods'; - return result; - } - - result.compressionMethods = buffer.slice(pos, pos + compressionMethodsLength); - - // Skip compression methods - pos += compressionMethodsLength; - - // Check if we have enough bytes for extensions length - if (pos + 2 > buffer.length) { - // No extensions present - this is valid for older TLS versions - result.isValid = true; - return result; - } - - // Parse extensions length (2 bytes, big-endian) - const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; - log(`Extensions length: ${extensionsLength}`); - pos += 2; - - // Extensions end position - const extensionsEnd = pos + extensionsLength; - - // Check if extensions length is valid - if (extensionsEnd > buffer.length) { - result.error = 'Extensions length exceeds buffer size'; - return result; - } - - // Iterate through extensions - const serverNames: string[] = []; - - while (pos + 4 <= extensionsEnd) { - // Parse extension type (2 bytes, big-endian) - const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; - log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`); - pos += 2; - - // Parse extension length (2 bytes, big-endian) - const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; - log(`Extension length: ${extensionLength}`); - pos += 2; - - // Extract extension data - if (pos + extensionLength > extensionsEnd) { - result.error = `Extension ${extensionType} data exceeds bounds`; - return result; - } - - const extensionData = buffer.slice(pos, pos + extensionLength); - - // Record all extensions - result.extensions.push({ - type: extensionType, - length: extensionLength, - data: extensionData - }); - - // Track specific extension types - if (extensionType === TlsExtensionType.SERVER_NAME) { - // Server Name Indication (SNI) - this.parseServerNameExtension(extensionData, serverNames, logger); - } else if (extensionType === TlsExtensionType.SESSION_TICKET) { - // Session ticket - result.hasSessionTicket = true; - } else if (extensionType === TlsExtensionType.PRE_SHARED_KEY) { - // TLS 1.3 PSK - result.hasPsk = true; - } else if (extensionType === TlsExtensionType.EARLY_DATA) { - // TLS 1.3 Early Data (0-RTT) - result.hasEarlyData = true; - } - - // Move to next extension - pos += extensionLength; - } - - // Store any server names found - if (serverNames.length > 0) { - result.serverNameList = serverNames; - } - - // Mark as valid if we get here - result.isValid = true; - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log(`Error parsing ClientHello: ${errorMessage}`); - result.error = errorMessage; - return result; - } - } - - /** - * Parses the server name extension data and extracts hostnames - * - * @param data Extension data buffer - * @param serverNames Array to populate with found server names - * @param logger Optional logging function - * @returns true if parsing succeeded - */ - private static parseServerNameExtension( - data: Buffer, - serverNames: string[], - logger?: LoggerFunction - ): boolean { - const log = logger || (() => {}); - - try { - // Need at least 2 bytes for server name list length - if (data.length < 2) { - log('SNI extension too small for server name list length'); - return false; - } - - // Parse server name list length (2 bytes) - const listLength = (data[0] << 8) + data[1]; - - // Skip to first name entry - let pos = 2; - - // End of list - const listEnd = pos + listLength; - - // Validate length - if (listEnd > data.length) { - log('SNI server name list exceeds extension data'); - return false; - } - - // Process all name entries - while (pos + 3 <= listEnd) { - // Name type (1 byte) - const nameType = data[pos]; - pos += 1; - - // For hostname, type must be 0 - if (nameType !== 0) { - // Skip this entry - if (pos + 2 <= listEnd) { - const nameLength = (data[pos] << 8) + data[pos + 1]; - pos += 2 + nameLength; - continue; - } else { - log('Malformed SNI entry'); - return false; - } - } - - // Parse hostname length (2 bytes) - if (pos + 2 > listEnd) { - log('SNI extension truncated'); - return false; - } - - const nameLength = (data[pos] << 8) + data[pos + 1]; - pos += 2; - - // Extract hostname - if (pos + nameLength > listEnd) { - log('SNI hostname truncated'); - return false; - } - - // Extract the hostname as UTF-8 - try { - const hostname = data.slice(pos, pos + nameLength).toString('utf8'); - log(`Found SNI hostname: ${hostname}`); - serverNames.push(hostname); - } catch (err) { - log(`Error extracting hostname: ${err}`); - } - - // Move to next entry - pos += nameLength; - } - - return serverNames.length > 0; - } catch (error) { - log(`Error parsing SNI extension: ${error}`); - return false; - } - } - - /** - * Determines if a ClientHello contains session resumption indicators - * - * @param buffer The ClientHello buffer - * @param logger Optional logging function - * @returns Session resumption result - */ - public static hasSessionResumption( - buffer: Buffer, - logger?: LoggerFunction - ): SessionResumptionResult { - const log = logger || (() => {}); - - if (!TlsUtils.isClientHello(buffer)) { - return { isResumption: false, hasSNI: false }; - } - - const parseResult = this.parseClientHello(buffer, logger); - if (!parseResult.isValid) { - log(`ClientHello parse failed: ${parseResult.error}`); - return { isResumption: false, hasSNI: false }; - } - - // Check resumption indicators - const hasSessionId = parseResult.hasSessionId; - const hasSessionTicket = parseResult.hasSessionTicket; - const hasPsk = parseResult.hasPsk; - const hasEarlyData = parseResult.hasEarlyData; - - // Check for SNI - const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; - - // Consider it a resumption if any resumption mechanism is present - const isResumption = hasSessionTicket || hasPsk || hasEarlyData || - (hasSessionId && !hasPsk); // Legacy resumption - - // Log details - if (isResumption) { - log( - 'Session resumption detected: ' + - (hasSessionTicket ? 'session ticket, ' : '') + - (hasPsk ? 'PSK, ' : '') + - (hasEarlyData ? 'early data, ' : '') + - (hasSessionId ? 'session ID' : '') + - (hasSNI ? ', with SNI' : ', without SNI') - ); - } - - return { isResumption, hasSNI }; - } - - /** - * Checks if a ClientHello appears to be from a tab reactivation - * - * @param buffer The ClientHello buffer - * @param logger Optional logging function - * @returns true if it appears to be a tab reactivation - */ - public static isTabReactivationHandshake( - buffer: Buffer, - logger?: LoggerFunction - ): boolean { - const log = logger || (() => {}); - - if (!TlsUtils.isClientHello(buffer)) { - return false; - } - - // Parse the ClientHello - const parseResult = this.parseClientHello(buffer, logger); - if (!parseResult.isValid) { - return false; - } - - // Tab reactivation pattern: session identifier + (ticket or PSK) but no SNI - const hasSessionId = parseResult.hasSessionId; - const hasSessionTicket = parseResult.hasSessionTicket; - const hasPsk = parseResult.hasPsk; - const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; - - if ((hasSessionId && (hasSessionTicket || hasPsk)) && !hasSNI) { - log('Detected tab reactivation pattern: session resumption without SNI'); - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/ts/protocols/tls/sni/index.ts b/ts/protocols/tls/sni/index.ts deleted file mode 100644 index 896f329..0000000 --- a/ts/protocols/tls/sni/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * TLS SNI (Server Name Indication) protocol utilities - */ - -export * from './client-hello-parser.js'; -export * from './sni-extraction.js'; \ No newline at end of file diff --git a/ts/protocols/tls/sni/sni-extraction.ts b/ts/protocols/tls/sni/sni-extraction.ts deleted file mode 100644 index fb57f81..0000000 --- a/ts/protocols/tls/sni/sni-extraction.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; -import { - ClientHelloParser, - type LoggerFunction -} from './client-hello-parser.js'; - -/** - * Connection tracking information - */ -export interface ConnectionInfo { - sourceIp: string; - sourcePort: number; - destIp: string; - destPort: number; - timestamp?: number; -} - -/** - * Utilities for extracting SNI information from TLS handshakes - */ -export class SniExtraction { - /** - * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. - * - * @param buffer The buffer containing the TLS ClientHello message - * @param logger Optional logging function - * @returns The extracted server name or undefined if not found - */ - public static extractSNI(buffer: Buffer, logger?: LoggerFunction): string | undefined { - const log = logger || (() => {}); - - try { - // Parse the ClientHello - const parseResult = ClientHelloParser.parseClientHello(buffer, logger); - if (!parseResult.isValid) { - log(`Failed to parse ClientHello: ${parseResult.error}`); - return undefined; - } - - // Check if ServerName extension was found - if (parseResult.serverNameList && parseResult.serverNameList.length > 0) { - // Use the first hostname (most common case) - const serverName = parseResult.serverNameList[0]; - log(`Found SNI: ${serverName}`); - return serverName; - } - - log('No SNI extension found in ClientHello'); - return undefined; - } catch (error) { - log(`Error extracting SNI: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - } - - /** - * Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello. - * - * In TLS 1.3, when a client attempts to resume a session, it may include - * the server name in the PSK identity hint rather than in the SNI extension. - * - * @param buffer The buffer containing the TLS ClientHello message - * @param logger Optional logging function - * @returns The extracted server name or undefined if not found - */ - public static extractSNIFromPSKExtension( - buffer: Buffer, - logger?: LoggerFunction - ): string | undefined { - const log = logger || (() => {}); - - try { - // Ensure this is a ClientHello - if (!TlsUtils.isClientHello(buffer)) { - log('Not a ClientHello message'); - return undefined; - } - - // Parse the ClientHello to find PSK extension - const parseResult = ClientHelloParser.parseClientHello(buffer, logger); - if (!parseResult.isValid || !parseResult.extensions) { - return undefined; - } - - // Find the PSK extension - const pskExtension = parseResult.extensions.find(ext => - ext.type === TlsExtensionType.PRE_SHARED_KEY); - - if (!pskExtension) { - log('No PSK extension found'); - return undefined; - } - - // Parse the PSK extension data - const data = pskExtension.data; - - // PSK extension structure: - // 2 bytes: identities list length - if (data.length < 2) return undefined; - - const identitiesLength = (data[0] << 8) + data[1]; - let pos = 2; - - // End of identities list - const identitiesEnd = pos + identitiesLength; - if (identitiesEnd > data.length) return undefined; - - // Process each PSK identity - while (pos + 2 <= identitiesEnd) { - // Identity length (2 bytes) - if (pos + 2 > identitiesEnd) break; - - const identityLength = (data[pos] << 8) + data[pos + 1]; - pos += 2; - - if (pos + identityLength > identitiesEnd) break; - - // Try to extract hostname from identity - // Chrome often embeds the hostname in the PSK identity - // This is a heuristic as there's no standard format - if (identityLength > 0) { - const identity = data.slice(pos, pos + identityLength); - - // Skip identity bytes - pos += identityLength; - - // Skip obfuscated ticket age (4 bytes) - if (pos + 4 <= identitiesEnd) { - pos += 4; - } else { - break; - } - - // Try to parse the identity as UTF-8 - try { - const identityStr = identity.toString('utf8'); - log(`PSK identity: ${identityStr}`); - - // Check if the identity contains hostname hints - // Chrome often embeds the hostname in a known format - // Try to extract using common patterns - - // Pattern 1: Look for domain name pattern - const domainPattern = - /([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i; - const domainMatch = identityStr.match(domainPattern); - if (domainMatch && domainMatch[0]) { - log(`Found domain in PSK identity: ${domainMatch[0]}`); - return domainMatch[0]; - } - - // Pattern 2: Chrome sometimes uses a specific format with delimiters - // This is a heuristic approach since the format isn't standardized - const parts = identityStr.split('|'); - if (parts.length > 1) { - for (const part of parts) { - if (part.includes('.') && !part.includes('/')) { - const possibleDomain = part.trim(); - if (/^[a-z0-9.-]+$/i.test(possibleDomain)) { - log(`Found possible domain in PSK delimiter format: ${possibleDomain}`); - return possibleDomain; - } - } - } - } - } catch (e) { - log('Failed to parse PSK identity as UTF-8'); - } - } - } - - log('No hostname found in PSK extension'); - return undefined; - } catch (error) { - log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - } - - /** - * Main entry point for SNI extraction with support for fragmented messages - * and session resumption edge cases. - * - * @param buffer The buffer containing TLS data - * @param connectionInfo Connection tracking information - * @param logger Optional logging function - * @param cachedSni Optional previously cached SNI value - * @returns The extracted server name or undefined - */ - public static extractSNIWithResumptionSupport( - buffer: Buffer, - connectionInfo?: ConnectionInfo, - logger?: LoggerFunction, - cachedSni?: string - ): string | undefined { - const log = logger || (() => {}); - - // Log buffer details for debugging - if (logger) { - log(`Buffer size: ${buffer.length} bytes`); - log(`Buffer starts with: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`); - - if (buffer.length >= 5) { - const recordType = buffer[0]; - const majorVersion = buffer[1]; - const minorVersion = buffer[2]; - const recordLength = (buffer[3] << 8) + buffer[4]; - - log( - `TLS Record: type=${recordType}, version=${majorVersion}.${minorVersion}, length=${recordLength}` - ); - } - } - - // Check if we need to handle fragmented packets - let processBuffer = buffer; - if (connectionInfo) { - const connectionId = TlsUtils.createConnectionId(connectionInfo); - const reassembledBuffer = ClientHelloParser.handleFragmentedClientHello( - buffer, - connectionId, - logger - ); - - if (!reassembledBuffer) { - log(`Waiting for more fragments on connection ${connectionId}`); - return undefined; // Need more fragments to complete ClientHello - } - - processBuffer = reassembledBuffer; - log(`Using reassembled buffer of length ${processBuffer.length}`); - } - - // First try the standard SNI extraction - const standardSni = this.extractSNI(processBuffer, logger); - if (standardSni) { - log(`Found standard SNI: ${standardSni}`); - return standardSni; - } - - // Check for session resumption when standard SNI extraction fails - if (TlsUtils.isClientHello(processBuffer)) { - const resumptionInfo = ClientHelloParser.hasSessionResumption(processBuffer, logger); - - if (resumptionInfo.isResumption) { - log(`Detected session resumption in ClientHello without standard SNI`); - - // Try to extract SNI from PSK extension - const pskSni = this.extractSNIFromPSKExtension(processBuffer, logger); - if (pskSni) { - log(`Extracted SNI from PSK extension: ${pskSni}`); - return pskSni; - } - } - } - - // If cached SNI was provided, use it for application data packets - if (cachedSni && TlsUtils.isTlsApplicationData(buffer)) { - log(`Using provided cached SNI for application data: ${cachedSni}`); - return cachedSni; - } - - return undefined; - } - - /** - * Unified method for processing a TLS packet and extracting SNI. - * Main entry point for SNI extraction that handles all edge cases. - * - * @param buffer The buffer containing TLS data - * @param connectionInfo Connection tracking information - * @param logger Optional logging function - * @param cachedSni Optional previously cached SNI value - * @returns The extracted server name or undefined - */ - public static processTlsPacket( - buffer: Buffer, - connectionInfo: ConnectionInfo, - logger?: LoggerFunction, - cachedSni?: string - ): string | undefined { - const log = logger || (() => {}); - - // Add timestamp if not provided - if (!connectionInfo.timestamp) { - connectionInfo.timestamp = Date.now(); - } - - // Check if this is a TLS handshake or application data - if (!TlsUtils.isTlsHandshake(buffer) && !TlsUtils.isTlsApplicationData(buffer)) { - log('Not a TLS handshake or application data packet'); - return undefined; - } - - // Create connection ID for tracking - const connectionId = TlsUtils.createConnectionId(connectionInfo); - log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`); - - // Handle application data with cached SNI (for connection racing) - if (TlsUtils.isTlsApplicationData(buffer)) { - // If explicit cachedSni was provided, use it - if (cachedSni) { - log(`Using provided cached SNI for application data: ${cachedSni}`); - return cachedSni; - } - - log('Application data packet without cached SNI, cannot determine hostname'); - return undefined; - } - - // Enhanced session resumption detection - if (TlsUtils.isClientHello(buffer)) { - const resumptionInfo = ClientHelloParser.hasSessionResumption(buffer, logger); - - if (resumptionInfo.isResumption) { - log(`Session resumption detected in TLS packet`); - - // Always try standard SNI extraction first - const standardSni = this.extractSNI(buffer, logger); - if (standardSni) { - log(`Found standard SNI in session resumption: ${standardSni}`); - return standardSni; - } - - // Enhanced session resumption SNI extraction - // Try extracting from PSK identity - const pskSni = this.extractSNIFromPSKExtension(buffer, logger); - if (pskSni) { - log(`Extracted SNI from PSK extension: ${pskSni}`); - return pskSni; - } - - log(`Session resumption without extractable SNI`); - } - } - - // For handshake messages, try the full extraction process - const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, logger); - - if (sni) { - log(`Successfully extracted SNI: ${sni}`); - return sni; - } - - // If we couldn't extract an SNI, check if this is a valid ClientHello - if (TlsUtils.isClientHello(buffer)) { - log('Valid ClientHello detected, but no SNI extracted - might need more data'); - } - - return undefined; - } -} \ No newline at end of file diff --git a/ts/protocols/tls/utils/index.ts b/ts/protocols/tls/utils/index.ts deleted file mode 100644 index 4aa964d..0000000 --- a/ts/protocols/tls/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * TLS utilities - */ diff --git a/ts/protocols/tls/utils/tls-utils.ts b/ts/protocols/tls/utils/tls-utils.ts deleted file mode 100644 index 25c1cb0..0000000 --- a/ts/protocols/tls/utils/tls-utils.ts +++ /dev/null @@ -1,201 +0,0 @@ -import * as plugins from '../../../plugins.js'; - -/** - * TLS record types as defined in various RFCs - */ -export enum TlsRecordType { - CHANGE_CIPHER_SPEC = 20, - ALERT = 21, - HANDSHAKE = 22, - APPLICATION_DATA = 23, - HEARTBEAT = 24, // RFC 6520 -} - -/** - * TLS handshake message types - */ -export enum TlsHandshakeType { - HELLO_REQUEST = 0, - CLIENT_HELLO = 1, - SERVER_HELLO = 2, - NEW_SESSION_TICKET = 4, - ENCRYPTED_EXTENSIONS = 8, // TLS 1.3 - CERTIFICATE = 11, - SERVER_KEY_EXCHANGE = 12, - CERTIFICATE_REQUEST = 13, - SERVER_HELLO_DONE = 14, - CERTIFICATE_VERIFY = 15, - CLIENT_KEY_EXCHANGE = 16, - FINISHED = 20, -} - -/** - * TLS extension types - */ -export enum TlsExtensionType { - SERVER_NAME = 0, // SNI - MAX_FRAGMENT_LENGTH = 1, - CLIENT_CERTIFICATE_URL = 2, - TRUSTED_CA_KEYS = 3, - TRUNCATED_HMAC = 4, - STATUS_REQUEST = 5, // OCSP - SUPPORTED_GROUPS = 10, // Previously named "elliptic_curves" - EC_POINT_FORMATS = 11, - SIGNATURE_ALGORITHMS = 13, - APPLICATION_LAYER_PROTOCOL_NEGOTIATION = 16, // ALPN - SIGNED_CERTIFICATE_TIMESTAMP = 18, // Certificate Transparency - PADDING = 21, - SESSION_TICKET = 35, - PRE_SHARED_KEY = 41, // TLS 1.3 - EARLY_DATA = 42, // TLS 1.3 0-RTT - SUPPORTED_VERSIONS = 43, // TLS 1.3 - COOKIE = 44, // TLS 1.3 - PSK_KEY_EXCHANGE_MODES = 45, // TLS 1.3 - CERTIFICATE_AUTHORITIES = 47, // TLS 1.3 - POST_HANDSHAKE_AUTH = 49, // TLS 1.3 - SIGNATURE_ALGORITHMS_CERT = 50, // TLS 1.3 - KEY_SHARE = 51, // TLS 1.3 -} - -/** - * TLS alert levels - */ -export enum TlsAlertLevel { - WARNING = 1, - FATAL = 2, -} - -/** - * TLS alert description codes - */ -export enum TlsAlertDescription { - CLOSE_NOTIFY = 0, - UNEXPECTED_MESSAGE = 10, - BAD_RECORD_MAC = 20, - DECRYPTION_FAILED = 21, // TLS 1.0 only - RECORD_OVERFLOW = 22, - DECOMPRESSION_FAILURE = 30, // TLS 1.2 and below - HANDSHAKE_FAILURE = 40, - NO_CERTIFICATE = 41, // SSLv3 only - BAD_CERTIFICATE = 42, - UNSUPPORTED_CERTIFICATE = 43, - CERTIFICATE_REVOKED = 44, - CERTIFICATE_EXPIRED = 45, - CERTIFICATE_UNKNOWN = 46, - ILLEGAL_PARAMETER = 47, - UNKNOWN_CA = 48, - ACCESS_DENIED = 49, - DECODE_ERROR = 50, - DECRYPT_ERROR = 51, - EXPORT_RESTRICTION = 60, // TLS 1.0 only - PROTOCOL_VERSION = 70, - INSUFFICIENT_SECURITY = 71, - INTERNAL_ERROR = 80, - INAPPROPRIATE_FALLBACK = 86, - USER_CANCELED = 90, - NO_RENEGOTIATION = 100, // TLS 1.2 and below - MISSING_EXTENSION = 109, // TLS 1.3 - UNSUPPORTED_EXTENSION = 110, // TLS 1.3 - CERTIFICATE_REQUIRED = 111, // TLS 1.3 - UNRECOGNIZED_NAME = 112, - BAD_CERTIFICATE_STATUS_RESPONSE = 113, - BAD_CERTIFICATE_HASH_VALUE = 114, // TLS 1.2 and below - UNKNOWN_PSK_IDENTITY = 115, - CERTIFICATE_REQUIRED_1_3 = 116, // TLS 1.3 - NO_APPLICATION_PROTOCOL = 120, -} - -/** - * TLS version codes (major.minor) - */ -export const TlsVersion = { - SSL3: [0x03, 0x00], - TLS1_0: [0x03, 0x01], - TLS1_1: [0x03, 0x02], - TLS1_2: [0x03, 0x03], - TLS1_3: [0x03, 0x04], -}; - -/** - * Utility functions for TLS protocol operations - */ -export class TlsUtils { - /** - * Checks if a buffer contains a TLS handshake record - * @param buffer The buffer to check - * @returns true if the buffer starts with a TLS handshake record - */ - public static isTlsHandshake(buffer: Buffer): boolean { - return buffer.length > 0 && buffer[0] === TlsRecordType.HANDSHAKE; - } - - /** - * Checks if a buffer contains TLS application data - * @param buffer The buffer to check - * @returns true if the buffer starts with a TLS application data record - */ - public static isTlsApplicationData(buffer: Buffer): boolean { - return buffer.length > 0 && buffer[0] === TlsRecordType.APPLICATION_DATA; - } - - /** - * Checks if a buffer contains a TLS alert record - * @param buffer The buffer to check - * @returns true if the buffer starts with a TLS alert record - */ - public static isTlsAlert(buffer: Buffer): boolean { - return buffer.length > 0 && buffer[0] === TlsRecordType.ALERT; - } - - /** - * Checks if a buffer contains a TLS ClientHello message - * @param buffer The buffer to check - * @returns true if the buffer appears to be a ClientHello message - */ - public static isClientHello(buffer: Buffer): boolean { - // Minimum ClientHello size (TLS record header + handshake header) - if (buffer.length < 9) { - return false; - } - - // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE) - if (buffer[0] !== TlsRecordType.HANDSHAKE) { - return false; - } - - // Skip version and length in TLS record header (5 bytes total) - // Check handshake type at byte 5 (must be CLIENT_HELLO) - return buffer[5] === TlsHandshakeType.CLIENT_HELLO; - } - - /** - * Gets the record length from a TLS record header - * @param buffer Buffer containing a TLS record - * @returns The record length if the buffer is valid, -1 otherwise - */ - public static getTlsRecordLength(buffer: Buffer): number { - if (buffer.length < 5) { - return -1; - } - - // Bytes 3-4 contain the record length (big-endian) - return (buffer[3] << 8) + buffer[4]; - } - - /** - * Creates a connection ID based on source/destination information - * Used to track fragmented ClientHello messages across multiple packets - * - * @param connectionInfo Object containing connection identifiers - * @returns A string ID for the connection - */ - public static createConnectionId(connectionInfo: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - }): string { - const { sourceIp, sourcePort, destIp, destPort } = connectionInfo; - return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; - } -} \ No newline at end of file diff --git a/ts/protocols/websocket/constants.ts b/ts/protocols/websocket/constants.ts deleted file mode 100644 index bd30728..0000000 --- a/ts/protocols/websocket/constants.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * WebSocket Protocol Constants - * Based on RFC 6455 - */ - -/** - * WebSocket opcode types - */ -export enum WebSocketOpcode { - CONTINUATION = 0x0, - TEXT = 0x1, - BINARY = 0x2, - CLOSE = 0x8, - PING = 0x9, - PONG = 0xa, -} - -/** - * WebSocket close codes - */ -export enum WebSocketCloseCode { - NORMAL_CLOSURE = 1000, - GOING_AWAY = 1001, - PROTOCOL_ERROR = 1002, - UNSUPPORTED_DATA = 1003, - NO_STATUS_RECEIVED = 1005, - ABNORMAL_CLOSURE = 1006, - INVALID_FRAME_PAYLOAD_DATA = 1007, - POLICY_VIOLATION = 1008, - MESSAGE_TOO_BIG = 1009, - MISSING_EXTENSION = 1010, - INTERNAL_ERROR = 1011, - SERVICE_RESTART = 1012, - TRY_AGAIN_LATER = 1013, - BAD_GATEWAY = 1014, - TLS_HANDSHAKE = 1015, -} - -/** - * WebSocket protocol version - */ -export const WEBSOCKET_VERSION = 13; - -/** - * WebSocket magic string for handshake - */ -export const WEBSOCKET_MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; - -/** - * WebSocket headers - */ -export const WEBSOCKET_HEADERS = { - UPGRADE: 'upgrade', - CONNECTION: 'connection', - SEC_WEBSOCKET_KEY: 'sec-websocket-key', - SEC_WEBSOCKET_VERSION: 'sec-websocket-version', - SEC_WEBSOCKET_ACCEPT: 'sec-websocket-accept', - SEC_WEBSOCKET_PROTOCOL: 'sec-websocket-protocol', - SEC_WEBSOCKET_EXTENSIONS: 'sec-websocket-extensions', -} as const; \ No newline at end of file diff --git a/ts/protocols/websocket/index.ts b/ts/protocols/websocket/index.ts deleted file mode 100644 index bc28921..0000000 --- a/ts/protocols/websocket/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * WebSocket Protocol Module - * WebSocket protocol utilities and constants - */ - -export * from './constants.js'; -export * from './types.js'; -export * from './utils.js'; \ No newline at end of file diff --git a/ts/protocols/websocket/types.ts b/ts/protocols/websocket/types.ts deleted file mode 100644 index 36336b3..0000000 --- a/ts/protocols/websocket/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * WebSocket Protocol Type Definitions - */ - -import type { WebSocketOpcode, WebSocketCloseCode } from './constants.js'; - -/** - * WebSocket frame header - */ -export interface IWebSocketFrameHeader { - fin: boolean; - rsv1: boolean; - rsv2: boolean; - rsv3: boolean; - opcode: WebSocketOpcode; - masked: boolean; - payloadLength: number; - maskingKey?: Buffer; -} - -/** - * WebSocket frame - */ -export interface IWebSocketFrame { - header: IWebSocketFrameHeader; - payload: Buffer; -} - -/** - * WebSocket close frame payload - */ -export interface IWebSocketClosePayload { - code: WebSocketCloseCode; - reason?: string; -} - -/** - * WebSocket handshake request headers - */ -export interface IWebSocketHandshakeHeaders { - upgrade: string; - connection: string; - 'sec-websocket-key': string; - 'sec-websocket-version': string; - 'sec-websocket-protocol'?: string; - 'sec-websocket-extensions'?: string; - [key: string]: string | undefined; -} - -/** - * Type for WebSocket raw data (matching ws library) - */ -export type RawData = Buffer | ArrayBuffer | Buffer[] | any; \ No newline at end of file diff --git a/ts/protocols/websocket/utils.ts b/ts/protocols/websocket/utils.ts deleted file mode 100644 index 38318d7..0000000 --- a/ts/protocols/websocket/utils.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * WebSocket Protocol Utilities - */ - -import * as crypto from 'node:crypto'; -import { WEBSOCKET_MAGIC_STRING } from './constants.js'; -import type { RawData } from './types.js'; - -/** - * Get the length of a WebSocket message regardless of its type - * (handles all possible WebSocket message data types) - */ -export function getMessageSize(data: RawData): number { - if (typeof data === 'string') { - // For string data, get the byte length - return Buffer.from(data, 'utf8').length; - } else if (data instanceof Buffer) { - // For Node.js Buffer - return data.length; - } else if (data instanceof ArrayBuffer) { - // For ArrayBuffer - return data.byteLength; - } else if (Array.isArray(data)) { - // For array of buffers, sum their lengths - return data.reduce((sum, chunk) => { - if (chunk instanceof Buffer) { - return sum + chunk.length; - } else if (chunk instanceof ArrayBuffer) { - return sum + chunk.byteLength; - } - return sum; - }, 0); - } else { - // For other types, try to determine the size or return 0 - try { - return Buffer.from(data).length; - } catch (e) { - return 0; - } - } -} - -/** - * Convert any raw WebSocket data to Buffer for consistent handling - */ -export function toBuffer(data: RawData): Buffer { - if (typeof data === 'string') { - return Buffer.from(data, 'utf8'); - } else if (data instanceof Buffer) { - return data; - } else if (data instanceof ArrayBuffer) { - return Buffer.from(data); - } else if (Array.isArray(data)) { - // For array of buffers, concatenate them - return Buffer.concat(data.map(chunk => { - if (chunk instanceof Buffer) { - return chunk; - } else if (chunk instanceof ArrayBuffer) { - return Buffer.from(chunk); - } - return Buffer.from(chunk); - })); - } else { - // For other types, try to convert to Buffer or return empty Buffer - try { - return Buffer.from(data); - } catch (e) { - return Buffer.alloc(0); - } - } -} - -/** - * Generate WebSocket accept key from client key - */ -export function generateAcceptKey(clientKey: string): string { - const hash = crypto.createHash('sha1'); - hash.update(clientKey + WEBSOCKET_MAGIC_STRING); - return hash.digest('base64'); -} - -/** - * Validate WebSocket upgrade request - */ -export function isWebSocketUpgrade(headers: Record): boolean { - const upgrade = headers['upgrade']; - const connection = headers['connection']; - - return upgrade?.toLowerCase() === 'websocket' && - connection?.toLowerCase().includes('upgrade'); -} - -/** - * Generate random WebSocket key for client handshake - */ -export function generateWebSocketKey(): string { - return crypto.randomBytes(16).toString('base64'); -} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/socket-handler-server.ts b/ts/proxies/smart-proxy/socket-handler-server.ts index dc3378a..3a46560 100644 --- a/ts/proxies/smart-proxy/socket-handler-server.ts +++ b/ts/proxies/smart-proxy/socket-handler-server.ts @@ -274,6 +274,12 @@ export class SocketHandlerServer { backend.pipe(socket); }); + // Track backend socket for cleanup on stop() + this.activeSockets.add(backend); + backend.on('close', () => { + this.activeSockets.delete(backend); + }); + // Connect timeout: if backend doesn't connect within 30s, destroy both backend.setTimeout(30_000); diff --git a/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts b/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts index b9be549..ba5885c 100644 --- a/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts +++ b/ts/proxies/smart-proxy/utils/route-helpers/socket-handlers.ts @@ -7,9 +7,54 @@ import * as plugins from '../../../../plugins.js'; import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js'; -import { ProtocolDetector } from '../../../../detection/index.js'; import { createSocketTracker } from '../../../../core/utils/socket-tracker.js'; +/** + * Minimal HTTP request parser for socket handlers. + * Parses method, path, and optionally headers from a raw buffer. + */ +function parseHttpRequest(data: Buffer, extractHeaders: boolean = false): { + method: string; + path: string; + headers: Record; + isComplete: boolean; + body?: string; +} | null { + const str = data.toString('utf8'); + const headerEnd = str.indexOf('\r\n\r\n'); + const isComplete = headerEnd !== -1; + const headerSection = isComplete ? str.slice(0, headerEnd) : str; + const lines = headerSection.split('\r\n'); + const requestLine = lines[0]; + if (!requestLine) return null; + + const parts = requestLine.split(' '); + if (parts.length < 2) return null; + + const method = parts[0]; + const path = parts[1]; + + // Quick check: valid HTTP method + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE']; + if (!validMethods.includes(method)) return null; + + const headers: Record = {}; + if (extractHeaders) { + for (let i = 1; i < lines.length; i++) { + const colonIdx = lines[i].indexOf(':'); + if (colonIdx > 0) { + const name = lines[i].slice(0, colonIdx).trim().toLowerCase(); + const value = lines[i].slice(colonIdx + 1).trim(); + headers[name] = value; + } + } + } + + const body = isComplete ? str.slice(headerEnd + 4) : undefined; + + return { method, path, headers, isComplete, body }; +} + /** * Pre-built socket handlers for common use cases */ @@ -104,30 +149,19 @@ export const SocketHandlers = { /** * HTTP redirect handler - * Uses the centralized detection module for HTTP parsing */ httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => { const tracker = createSocketTracker(socket); - const connectionId = ProtocolDetector.createConnectionId({ - socketId: context.connectionId || `${Date.now()}-${Math.random()}` - }); - const handleData = async (data: Buffer) => { - // Use detection module for parsing - const detectionResult = await ProtocolDetector.detectWithConnectionTracking( - data, - connectionId, - { extractFullHeaders: false } // We only need method and path - ); - - if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) { - const method = detectionResult.connectionInfo.method || 'GET'; - const path = detectionResult.connectionInfo.path || '/'; + const handleData = (data: Buffer) => { + const parsed = parseHttpRequest(data); + if (parsed) { + const path = parsed.path || '/'; const domain = context.domain || 'localhost'; const port = context.port; - let finalLocation = locationTemplate + const finalLocation = locationTemplate .replace('{domain}', domain) .replace('{port}', String(port)) .replace('{path}', path) @@ -146,18 +180,13 @@ export const SocketHandlers = { socket.write(response); } else { - // Not a valid HTTP request, close connection socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); } socket.end(); - // Clean up detection state - ProtocolDetector.cleanupConnections(); - // Clean up all tracked resources tracker.cleanup(); }; - // Use tracker to manage the listener socket.once('data', handleData); tracker.addListener('error', (err) => { @@ -171,45 +200,31 @@ export const SocketHandlers = { /** * HTTP server handler for ACME challenges and other HTTP needs - * Uses the centralized detection module for HTTP parsing */ httpServer: (handler: (req: { method: string; url: string; headers: Record; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => { const tracker = createSocketTracker(socket); let requestParsed = false; let responseTimer: NodeJS.Timeout | null = null; - const connectionId = ProtocolDetector.createConnectionId({ - socketId: context.connectionId || `${Date.now()}-${Math.random()}` - }); - const processData = async (data: Buffer) => { - if (requestParsed) return; // Only handle the first request + const processData = (data: Buffer) => { + if (requestParsed) return; - // Use HttpDetector for parsing - const detectionResult = await ProtocolDetector.detectWithConnectionTracking( - data, - connectionId, - { extractFullHeaders: true } - ); + const parsed = parseHttpRequest(data, true); - if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) { - // Not a complete HTTP request yet - return; + if (!parsed || !parsed.isComplete) { + return; // Not a complete HTTP request yet } requestParsed = true; - // Remove data listener after parsing request socket.removeListener('data', processData); - const connInfo = detectionResult.connectionInfo; - // Create request object from detection result const req = { - method: connInfo.method || 'GET', - url: connInfo.path || '/', - headers: connInfo.headers || {}, - body: detectionResult.remainingBuffer?.toString() || '' + method: parsed.method, + url: parsed.path, + headers: parsed.headers, + body: parsed.body || '' }; - // Create response object let statusCode = 200; const responseHeaders: Record = {}; let ended = false; @@ -225,7 +240,6 @@ export const SocketHandlers = { if (ended) return; ended = true; - // Clear response timer since we're sending now if (responseTimer) { clearTimeout(responseTimer); responseTimer = null; @@ -261,26 +275,22 @@ export const SocketHandlers = { try { handler(req, res); - // Ensure response is sent even if handler doesn't call send() responseTimer = setTimeout(() => { if (!ended) { res.send(''); } responseTimer = null; }, 1000); - // Track and unref the timer tracker.addTimer(responseTimer); } catch (error) { if (!ended) { res.status(500); res.send('Internal Server Error'); } - // Use safeDestroy for error cases tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error')); } }; - // Use tracker to manage listeners tracker.addListener('data', processData); tracker.addListener('error', (err) => { @@ -290,14 +300,10 @@ export const SocketHandlers = { }); tracker.addListener('close', () => { - // Clear any pending response timer if (responseTimer) { clearTimeout(responseTimer); responseTimer = null; } - // Clean up detection state - ProtocolDetector.cleanupConnections(); - // Clean up all tracked resources tracker.cleanup(); }); } @@ -305,11 +311,6 @@ export const SocketHandlers = { /** * Create a socket handler route configuration - * @param domains Domain(s) to match - * @param ports Port(s) to listen on - * @param handler Socket handler function - * @param options Additional route options - * @returns Route configuration object */ export function createSocketHandlerRoute( domains: string | string[], diff --git a/ts/routing/index.ts b/ts/routing/index.ts index 879ad65..3309793 100644 --- a/ts/routing/index.ts +++ b/ts/routing/index.ts @@ -4,6 +4,3 @@ // Export types and models export * from './models/http-types.js'; - -// Export router functionality -export * from './router/index.js'; diff --git a/ts/routing/router/http-router.ts b/ts/routing/router/http-router.ts deleted file mode 100644 index 0d2ea18..0000000 --- a/ts/routing/router/http-router.ts +++ /dev/null @@ -1,266 +0,0 @@ -import * as plugins from '../../plugins.js'; -import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; -import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js'; - -/** - * Interface for router result with additional metadata - */ -export interface RouterResult { - route: IRouteConfig; - pathMatch?: string; - pathParams?: Record; - pathRemainder?: string; -} - - -/** - * Logger interface for HttpRouter - */ -export interface ILogger { - debug?: (message: string, data?: any) => void; - info: (message: string, data?: any) => void; - warn: (message: string, data?: any) => void; - error: (message: string, data?: any) => void; -} - -/** - * Unified HTTP Router for reverse proxy requests - * - * Domain matching patterns: - * - Exact matches: "example.com" - * - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com) - * - TLD wildcards: "example.*" (matches example.com, example.org, etc.) - * - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain) - * - Default fallback: "*" (matches any unmatched domain) - * - * Path pattern matching: - * - Exact path: "/api/users" - * - Wildcard paths: "/api/*" - * - Path parameters: "/users/:id/profile" - */ -export class HttpRouter { - // Store routes sorted by priority - private routes: IRouteConfig[] = []; - // Default route to use when no match is found (optional) - private defaultRoute?: IRouteConfig; - // Logger interface - private logger: ILogger; - - constructor( - routes?: IRouteConfig[], - logger?: ILogger - ) { - this.logger = logger || { - error: console.error.bind(console), - warn: console.warn.bind(console), - info: console.info.bind(console), - debug: console.debug?.bind(console) - }; - - if (routes) { - this.setRoutes(routes); - } - } - - /** - * Sets a new set of routes - * @param routes Array of route configurations - */ - public setRoutes(routes: IRouteConfig[]): void { - this.routes = [...routes]; - - // Sort routes by priority (higher priority first) - this.routes.sort((a, b) => { - const priorityA = a.priority ?? 0; - const priorityB = b.priority ?? 0; - return priorityB - priorityA; - }); - - // Find default route if any (route with "*" as domain) - this.defaultRoute = this.routes.find(route => { - const domains = Array.isArray(route.match.domains) - ? route.match.domains - : route.match.domains ? [route.match.domains] : []; - return domains.includes('*'); - }); - - const uniqueDomains = this.getHostnames(); - this.logger.info(`HttpRouter initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`); - } - - /** - * Routes a request based on hostname and path - * @param req The incoming HTTP request - * @returns The matching route or undefined if no match found - */ - public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined { - const result = this.routeReqWithDetails(req); - return result ? result.route : undefined; - } - - /** - * Routes a request with detailed matching information - * @param req The incoming HTTP request - * @returns Detailed routing result including matched route and path information - */ - public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined { - // Extract and validate host header - const originalHost = req.headers.host; - if (!originalHost) { - this.logger.error('No host header found in request'); - return this.defaultRoute ? { route: this.defaultRoute } : undefined; - } - - // Parse URL for path matching - const parsedUrl = plugins.url.parse(req.url || '/'); - const urlPath = parsedUrl.pathname || '/'; - - // Extract hostname without port - const hostWithoutPort = originalHost.split(':')[0].toLowerCase(); - - // Find matching route - const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath); - - if (matchingRoute) { - return matchingRoute; - } - - // Fall back to default route if available - if (this.defaultRoute) { - this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`); - return { route: this.defaultRoute }; - } - - this.logger.error(`No route found for host: ${hostWithoutPort}`); - return undefined; - } - - /** - * Find the best matching route for a given hostname and path - */ - private findMatchingRoute(hostname: string, path: string): RouterResult | undefined { - // Try each route in priority order - for (const route of this.routes) { - // Skip disabled routes - if (route.enabled === false) { - continue; - } - - // Check domain match - if (route.match.domains) { - const domains = Array.isArray(route.match.domains) - ? route.match.domains - : [route.match.domains]; - - // Check if any domain pattern matches - const domainMatches = domains.some(domain => - DomainMatcher.match(domain, hostname) - ); - - if (!domainMatches) { - continue; - } - } - - // Check path match if specified - if (route.match.path) { - const pathResult = PathMatcher.match(route.match.path, path); - if (pathResult.matches) { - return { - route, - pathMatch: pathResult.pathMatch || path, - pathParams: pathResult.params, - pathRemainder: pathResult.pathRemainder - }; - } - } else { - // No path specified, so domain match is sufficient - return { route }; - } - } - - return undefined; - } - - /** - * Gets all currently active route configurations - * @returns Array of all active routes - */ - public getRoutes(): IRouteConfig[] { - return [...this.routes]; - } - - /** - * Gets all hostnames that this router is configured to handle - * @returns Array of unique hostnames - */ - public getHostnames(): string[] { - const hostnames = new Set(); - for (const route of this.routes) { - if (!route.match.domains) continue; - - const domains = Array.isArray(route.match.domains) - ? route.match.domains - : [route.match.domains]; - - for (const domain of domains) { - if (domain !== '*') { - hostnames.add(domain.toLowerCase()); - } - } - } - return Array.from(hostnames); - } - - /** - * Adds a single new route configuration - * @param route The route configuration to add - */ - public addRoute(route: IRouteConfig): void { - this.routes.push(route); - - // Re-sort routes by priority - this.routes.sort((a, b) => { - const priorityA = a.priority ?? 0; - const priorityB = b.priority ?? 0; - return priorityB - priorityA; - }); - } - - /** - * Removes routes by domain pattern - * @param domain The domain pattern to remove routes for - * @returns Boolean indicating whether any routes were removed - */ - public removeRoutesByDomain(domain: string): boolean { - const initialCount = this.routes.length; - - // Filter out routes that match the domain - this.routes = this.routes.filter(route => { - if (!route.match.domains) return true; - - const domains = Array.isArray(route.match.domains) - ? route.match.domains - : [route.match.domains]; - - return !domains.includes(domain); - }); - - return this.routes.length !== initialCount; - } - - /** - * Remove a specific route by reference - * @param route The route to remove - * @returns Boolean indicating if the route was found and removed - */ - public removeRoute(route: IRouteConfig): boolean { - const index = this.routes.indexOf(route); - if (index !== -1) { - this.routes.splice(index, 1); - return true; - } - return false; - } - -} \ No newline at end of file diff --git a/ts/routing/router/index.ts b/ts/routing/router/index.ts deleted file mode 100644 index fca6ebf..0000000 --- a/ts/routing/router/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * HTTP routing - */ - -// Export the unified HttpRouter -export { HttpRouter } from './http-router.js'; -export type { RouterResult, ILogger } from './http-router.js'; diff --git a/ts/tls/index.ts b/ts/tls/index.ts deleted file mode 100644 index 1eb8e9b..0000000 --- a/ts/tls/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * TLS module for smartproxy - * Re-exports protocol components and provides smartproxy-specific functionality - */ - -// Re-export all protocol components from protocols/tls -export * from '../protocols/tls/index.js'; - -// Export smartproxy-specific SNI handler -export * from './sni/sni-handler.js'; - -// Create a namespace for SNI utilities -import { SniHandler } from './sni/sni-handler.js'; -import { SniExtraction } from '../protocols/tls/sni/sni-extraction.js'; -import { ClientHelloParser } from '../protocols/tls/sni/client-hello-parser.js'; - -// Export utility objects for convenience -export const SNI = { - // Main handler class (for backward compatibility) - Handler: SniHandler, - - // Utility classes - Extraction: SniExtraction, - Parser: ClientHelloParser, - - // Convenience functions - extractSNI: SniHandler.extractSNI, - processTlsPacket: SniHandler.processTlsPacket, -}; \ No newline at end of file diff --git a/ts/tls/sni/index.ts b/ts/tls/sni/index.ts deleted file mode 100644 index e36f05b..0000000 --- a/ts/tls/sni/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * SNI handling - */ diff --git a/ts/tls/sni/sni-handler.ts b/ts/tls/sni/sni-handler.ts deleted file mode 100644 index 89d76e9..0000000 --- a/ts/tls/sni/sni-handler.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { - TlsRecordType, - TlsHandshakeType, - TlsExtensionType, - TlsUtils -} from '../../protocols/tls/utils/tls-utils.js'; -import { - ClientHelloParser, - type LoggerFunction -} from '../../protocols/tls/sni/client-hello-parser.js'; -import { - SniExtraction, - type ConnectionInfo -} from '../../protocols/tls/sni/sni-extraction.js'; - -/** - * SNI (Server Name Indication) handler for TLS connections. - * Provides robust extraction of SNI values from TLS ClientHello messages - * with support for fragmented packets, TLS 1.3 resumption, Chrome-specific - * connection behaviors, and tab hibernation/reactivation scenarios. - * - * This class retains the original API but leverages the new modular implementation - * for better maintainability and testability. - */ -export class SniHandler { - // Re-export constants for backward compatibility - private static readonly TLS_HANDSHAKE_RECORD_TYPE = TlsRecordType.HANDSHAKE; - private static readonly TLS_APPLICATION_DATA_TYPE = TlsRecordType.APPLICATION_DATA; - private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = TlsHandshakeType.CLIENT_HELLO; - private static readonly TLS_SNI_EXTENSION_TYPE = TlsExtensionType.SERVER_NAME; - private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = TlsExtensionType.SESSION_TICKET; - private static readonly TLS_SNI_HOST_NAME_TYPE = 0; // NameType.HOST_NAME in RFC 6066 - private static readonly TLS_PSK_EXTENSION_TYPE = TlsExtensionType.PRE_SHARED_KEY; - private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = TlsExtensionType.PSK_KEY_EXCHANGE_MODES; - private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = TlsExtensionType.EARLY_DATA; - - /** - * Checks if a buffer contains a TLS handshake message (record type 22) - * @param buffer - The buffer to check - * @returns true if the buffer starts with a TLS handshake record type - */ - public static isTlsHandshake(buffer: Buffer): boolean { - return TlsUtils.isTlsHandshake(buffer); - } - - /** - * Checks if a buffer contains TLS application data (record type 23) - * @param buffer - The buffer to check - * @returns true if the buffer starts with a TLS application data record type - */ - public static isTlsApplicationData(buffer: Buffer): boolean { - return TlsUtils.isTlsApplicationData(buffer); - } - - /** - * Creates a connection ID based on source/destination information - * Used to track fragmented ClientHello messages across multiple packets - * - * @param connectionInfo - Object containing connection identifiers (IP/port) - * @returns A string ID for the connection - */ - public static createConnectionId(connectionInfo: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - }): string { - return TlsUtils.createConnectionId(connectionInfo); - } - - /** - * Handles potential fragmented ClientHello messages by buffering and reassembling - * TLS record fragments that might span multiple TCP packets. - * - * @param buffer - The current buffer fragment - * @param connectionId - Unique identifier for the connection - * @param enableLogging - Whether to enable logging - * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed - */ - public static handleFragmentedClientHello( - buffer: Buffer, - connectionId: string, - enableLogging: boolean = false - ): Buffer | undefined { - const logger = enableLogging ? - (message: string) => console.log(`[SNI Fragment] ${message}`) : - undefined; - - return ClientHelloParser.handleFragmentedClientHello(buffer, connectionId, logger); - } - - /** - * Checks if a buffer contains a TLS ClientHello message - * @param buffer - The buffer to check - * @returns true if the buffer appears to be a ClientHello message - */ - public static isClientHello(buffer: Buffer): boolean { - return TlsUtils.isClientHello(buffer); - } - - /** - * Checks if a ClientHello message contains session resumption indicators - * such as session tickets or PSK (Pre-Shared Key) extensions. - * - * @param buffer - The buffer containing a ClientHello message - * @param enableLogging - Whether to enable logging - * @returns Object containing details about session resumption and SNI presence - */ - public static hasSessionResumption( - buffer: Buffer, - enableLogging: boolean = false - ): { isResumption: boolean; hasSNI: boolean } { - const logger = enableLogging ? - (message: string) => console.log(`[Session Resumption] ${message}`) : - undefined; - - return ClientHelloParser.hasSessionResumption(buffer, logger); - } - - /** - * Detects characteristics of a tab reactivation TLS handshake - * These often have specific patterns in Chrome and other browsers - * - * @param buffer - The buffer containing a ClientHello message - * @param enableLogging - Whether to enable logging - * @returns true if this appears to be a tab reactivation handshake - */ - public static isTabReactivationHandshake( - buffer: Buffer, - enableLogging: boolean = false - ): boolean { - const logger = enableLogging ? - (message: string) => console.log(`[Tab Reactivation] ${message}`) : - undefined; - - return ClientHelloParser.isTabReactivationHandshake(buffer, logger); - } - - /** - * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. - * Implements robust parsing with support for session resumption edge cases. - * - * @param buffer - The buffer containing the TLS ClientHello message - * @param enableLogging - Whether to enable detailed debug logging - * @returns The extracted server name or undefined if not found - */ - public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { - const logger = enableLogging ? - (message: string) => console.log(`[SNI Extraction] ${message}`) : - undefined; - - return SniExtraction.extractSNI(buffer, logger); - } - - /** - * Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello. - * - * In TLS 1.3, when a client attempts to resume a session, it may include - * the server name in the PSK identity hint rather than in the SNI extension. - * - * @param buffer - The buffer containing the TLS ClientHello message - * @param enableLogging - Whether to enable detailed debug logging - * @returns The extracted server name or undefined if not found - */ - public static extractSNIFromPSKExtension( - buffer: Buffer, - enableLogging: boolean = false - ): string | undefined { - const logger = enableLogging ? - (message: string) => console.log(`[PSK-SNI Extraction] ${message}`) : - undefined; - - return SniExtraction.extractSNIFromPSKExtension(buffer, logger); - } - - /** - * Checks if the buffer contains TLS 1.3 early data (0-RTT) - * @param buffer - The buffer to check - * @param enableLogging - Whether to enable logging - * @returns true if early data is detected - */ - public static hasEarlyData(buffer: Buffer, enableLogging: boolean = false): boolean { - // This functionality has been moved to ClientHelloParser - // We can implement it in terms of the parse result if needed - const logger = enableLogging ? - (message: string) => console.log(`[Early Data] ${message}`) : - undefined; - - const parseResult = ClientHelloParser.parseClientHello(buffer, logger); - return parseResult.isValid && parseResult.hasEarlyData; - } - - /** - * Attempts to extract SNI from an initial ClientHello packet and handles - * session resumption edge cases more robustly than the standard extraction. - * - * This method handles: - * 1. Standard SNI extraction - * 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.) - * 3. Session ticket-based resumption - * 4. Fragmented ClientHello messages - * 5. TLS 1.3 Early Data (0-RTT) - * 6. Chrome's connection racing behaviors - * - * @param buffer - The buffer containing the TLS ClientHello message - * @param connectionInfo - Optional connection information for fragment handling - * @param enableLogging - Whether to enable detailed debug logging - * @returns The extracted server name or undefined if not found or more data needed - */ - public static extractSNIWithResumptionSupport( - buffer: Buffer, - connectionInfo?: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; - }, - enableLogging: boolean = false - ): string | undefined { - const logger = enableLogging ? - (message: string) => console.log(`[SNI Extraction] ${message}`) : - undefined; - - return SniExtraction.extractSNIWithResumptionSupport( - buffer, - connectionInfo as ConnectionInfo, - logger - ); - } - - /** - * Main entry point for SNI extraction that handles all edge cases. - * This should be called for each TLS packet received from a client. - * - * The method uses connection tracking to handle fragmented ClientHello - * messages and various TLS 1.3 behaviors, including Chrome's connection - * racing patterns and tab reactivation behaviors. - * - * @param buffer - The buffer containing TLS data - * @param connectionInfo - Connection metadata (IPs and ports) - * @param enableLogging - Whether to enable detailed debug logging - * @param cachedSni - Optional cached SNI from previous connections (for racing detection) - * @returns The extracted server name or undefined if not found or more data needed - */ - public static processTlsPacket( - buffer: Buffer, - connectionInfo: { - sourceIp: string; - sourcePort: number; - destIp: string; - destPort: number; - timestamp?: number; - }, - enableLogging: boolean = false, - cachedSni?: string - ): string | undefined { - const logger = enableLogging ? - (message: string) => console.log(`[TLS Packet] ${message}`) : - undefined; - - return SniExtraction.processTlsPacket(buffer, connectionInfo, logger, cachedSni); - } -} \ No newline at end of file