Compare commits

...

6 Commits

Author SHA1 Message Date
cbde778f09 v23.1.4
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 22:35:25 +00:00
bc2bc874a5 fix(tests): make tests more robust and bump small dependencies 2026-02-12 22:35:25 +00:00
fdabf807b0 v23.1.3
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 20:17:32 +00:00
81e0e6b4d8 fix(rustproxy): install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches 2026-02-12 20:17:32 +00:00
28fa69bf59 v23.1.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 13:48:30 +00:00
5019658032 fix(core): use node: scoped builtin imports and add route unit tests 2026-02-11 13:48:30 +00:00
21 changed files with 7759 additions and 113 deletions

View File

@@ -1,5 +1,29 @@
# Changelog # Changelog
## 2026-02-12 - 23.1.4 - fix(tests)
make tests more robust and bump small dependencies
- Bump dependencies: @push.rocks/smartrust ^1.2.1 and minimatch ^10.2.0
- Replace hardcoded ports with named constants (ECHO_PORT, PROXY_PORT, PROXY_PORT_1/2) to avoid collisions between tests
- Add server 'error' handlers and reject listen promises on server errors to prevent silent hangs
- Reduce test timeouts and intervals (shorter test durations, more frequent pings) to speed up test runs
- Ensure proxy is stopped between tests and remove forced process.exit; export tap.start() consistently
- Adjust assertions to match the new shorter ping/response counts
## 2026-02-12 - 23.1.3 - fix(rustproxy)
install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches
- Install ring-based rustls crypto provider at startup to prevent panics from instant-acme/hyper-rustls calling ClientConfig::builder() before TLS listeners are initialized
- Add a non-blocking 10ms peek to detect HTTP traffic in the TCP passthrough fast-path to avoid misrouting HTTP and ensure HTTP proxy handles CORS, errors, and request-level routing
- Skip the fast-path and fall back to the HTTP proxy when HTTP is detected (with a debug log)
- When no route matches for detected HTTP connections, send an HTTP 502 Bad Gateway response and close the connection instead of silently dropping it
## 2026-02-11 - 23.1.2 - fix(core)
use node: scoped builtin imports and add route unit tests
- Replaced bare Node built-in imports (events, fs, http, https, net, path, tls, url, http2, buffer, crypto) with 'node:' specifiers for ESM/bundler compatibility (files updated include ts/plugins.ts, ts/core/models/socket-types.ts, ts/core/utils/enhanced-connection-pool.ts, ts/core/utils/socket-tracker.ts, ts/protocols/common/fragment-handler.ts, ts/protocols/tls/sni/client-hello-parser.ts, ts/protocols/tls/sni/sni-extraction.ts, ts/protocols/websocket/utils.ts, ts/tls/sni/sni-handler.ts).
- Added new unit tests (test/test.bun.ts and test/test.deno.ts) covering route helpers, validators, matching, merging and cloning to improve test coverage.
## 2026-02-11 - 23.1.1 - fix(rust-proxy) ## 2026-02-11 - 23.1.1 - fix(rust-proxy)
increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies

7333
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "23.1.1", "version": "23.1.4",
"private": false, "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.", "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", "main": "dist_ts/index.js",
@@ -34,14 +34,14 @@
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrust": "^1.2.0", "@push.rocks/smartrust": "^1.2.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.1.0", "@push.rocks/smartstring": "^4.1.0",
"@push.rocks/taskbuffer": "^4.2.0", "@push.rocks/taskbuffer": "^4.2.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",
"@types/minimatch": "^6.0.0", "@types/minimatch": "^6.0.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"minimatch": "^10.1.2", "minimatch": "^10.2.0",
"pretty-ms": "^9.3.0", "pretty-ms": "^9.3.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },

62
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1 version: 5.0.1
'@push.rocks/smartrust': '@push.rocks/smartrust':
specifier: ^1.2.0 specifier: ^1.2.1
version: 1.2.0 version: 1.2.1
'@push.rocks/smartrx': '@push.rocks/smartrx':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
@@ -57,8 +57,8 @@ importers:
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
minimatch: minimatch:
specifier: ^10.1.2 specifier: ^10.2.0
version: 10.1.2 version: 10.2.0
pretty-ms: pretty-ms:
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
@@ -570,14 +570,6 @@ packages:
resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.1':
resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==}
engines: {node: 20 || >=22}
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -883,8 +875,8 @@ packages:
'@push.rocks/smartrouter@1.3.3': '@push.rocks/smartrouter@1.3.3':
resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==} resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==}
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
resolution: {integrity: sha512-JlaALselIHoP6C3ceQbrvz424G21cND/QsH/KI3E/JrO4XphJiGZwM6f4yJWrijdPYR/YYMoaIiYN7ybZp0C4w==} resolution: {integrity: sha512-ANwXXibUwoHNWF1hhXhXVVrfzYlhgHYRa2205Jkd/s/wXzcWHftYZthilJj+52B7nkzSB76umfxKfK5eBYY2Ug==}
'@push.rocks/smartrx@3.0.10': '@push.rocks/smartrx@3.0.10':
resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==} resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==}
@@ -1649,6 +1641,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.2:
resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==}
engines: {node: 20 || >=22}
bare-events@2.8.2: bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies: peerDependencies:
@@ -1714,6 +1710,10 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.2:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==} resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==}
@@ -2750,8 +2750,8 @@ packages:
minimalistic-crypto-utils@1.0.1: minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.1.2: minimatch@10.2.0:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
minimatch@3.1.2: minimatch@3.1.2:
@@ -4654,12 +4654,6 @@ snapshots:
dependencies: dependencies:
mute-stream: 1.0.0 mute-stream: 1.0.0
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.1':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
@@ -5034,7 +5028,7 @@ snapshots:
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
@@ -5127,7 +5121,7 @@ snapshots:
acme-client: 5.4.0 acme-client: 5.4.0
dns-packet: 5.6.1 dns-packet: 5.6.1
elliptic: 6.6.1 elliptic: 6.6.1
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5143,7 +5137,7 @@ snapshots:
acme-client: 5.4.0 acme-client: 5.4.0
dns-packet: 5.6.1 dns-packet: 5.6.1
elliptic: 6.6.1 elliptic: 6.6.1
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5497,7 +5491,7 @@ snapshots:
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
path-to-regexp: 8.3.0 path-to-regexp: 8.3.0
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
dependencies: dependencies:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -6323,7 +6317,7 @@ snapshots:
'@types/minimatch@6.0.0': '@types/minimatch@6.0.0':
dependencies: dependencies:
minimatch: 10.1.2 minimatch: 10.2.0
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
@@ -6494,6 +6488,10 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.2:
dependencies:
jackspeak: 4.2.3
bare-events@2.8.2: {} bare-events@2.8.2: {}
bare-fs@4.5.3: bare-fs@4.5.3:
@@ -6564,6 +6562,10 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.2:
dependencies:
balanced-match: 4.0.2
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@@ -7157,7 +7159,7 @@ snapshots:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
jackspeak: 4.2.3 jackspeak: 4.2.3
minimatch: 10.1.2 minimatch: 10.2.0
minipass: 7.1.2 minipass: 7.1.2
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.1 path-scurry: 2.0.1
@@ -7862,9 +7864,9 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {} minimalistic-crypto-utils@1.0.1: {}
minimatch@10.1.2: minimatch@10.2.0:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.1 brace-expansion: 5.0.2
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:

View File

@@ -364,6 +364,10 @@ impl TcpListenerManager {
// doesn't send initial data (e.g., SMTP, greeting-based protocols). // doesn't send initial data (e.g., SMTP, greeting-based protocols).
// If a route matches by port alone and doesn't need domain/path/TLS info, // If a route matches by port alone and doesn't need domain/path/TLS info,
// we can forward immediately without waiting for client data. // we can forward immediately without waiting for client data.
//
// IMPORTANT: HTTP connections must NOT use this path — they need the HTTP
// proxy for proper error responses, CORS handling, and request-level routing.
// We detect HTTP via a non-blocking peek before committing to raw forwarding.
{ {
let quick_ctx = rustproxy_routing::MatchContext { let quick_ctx = rustproxy_routing::MatchContext {
port, port,
@@ -384,7 +388,28 @@ impl TcpListenerManager {
// Only use fast path for simple port-only forward routes with no TLS // Only use fast path for simple port-only forward routes with no TLS
if has_no_domain && has_no_path && is_forward && has_no_tls { if has_no_domain && has_no_path && is_forward && has_no_tls {
if let Some(target) = quick_match.target { // Non-blocking peek: if client has already sent data that looks
// like HTTP, skip fast path and let the normal path handle it
// through the HTTP proxy (for CORS, error responses, path routing).
let is_likely_http = {
let mut probe = [0u8; 16];
// Brief peek: HTTP clients send data immediately after connect.
// Server-speaks-first protocols (SMTP etc.) send nothing initially.
// 10ms is ample for any HTTP client while negligible for
// server-speaks-first protocols (which wait seconds for greeting).
match tokio::time::timeout(
std::time::Duration::from_millis(10),
stream.peek(&mut probe),
).await {
Ok(Ok(n)) if n > 0 => sni_parser::is_http(&probe[..n]),
_ => false,
}
};
if is_likely_http {
debug!("Fast-path skipped: HTTP detected from {}, using HTTP proxy", peer_addr);
// Fall through to normal path for HTTP proxy handling
} else if let Some(target) = quick_match.target {
let target_host = target.host.first().to_string(); let target_host = target.host.first().to_string();
let target_port = target.port.resolve(port); let target_port = target.port.resolve(port);
let route_id = quick_match.route.id.as_deref(); let route_id = quick_match.route.id.as_deref();
@@ -562,6 +587,17 @@ impl TcpListenerManager {
Some(rm) => rm, Some(rm) => rm,
None => { None => {
debug!("No route matched for port {} domain {:?}", port, domain); debug!("No route matched for port {} domain {:?}", port, domain);
if is_http {
// Send a proper HTTP error instead of dropping the connection
use tokio::io::AsyncWriteExt;
let body = "No route matched";
let resp = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
}
return Ok(()); return Ok(());
} }
}; };

View File

@@ -29,6 +29,11 @@ struct Cli {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Install the default CryptoProvider early, before any TLS or ACME code runs.
// This prevents panics from instant-acme/hyper-rustls calling ClientConfig::builder()
// before TLS listeners have started. Idempotent — later calls harmlessly return Err.
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize tracing - write to stderr so stdout is reserved for management IPC // Initialize tracing - write to stderr so stdout is reserved for management IPC

123
test/test.bun.ts Normal file
View File

@@ -0,0 +1,123 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpsTerminateRoute,
createCompleteHttpsServer,
createHttpRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
mergeRouteConfigs,
cloneRoute,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRoutes,
validateRouteConfig,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.action.type).toEqual('forward');
expect(route.action.tls).toBeDefined();
expect(route.action.tls!.mode).toEqual('terminate');
expect(route.match.domains).toEqual('secure.example.com');
});
tap.test('route creation - createCompleteHttpsServer returns redirect and main route', async () => {
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
expect(routes).toBeArray();
expect(routes.length).toBeGreaterThanOrEqual(2);
// Should have an HTTP→HTTPS redirect and an HTTPS route
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
expect(hasRedirect || hasHttps).toBeTrue();
});
tap.test('route validation - validateRoutes on a set of routes', async () => {
const routes: IRouteConfig[] = [
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
];
const result = validateRoutes(routes);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
const routes: any[] = [
createHttpRoute('valid.com', { host: '127.0.0.1', port: 3000 }),
{ match: { ports: 80 } }, // missing action
];
const result = validateRoutes(routes);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('path matching - routeMatchesPath with exact path', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
route.match.path = '/api';
expect(routeMatchesPath(route, '/api')).toBeTrue();
expect(routeMatchesPath(route, '/other')).toBeFalse();
});
tap.test('path matching - route without path matches everything', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// No path set, should match any path
expect(routeMatchesPath(route, '/anything')).toBeTrue();
expect(routeMatchesPath(route, '/')).toBeTrue();
});
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.priority = 10;
base.name = 'base-route';
const merged = mergeRouteConfigs(base, {
priority: 50,
name: 'merged-route',
});
expect(merged.priority).toEqual(50);
expect(merged.name).toEqual('merged-route');
// Original route fields should be preserved
expect(merged.match.domains).toEqual('example.com');
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
});
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.name = 'original';
const merged = mergeRouteConfigs(base, { name: 'changed' });
expect(base.name).toEqual('original');
expect(merged.name).toEqual('changed');
});
tap.test('route cloning - cloneRoute produces independent copy', async () => {
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
original.priority = 42;
original.name = 'original-route';
const cloned = cloneRoute(original);
// Should be equal in value
expect(cloned.match.domains).toEqual('example.com');
expect(cloned.priority).toEqual(42);
expect(cloned.name).toEqual('original-route');
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
expect(cloned.action.targets![0].port).toEqual(3000);
// Should be independent - modifying clone shouldn't affect original
cloned.name = 'cloned-route';
cloned.priority = 99;
expect(original.name).toEqual('original-route');
expect(original.priority).toEqual(42);
});
export default tap.start();

111
test/test.deno.ts Normal file
View File

@@ -0,0 +1,111 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpRoute,
createHttpsTerminateRoute,
createLoadBalancerRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
findMatchingRoutes,
findBestMatchingRoute,
routeMatchesDomain,
routeMatchesPort,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRouteConfig,
isValidDomain,
isValidPort,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpRoute produces correct structure', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.match.domains).toEqual('example.com');
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeArray();
expect(route.action.targets![0].host).toEqual('127.0.0.1');
expect(route.action.targets![0].port).toEqual(3000);
});
tap.test('route creation - createHttpRoute with array of domains', async () => {
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
expect(route.match.domains).toEqual(['a.com', 'b.com']);
});
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
const route = createHttpRoute('valid.example.com', { host: '10.0.0.1', port: 8080 });
const result = validateRouteConfig(route);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRouteConfig rejects missing action', async () => {
const badRoute = { match: { ports: 80 } } as any;
const result = validateRouteConfig(badRoute);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('route validation - isValidDomain checks correctly', async () => {
expect(isValidDomain('example.com')).toBeTrue();
expect(isValidDomain('*.example.com')).toBeTrue();
expect(isValidDomain('')).toBeFalse();
});
tap.test('route validation - isValidPort checks correctly', async () => {
expect(isValidPort(80)).toBeTrue();
expect(isValidPort(443)).toBeTrue();
expect(isValidPort(0)).toBeFalse();
expect(isValidPort(70000)).toBeFalse();
expect(isValidPort(-1)).toBeFalse();
});
tap.test('domain matching - exact domain', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
});
tap.test('domain matching - wildcard domain', async () => {
const route = createHttpRoute('*.example.com', { host: '127.0.0.1', port: 3000 });
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
});
tap.test('port matching - single port', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// createHttpRoute defaults to port 80
expect(routeMatchesPort(route, 80)).toBeTrue();
expect(routeMatchesPort(route, 443)).toBeFalse();
});
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
lowPriority.priority = 10;
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
highPriority.priority = 100;
const routes: IRouteConfig[] = [lowPriority, highPriority];
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(best).toBeDefined();
expect(best!.priority).toEqual(100);
expect(best!.action.targets![0].port).toEqual(4000);
});
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
expect(matches).toHaveLength(2);
});
export default tap.start();

View File

@@ -6,6 +6,9 @@ import { SmartProxy } from '../ts/index.js';
let testProxy: SmartProxy; let testProxy: SmartProxy;
let targetServer: net.Server; let targetServer: net.Server;
const ECHO_PORT = 47200;
const PROXY_PORT = 47201;
// Create a simple echo server as target // Create a simple echo server as target
tap.test('setup test environment', async () => { tap.test('setup test environment', async () => {
// Create target server that echoes data back // Create target server that echoes data back
@@ -23,9 +26,13 @@ tap.test('setup test environment', async () => {
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
targetServer.listen(9876, () => { targetServer.on('error', (err) => {
console.log('Target server listening on port 9876'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
targetServer.listen(ECHO_PORT, () => {
console.log(`Target server listening on port ${ECHO_PORT}`);
resolve(); resolve();
}); });
}); });
@@ -35,13 +42,13 @@ tap.test('setup test environment', async () => {
routes: [{ routes: [{
name: 'tcp-forward-test', name: 'tcp-forward-test',
match: { match: {
ports: 8888 // Plain TCP port ports: PROXY_PORT // Plain TCP port
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
}] }]
// No TLS configuration - just plain TCP forwarding // No TLS configuration - just plain TCP forwarding
} }
@@ -49,7 +56,7 @@ tap.test('setup test environment', async () => {
defaults: { defaults: {
target: { target: {
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
} }
}, },
enableDetailedLogging: true, enableDetailedLogging: true,
@@ -64,7 +71,7 @@ tap.test('setup test environment', async () => {
}); });
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => { tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
tools.timeout(60000); // 60 second test timeout tools.timeout(15000); // 15 second test timeout
const client = new net.Socket(); const client = new net.Socket();
let messagesReceived = 0; let messagesReceived = 0;
@@ -72,7 +79,7 @@ tap.test('should keep WebSocket-like connection open for extended period', async
// Connect to proxy // Connect to proxy
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(8888, 'localhost', () => { client.connect(PROXY_PORT, 'localhost', () => {
console.log('Client connected to proxy'); console.log('Client connected to proxy');
resolve(); resolve();
}); });
@@ -99,19 +106,19 @@ tap.test('should keep WebSocket-like connection open for extended period', async
expect(messagesReceived).toEqual(1); expect(messagesReceived).toEqual(1);
// Simulate WebSocket-like keep-alive pattern // Simulate WebSocket-like keep-alive pattern
// Send periodic messages over 60 seconds // Send periodic messages over 5 seconds
const startTime = Date.now(); const startTime = Date.now();
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
if (!connectionClosed && Date.now() - startTime < 60000) { if (!connectionClosed && Date.now() - startTime < 5000) {
console.log('Sending ping...'); console.log('Sending ping...');
client.write('PING\n'); client.write('PING\n');
} else { } else {
clearInterval(pingInterval); clearInterval(pingInterval);
} }
}, 10000); // Every 10 seconds }, 1000); // Every 1 second
// Wait for 55 seconds (must complete within 60s runner timeout) // Wait for 5 seconds — sufficient to verify the connection stays open
await new Promise(resolve => setTimeout(resolve, 55000)); await new Promise(resolve => setTimeout(resolve, 5000));
// Clean up interval // Clean up interval
clearInterval(pingInterval); clearInterval(pingInterval);
@@ -119,8 +126,8 @@ tap.test('should keep WebSocket-like connection open for extended period', async
// Connection should still be open // Connection should still be open
expect(connectionClosed).toEqual(false); expect(connectionClosed).toEqual(false);
// Should have received responses (1 hello + 6 pings) // Should have received responses (1 hello + ~5 pings)
expect(messagesReceived).toBeGreaterThan(5); expect(messagesReceived).toBeGreaterThan(3);
// Close connection gracefully // Close connection gracefully
client.end(); client.end();

View File

@@ -5,8 +5,8 @@ import * as net from 'net';
let smartProxyInstance: SmartProxy; let smartProxyInstance: SmartProxy;
let echoServer: net.Server; let echoServer: net.Server;
const echoServerPort = 9876; const echoServerPort = 47300;
const proxyPort = 8080; const proxyPort = 47301;
// Create an echo server for testing // Create an echo server for testing
tap.test('should create echo server for testing', async () => { tap.test('should create echo server for testing', async () => {
@@ -16,7 +16,11 @@ tap.test('should create echo server for testing', async () => {
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
echoServer.on('error', (err) => {
console.error(`Echo server error: ${err.message}`);
reject(err);
});
echoServer.listen(echoServerPort, () => { echoServer.listen(echoServerPort, () => {
console.log(`Echo server listening on port ${echoServerPort}`); console.log(`Echo server listening on port ${echoServerPort}`);
resolve(); resolve();

View File

@@ -5,19 +5,27 @@ import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
let echoServer: net.Server; let echoServer: net.Server;
let proxy: SmartProxy; let proxy: SmartProxy;
const ECHO_PORT = 47400;
const PROXY_PORT_1 = 47401;
const PROXY_PORT_2 = 47402;
tap.test('port forwarding should not immediately close connections', async (tools) => { tap.test('port forwarding should not immediately close connections', async (tools) => {
// Set a timeout for this test // Set a timeout for this test
tools.timeout(10000); // 10 seconds tools.timeout(10000); // 10 seconds
// Create an echo server // Create an echo server
echoServer = await new Promise<net.Server>((resolve) => { echoServer = await new Promise<net.Server>((resolve, reject) => {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
socket.write(`ECHO: ${data}`); socket.write(`ECHO: ${data}`);
}); });
}); });
server.listen(8888, () => { server.on('error', (err) => {
console.log('Echo server listening on port 8888'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
server.listen(ECHO_PORT, () => {
console.log(`Echo server listening on port ${ECHO_PORT}`);
resolve(server); resolve(server);
}); });
}); });
@@ -26,10 +34,10 @@ tap.test('port forwarding should not immediately close connections', async (tool
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'test-forward', name: 'test-forward',
match: { ports: 9999 }, match: { ports: PROXY_PORT_1 },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ host: 'localhost', port: 8888 }] targets: [{ host: 'localhost', port: ECHO_PORT }]
} }
}] }]
}); });
@@ -37,7 +45,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
await proxy.start(); await proxy.start();
// Test connection through proxy // Test connection through proxy
const client = net.createConnection(9999, 'localhost'); const client = net.createConnection(PROXY_PORT_1, 'localhost');
const result = await new Promise<string>((resolve, reject) => { const result = await new Promise<string>((resolve, reject) => {
client.on('data', (data) => { client.on('data', (data) => {
@@ -52,6 +60,9 @@ tap.test('port forwarding should not immediately close connections', async (tool
}); });
expect(result).toEqual('ECHO: Hello'); expect(result).toEqual('ECHO: Hello');
// Stop proxy from test 1 before test 2 reassigns the variable
await proxy.stop();
}); });
tap.test('TLS passthrough should work correctly', async () => { tap.test('TLS passthrough should work correctly', async () => {
@@ -59,7 +70,7 @@ tap.test('TLS passthrough should work correctly', async () => {
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'tls-test', name: 'tls-test',
match: { ports: 8443, domains: 'test.example.com' }, match: { ports: PROXY_PORT_2, domains: 'test.example.com' },
action: { action: {
type: 'forward', type: 'forward',
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
@@ -85,16 +96,6 @@ tap.test('cleanup', async () => {
}); });
}); });
} }
if (proxy) {
await proxy.stop();
console.log('Proxy stopped');
}
}); });
export default tap.start().then(() => { export default tap.start();
// Force exit after tests complete
setTimeout(() => {
console.log('Forcing process exit');
process.exit(0);
}, 1000);
});

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '23.1.1', version: '23.1.4',
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.' 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.'
} }

View File

@@ -1,4 +1,4 @@
import * as net from 'net'; import * as net from 'node:net';
import { WrappedSocket } from './wrapped-socket.js'; import { WrappedSocket } from './wrapped-socket.js';
/** /**

View File

@@ -1,7 +1,7 @@
import { LifecycleComponent } from './lifecycle-component.js'; import { LifecycleComponent } from './lifecycle-component.js';
import { BinaryHeap } from './binary-heap.js'; import { BinaryHeap } from './binary-heap.js';
import { AsyncMutex } from './async-utils.js'; import { AsyncMutex } from './async-utils.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
/** /**
* Interface for pooled connection * Interface for pooled connection

View File

@@ -3,7 +3,7 @@
* Provides standardized socket cleanup with proper listener and timer management * Provides standardized socket cleanup with proper listener and timer management
*/ */
import type { Socket } from 'net'; import type { Socket } from 'node:net';
export type SocketTracked = { export type SocketTracked = {
cleanup: () => void; cleanup: () => void;

View File

@@ -1,13 +1,13 @@
// node native scope // node native scope
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
import * as fs from 'fs'; import * as fs from 'node:fs';
import * as http from 'http'; import * as http from 'node:http';
import * as https from 'https'; import * as https from 'node:https';
import * as net from 'net'; import * as net from 'node:net';
import * as path from 'path'; import * as path from 'node:path';
import * as tls from 'tls'; import * as tls from 'node:tls';
import * as url from 'url'; import * as url from 'node:url';
import * as http2 from 'http2'; import * as http2 from 'node:http2';
export { EventEmitter, fs, http, https, net, path, tls, url, http2 }; export { EventEmitter, fs, http, https, net, path, tls, url, http2 };

View File

@@ -5,7 +5,7 @@
* that may span multiple TCP packets. * that may span multiple TCP packets.
*/ */
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
/** /**
* Fragment tracking information * Fragment tracking information

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js';
import { import {
ClientHelloParser, ClientHelloParser,

View File

@@ -2,7 +2,7 @@
* WebSocket Protocol Utilities * WebSocket Protocol Utilities
*/ */
import * as crypto from 'crypto'; import * as crypto from 'node:crypto';
import { WEBSOCKET_MAGIC_STRING } from './constants.js'; import { WEBSOCKET_MAGIC_STRING } from './constants.js';
import type { RawData } from './types.js'; import type { RawData } from './types.js';

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,