Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 572e31587a | |||
| 8587fb997c | |||
| 9ba101c59b | |||
| 1ad3e61c15 | |||
| 3bfa451341 | |||
| 7b3ab7378b |
20
changelog.md
20
changelog.md
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-04 - 27.4.0 - feat(rustproxy)
|
||||
add HTTP/3 proxy service wiring for QUIC listeners
|
||||
|
||||
- registers H3ProxyService with the UDP listener manager so QUIC connections can serve HTTP/3
|
||||
- keeps proxy IP configuration intact while enabling HTTP/3 handling during listener setup
|
||||
|
||||
## 2026-04-04 - 27.3.1 - fix(metrics)
|
||||
correct frontend and backend protocol connection tracking across h1, h2, h3, and websocket traffic
|
||||
|
||||
- move frontend protocol accounting from per-request to connection lifetime tracking for HTTP/1, HTTP/2, and HTTP/3
|
||||
- add backend protocol guards to connection drivers so active protocol metrics reflect live upstream connections
|
||||
- prevent protocol counter underflow by using atomic saturating decrements in the metrics collector
|
||||
- read backend protocol distribution directly from cached aggregate counters in the Rust metrics adapter
|
||||
|
||||
## 2026-04-04 - 27.3.0 - feat(test)
|
||||
add end-to-end WebSocket proxy test coverage
|
||||
|
||||
- add comprehensive WebSocket e2e tests for upgrade handling, bidirectional messaging, header forwarding, close propagation, and large payloads
|
||||
- add ws and @types/ws as development dependencies to support the new test suite
|
||||
|
||||
## 2026-04-04 - 27.2.0 - feat(metrics)
|
||||
add frontend and backend protocol distribution metrics
|
||||
|
||||
|
||||
8
deno.lock
generated
8
deno.lock
generated
@@ -12,9 +12,11 @@
|
||||
"npm:@push.rocks/smartserve@^2.0.3": "2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0": "9.5.0",
|
||||
"npm:@types/node@^25.5.0": "25.5.0",
|
||||
"npm:@types/ws@^8.18.1": "8.18.1",
|
||||
"npm:minimatch@^10.2.4": "10.2.4",
|
||||
"npm:typescript@^6.0.2": "6.0.2",
|
||||
"npm:why-is-node-running@^3.2.2": "3.2.2"
|
||||
"npm:why-is-node-running@^3.2.2": "3.2.2",
|
||||
"npm:ws@^8.20.0": "8.20.0"
|
||||
},
|
||||
"npm": {
|
||||
"@api.global/typedrequest-interfaces@2.0.2": {
|
||||
@@ -6743,9 +6745,11 @@
|
||||
"npm:@push.rocks/smartserve@^2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0",
|
||||
"npm:@types/node@^25.5.0",
|
||||
"npm:@types/ws@^8.18.1",
|
||||
"npm:minimatch@^10.2.4",
|
||||
"npm:typescript@^6.0.2",
|
||||
"npm:why-is-node-running@^3.2.2"
|
||||
"npm:why-is-node-running@^3.2.2",
|
||||
"npm:ws@^8.20.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "27.2.0",
|
||||
"version": "27.4.0",
|
||||
"private": false,
|
||||
"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.",
|
||||
"main": "dist_ts/index.js",
|
||||
@@ -22,8 +22,10 @@
|
||||
"@git.zone/tstest": "^3.6.0",
|
||||
"@push.rocks/smartserve": "^2.0.3",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^6.0.2",
|
||||
"why-is-node-running": "^3.2.2"
|
||||
"why-is-node-running": "^3.2.2",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -45,12 +45,18 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
typescript:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
why-is-node-running:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -3304,18 +3310,6 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
|
||||
|
||||
ws@8.19.0:
|
||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -5296,7 +5290,7 @@ snapshots:
|
||||
'@push.rocks/smartenv': 6.0.0
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
ws: 8.19.0
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@@ -8033,8 +8027,6 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
xml-parse-from-string@1.0.1: {}
|
||||
|
||||
@@ -18,7 +18,7 @@ use tracing::{debug, warn};
|
||||
use rustproxy_config::RouteConfig;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::proxy_service::{ConnActivity, HttpProxyService};
|
||||
use crate::proxy_service::{ConnActivity, HttpProxyService, ProtocolGuard};
|
||||
|
||||
/// HTTP/3 proxy service.
|
||||
///
|
||||
@@ -48,6 +48,9 @@ impl H3ProxyService {
|
||||
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
|
||||
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
|
||||
|
||||
// Track frontend H3 connection for the QUIC connection's lifetime.
|
||||
let _frontend_h3_guard = ProtocolGuard::frontend(Arc::clone(self.http_proxy.metrics()), "h3");
|
||||
|
||||
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
|
||||
h3::server::builder()
|
||||
.send_grease(false)
|
||||
|
||||
@@ -140,6 +140,38 @@ impl Drop for ProtocolGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection-level frontend protocol tracker.
|
||||
///
|
||||
/// In `handle_io`, the HTTP protocol (h1 vs h2) is unknown until the first request
|
||||
/// arrives. This struct uses `OnceLock` so the first request detects the protocol
|
||||
/// and opens the counter; subsequent requests on the same connection are no-ops.
|
||||
/// On Drop (when the connection ends), the counter is closed.
|
||||
pub(crate) struct FrontendProtocolTracker {
|
||||
metrics: Arc<MetricsCollector>,
|
||||
proto: std::sync::OnceLock<&'static str>,
|
||||
}
|
||||
|
||||
impl FrontendProtocolTracker {
|
||||
fn new(metrics: Arc<MetricsCollector>) -> Self {
|
||||
Self { metrics, proto: std::sync::OnceLock::new() }
|
||||
}
|
||||
|
||||
/// Set the frontend protocol. Only the first call opens the counter.
|
||||
fn set(&self, proto: &'static str) {
|
||||
if self.proto.set(proto).is_ok() {
|
||||
self.metrics.frontend_protocol_opened(proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FrontendProtocolTracker {
|
||||
fn drop(&mut self) {
|
||||
if let Some(proto) = self.proto.get() {
|
||||
self.metrics.frontend_protocol_closed(proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend stream that can be either plain TCP or TLS-wrapped.
|
||||
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
|
||||
pub(crate) enum BackendStream {
|
||||
@@ -365,6 +397,11 @@ impl HttpProxyService {
|
||||
self.protocol_cache.snapshot()
|
||||
}
|
||||
|
||||
/// Access the shared metrics collector (used by H3ProxyService for protocol tracking).
|
||||
pub fn metrics(&self) -> &Arc<MetricsCollector> {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Handle an incoming HTTP connection on a plain TCP stream.
|
||||
pub async fn handle_connection(
|
||||
self: Arc<Self>,
|
||||
@@ -409,10 +446,24 @@ impl HttpProxyService {
|
||||
let active_requests = Arc::new(AtomicU64::new(0));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Connection-level frontend protocol tracker: the first request detects
|
||||
// h1 vs h2 from req.version() and opens the counter. On connection close
|
||||
// (when handle_io returns), Drop closes the counter.
|
||||
let frontend_tracker = Arc::new(FrontendProtocolTracker::new(Arc::clone(&self.metrics)));
|
||||
let ft_inner = Arc::clone(&frontend_tracker);
|
||||
|
||||
let la_inner = Arc::clone(&last_activity);
|
||||
let ar_inner = Arc::clone(&active_requests);
|
||||
let cancel_inner = cancel.clone();
|
||||
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
|
||||
// Detect frontend protocol from the first request on this connection.
|
||||
// OnceLock ensures only the first call opens the counter.
|
||||
let proto: &'static str = match req.version() {
|
||||
hyper::Version::HTTP_2 => "h2",
|
||||
_ => "h1",
|
||||
};
|
||||
ft_inner.set(proto);
|
||||
|
||||
// Mark request start — RAII guard decrements on drop (panic-safe)
|
||||
la_inner.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
let req_guard = ActiveRequestGuard::new(Arc::clone(&ar_inner));
|
||||
@@ -655,17 +706,8 @@ impl HttpProxyService {
|
||||
.map(|p| p.as_str().eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Track frontend protocol for distribution metrics (h1/h2/h3/ws)
|
||||
let frontend_proto: &'static str = if is_h1_websocket || is_h2_websocket {
|
||||
"ws"
|
||||
} else {
|
||||
match req.version() {
|
||||
hyper::Version::HTTP_2 => "h2",
|
||||
hyper::Version::HTTP_3 => "h3",
|
||||
_ => "h1", // HTTP/1.0, HTTP/1.1
|
||||
}
|
||||
};
|
||||
let _frontend_proto_guard = ProtocolGuard::frontend(Arc::clone(&self.metrics), frontend_proto);
|
||||
// Frontend protocol is tracked at the connection level (handle_io / h3_service).
|
||||
// WebSocket tunnels additionally get their own "ws" guards in the spawned task.
|
||||
|
||||
if is_h1_websocket || is_h2_websocket {
|
||||
let result = self.handle_websocket_upgrade(
|
||||
@@ -1275,13 +1317,18 @@ impl HttpProxyService {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
{
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H1 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, domain, conn_activity, backend_key).await
|
||||
}
|
||||
@@ -1402,7 +1449,10 @@ impl HttpProxyService {
|
||||
let pool = Arc::clone(&self.connection_pool);
|
||||
let key = pool_key.clone();
|
||||
let gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H2 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
|
||||
if let Err(e) = conn.await {
|
||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||
}
|
||||
@@ -1701,7 +1751,10 @@ impl HttpProxyService {
|
||||
let pool = Arc::clone(&self.connection_pool);
|
||||
let key = pool_key.clone();
|
||||
let gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H2 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
|
||||
if let Err(e) = conn.await {
|
||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||
}
|
||||
@@ -1871,13 +1924,18 @@ impl HttpProxyService {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
{
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H1 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut upstream_req = Request::builder()
|
||||
.method(method)
|
||||
@@ -2425,7 +2483,10 @@ impl HttpProxyService {
|
||||
selector: upstream_selector,
|
||||
key: upstream_key_owned.clone(),
|
||||
};
|
||||
// Track backend WebSocket connection — guard decrements on tunnel close
|
||||
// Track WebSocket tunnel as "ws" on both frontend and backend.
|
||||
// Frontend h1/h2 is tracked at the connection level (handle_io); this
|
||||
// additional "ws" guard captures the tunnel's lifetime independently.
|
||||
let _frontend_ws_guard = ProtocolGuard::frontend(Arc::clone(&metrics), "ws");
|
||||
let _backend_ws_guard = ProtocolGuard::backend(Arc::clone(&metrics), "ws");
|
||||
|
||||
let client_upgraded = match on_client_upgrade.await {
|
||||
@@ -2889,7 +2950,10 @@ impl HttpProxyService {
|
||||
let driver_pool = Arc::clone(&self.connection_pool);
|
||||
let driver_pool_key = pool_key.clone();
|
||||
let driver_gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H3 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h3");
|
||||
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||
debug!("H3 connection driver closed: {:?}", close_err);
|
||||
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
@@ -506,13 +506,14 @@ impl MetricsCollector {
|
||||
total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record a frontend request/connection closed with a given protocol.
|
||||
/// Record a frontend connection closed with a given protocol.
|
||||
pub fn frontend_protocol_closed(&self, proto: &str) {
|
||||
let (active, _) = self.frontend_proto_counters(proto);
|
||||
let val = active.load(Ordering::Relaxed);
|
||||
if val > 0 {
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
// Atomic saturating decrement — avoids TOCTOU race where concurrent
|
||||
// closes could both read val=1, both subtract, wrapping to u64::MAX.
|
||||
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
if v > 0 { Some(v - 1) } else { None }
|
||||
}).ok();
|
||||
}
|
||||
|
||||
/// Record a backend connection opened with a given protocol.
|
||||
@@ -525,10 +526,10 @@ impl MetricsCollector {
|
||||
/// Record a backend connection closed with a given protocol.
|
||||
pub fn backend_protocol_closed(&self, proto: &str) {
|
||||
let (active, _) = self.backend_proto_counters(proto);
|
||||
let val = active.load(Ordering::Relaxed);
|
||||
if val > 0 {
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
// Atomic saturating decrement — see frontend_protocol_closed for rationale.
|
||||
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
if v > 0 { Some(v - 1) } else { None }
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// ── Per-backend recording methods ──
|
||||
|
||||
@@ -845,6 +845,10 @@ impl RustProxy {
|
||||
connection_registry,
|
||||
);
|
||||
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
|
||||
// Wire up H3ProxyService so QUIC connections can serve HTTP/3
|
||||
let http_proxy = listener.http_proxy().clone();
|
||||
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(http_proxy);
|
||||
udp_mgr.set_h3_service(std::sync::Arc::new(h3_svc));
|
||||
self.udp_listener_manager = Some(udp_mgr);
|
||||
}
|
||||
}
|
||||
|
||||
418
test/test.websocket-e2e.ts
Normal file
418
test/test.websocket-e2e.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import * as http from 'http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
/**
|
||||
* Helper: create a WebSocket client that connects through the proxy.
|
||||
* Registers the message handler BEFORE awaiting open to avoid race conditions.
|
||||
*/
|
||||
function connectWs(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
opts: WebSocket.ClientOptions = {},
|
||||
): { ws: WebSocket; messages: string[]; opened: Promise<void> } {
|
||||
const messages: string[] = [];
|
||||
const ws = new WebSocket(url, { headers, ...opts });
|
||||
|
||||
// Register message handler immediately — before open fires
|
||||
ws.on('message', (data) => {
|
||||
messages.push(data.toString());
|
||||
});
|
||||
|
||||
const opened = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('WebSocket open timeout')), 5000);
|
||||
ws.on('open', () => { clearTimeout(timeout); resolve(); });
|
||||
ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
|
||||
return { ws, messages, opened };
|
||||
}
|
||||
|
||||
/** Wait until `predicate` returns true, with a hard timeout. */
|
||||
function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs);
|
||||
const check = () => {
|
||||
if (predicate()) { clearTimeout(deadline); resolve(); }
|
||||
else setTimeout(check, 30);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/** Graceful close helper */
|
||||
function closeWs(ws: WebSocket): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (ws.readyState === WebSocket.CLOSED) return resolve();
|
||||
ws.on('close', () => resolve());
|
||||
ws.close();
|
||||
setTimeout(resolve, 2000); // fallback
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test 1: Basic WebSocket upgrade and bidirectional messaging ───
|
||||
tap.test('should proxy WebSocket connections with bidirectional messaging', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
// Backend: echoes messages with prefix, sends greeting on connect
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer });
|
||||
const backendMessages: string[] = [];
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (data) => {
|
||||
const msg = data.toString();
|
||||
backendMessages.push(msg);
|
||||
ws.send(`echo: ${msg}`);
|
||||
});
|
||||
ws.send('hello from backend');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-test-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
// Connect client — message handler registered before open
|
||||
const { ws, messages, opened } = connectWs(
|
||||
`ws://127.0.0.1:${PROXY_PORT}/`,
|
||||
{ Host: 'test.local' },
|
||||
);
|
||||
await opened;
|
||||
|
||||
// Wait for the backend greeting
|
||||
await waitFor(() => messages.length >= 1);
|
||||
expect(messages[0]).toEqual('hello from backend');
|
||||
|
||||
// Send 3 messages, expect 3 echoes
|
||||
ws.send('ping 1');
|
||||
ws.send('ping 2');
|
||||
ws.send('ping 3');
|
||||
|
||||
await waitFor(() => messages.length >= 4);
|
||||
|
||||
expect(messages).toContain('echo: ping 1');
|
||||
expect(messages).toContain('echo: ping 2');
|
||||
expect(messages).toContain('echo: ping 3');
|
||||
expect(backendMessages).toInclude('ping 1');
|
||||
expect(backendMessages).toInclude('ping 2');
|
||||
expect(backendMessages).toInclude('ping 3');
|
||||
|
||||
await closeWs(ws);
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
// ─── Test 2: Multiple concurrent WebSocket connections ───
|
||||
tap.test('should handle multiple concurrent WebSocket connections', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer });
|
||||
|
||||
let connectionCount = 0;
|
||||
wss.on('connection', (ws) => {
|
||||
const id = ++connectionCount;
|
||||
ws.on('message', (data) => {
|
||||
ws.send(`conn${id}: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-multi-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
const NUM_CLIENTS = 5;
|
||||
const clients: { ws: WebSocket; messages: string[] }[] = [];
|
||||
|
||||
for (let i = 0; i < NUM_CLIENTS; i++) {
|
||||
const c = connectWs(
|
||||
`ws://127.0.0.1:${PROXY_PORT}/`,
|
||||
{ Host: 'test.local' },
|
||||
);
|
||||
await c.opened;
|
||||
clients.push(c);
|
||||
}
|
||||
|
||||
// Each client sends a unique message
|
||||
for (let i = 0; i < NUM_CLIENTS; i++) {
|
||||
clients[i].ws.send(`hello from client ${i}`);
|
||||
}
|
||||
|
||||
// Wait for all replies
|
||||
await waitFor(() => clients.every((c) => c.messages.length >= 1));
|
||||
|
||||
for (let i = 0; i < NUM_CLIENTS; i++) {
|
||||
expect(clients[i].messages.length).toBeGreaterThanOrEqual(1);
|
||||
expect(clients[i].messages[0]).toInclude(`hello from client ${i}`);
|
||||
}
|
||||
expect(connectionCount).toEqual(NUM_CLIENTS);
|
||||
|
||||
for (const c of clients) await closeWs(c.ws);
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
// ─── Test 3: WebSocket with binary data ───
|
||||
tap.test('should proxy binary WebSocket frames', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (data) => {
|
||||
ws.send(data, { binary: true });
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-binary-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
const receivedBuffers: Buffer[] = [];
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PROXY_PORT}/`, {
|
||||
headers: { Host: 'test.local' },
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
receivedBuffers.push(Buffer.from(data as ArrayBuffer));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('timeout')), 5000);
|
||||
ws.on('open', () => { clearTimeout(timeout); resolve(); });
|
||||
ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
|
||||
// Send a 256-byte buffer with known content
|
||||
const sentBuffer = Buffer.alloc(256);
|
||||
for (let i = 0; i < 256; i++) sentBuffer[i] = i;
|
||||
ws.send(sentBuffer);
|
||||
|
||||
await waitFor(() => receivedBuffers.length >= 1);
|
||||
|
||||
expect(receivedBuffers[0].length).toEqual(256);
|
||||
expect(Buffer.compare(receivedBuffers[0], sentBuffer)).toEqual(0);
|
||||
|
||||
await closeWs(ws);
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
// ─── Test 4: WebSocket path and query string preserved ───
|
||||
tap.test('should preserve path and query string through proxy', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer });
|
||||
|
||||
let receivedUrl = '';
|
||||
wss.on('connection', (ws, req) => {
|
||||
receivedUrl = req.url || '';
|
||||
ws.send(`url: ${receivedUrl}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-path-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
const { ws, messages, opened } = connectWs(
|
||||
`ws://127.0.0.1:${PROXY_PORT}/chat/room1?token=abc123`,
|
||||
{ Host: 'test.local' },
|
||||
);
|
||||
await opened;
|
||||
|
||||
await waitFor(() => messages.length >= 1);
|
||||
|
||||
expect(receivedUrl).toEqual('/chat/room1?token=abc123');
|
||||
expect(messages[0]).toEqual('url: /chat/room1?token=abc123');
|
||||
|
||||
await closeWs(ws);
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
// ─── Test 5: Clean close propagation ───
|
||||
tap.test('should handle clean WebSocket close from client', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer });
|
||||
|
||||
let backendGotClose = false;
|
||||
let backendCloseCode = 0;
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('close', (code) => {
|
||||
backendGotClose = true;
|
||||
backendCloseCode = code;
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-close-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
const { ws, messages, opened } = connectWs(
|
||||
`ws://127.0.0.1:${PROXY_PORT}/`,
|
||||
{ Host: 'test.local' },
|
||||
);
|
||||
await opened;
|
||||
|
||||
// Confirm connection works with a round-trip
|
||||
ws.send('test');
|
||||
await waitFor(() => messages.length >= 1);
|
||||
|
||||
// Close with code 1000
|
||||
let clientCloseCode = 0;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
ws.on('close', (code) => {
|
||||
clientCloseCode = code;
|
||||
resolve();
|
||||
});
|
||||
setTimeout(resolve, 3000);
|
||||
});
|
||||
ws.close(1000, 'done');
|
||||
await closed;
|
||||
|
||||
// Wait for backend to register
|
||||
await waitFor(() => backendGotClose, 3000);
|
||||
|
||||
expect(backendGotClose).toBeTrue();
|
||||
expect(clientCloseCode).toEqual(1000);
|
||||
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
// ─── Test 6: Large messages ───
|
||||
tap.test('should handle large WebSocket messages', async () => {
|
||||
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
const backendServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: backendServer, maxPayload: 5 * 1024 * 1024 });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (data) => {
|
||||
const buf = Buffer.from(data as ArrayBuffer);
|
||||
ws.send(`received ${buf.length} bytes`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'ws-large-route',
|
||||
match: { ports: PROXY_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
}],
|
||||
});
|
||||
await proxy.start();
|
||||
|
||||
const { ws, messages, opened } = connectWs(
|
||||
`ws://127.0.0.1:${PROXY_PORT}/`,
|
||||
{ Host: 'test.local' },
|
||||
{ maxPayload: 5 * 1024 * 1024 },
|
||||
);
|
||||
await opened;
|
||||
|
||||
// Send a 1MB message
|
||||
const largePayload = Buffer.alloc(1024 * 1024, 0x42);
|
||||
ws.send(largePayload);
|
||||
|
||||
await waitFor(() => messages.length >= 1);
|
||||
expect(messages[0]).toEqual(`received ${1024 * 1024} bytes`);
|
||||
|
||||
await closeWs(ws);
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '27.2.0',
|
||||
version: '27.4.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.'
|
||||
}
|
||||
|
||||
@@ -106,27 +106,14 @@ export class RustMetricsAdapter implements IMetrics {
|
||||
};
|
||||
},
|
||||
backendProtocols: (): IProtocolDistribution => {
|
||||
// Merge per-backend h1/h2/h3 data with aggregate ws/other counters
|
||||
const bp = this.cache?.backendProtocols;
|
||||
let h1Active = 0, h1Total = 0;
|
||||
let h2Active = 0, h2Total = 0;
|
||||
let h3Active = 0, h3Total = 0;
|
||||
if (this.cache?.backends) {
|
||||
for (const bm of Object.values(this.cache.backends)) {
|
||||
const m = bm as any;
|
||||
const active = m.activeConnections ?? 0;
|
||||
const total = m.totalConnections ?? 0;
|
||||
switch (m.protocol) {
|
||||
case 'h2': h2Active += active; h2Total += total; break;
|
||||
case 'h3': h3Active += active; h3Total += total; break;
|
||||
default: h1Active += active; h1Total += total; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
h1Active, h1Total,
|
||||
h2Active, h2Total,
|
||||
h3Active, h3Total,
|
||||
h1Active: bp?.h1Active ?? 0,
|
||||
h1Total: bp?.h1Total ?? 0,
|
||||
h2Active: bp?.h2Active ?? 0,
|
||||
h2Total: bp?.h2Total ?? 0,
|
||||
h3Active: bp?.h3Active ?? 0,
|
||||
h3Total: bp?.h3Total ?? 0,
|
||||
wsActive: bp?.wsActive ?? 0,
|
||||
wsTotal: bp?.wsTotal ?? 0,
|
||||
otherActive: bp?.otherActive ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user