fix(PortProxy): Refactor and enhance PortProxy test cases and handling

This commit is contained in:
Philipp Kunz 2025-02-27 21:19:34 +00:00
parent 622ad2ff20
commit 5d6b707440
4 changed files with 111 additions and 100 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## 2025-02-27 - 3.18.1 - fix(PortProxy)
Refactor and enhance PortProxy test cases and handling
- Refactored test cases in test/test.portproxy.ts for clarity and added coverage.
- Improved TCP server helper functions for better flexibility.
- Fixed issues with domain handling in PortProxy configuration.
- Introduced round-robin logic for multi-IP domains in PortProxy.
- Ensured proper cleanup and stopping of test servers in the test suite.
## 2025-02-27 - 3.18.0 - feat(PortProxy) ## 2025-02-27 - 3.18.0 - feat(PortProxy)
Add SNI-based renegotiation handling in PortProxy Add SNI-based renegotiation handling in PortProxy

View File

@ -8,31 +8,30 @@ const TEST_SERVER_PORT = 4000;
const PROXY_PORT = 4001; const PROXY_PORT = 4001;
const TEST_DATA = 'Hello through port proxy!'; const TEST_DATA = 'Hello through port proxy!';
// Helper function to create a test TCP server // Helper: Creates a test TCP server that listens on a given port and host.
function createTestServer(port: number): Promise<net.Server> { function createTestServer(port: number, host: string = 'localhost'): Promise<net.Server> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
// Echo the received data back // Echo the received data back with a prefix.
socket.write(`Echo: ${data.toString()}`); socket.write(`Echo: ${data.toString()}`);
}); });
socket.on('error', (error) => { socket.on('error', (error) => {
console.error('[Test Server] Socket error:', error); console.error(`[Test Server] Socket error on ${host}:${port}:`, error);
}); });
}); });
server.listen(port, () => { server.listen(port, host, () => {
console.log(`[Test Server] Listening on port ${port}`); console.log(`[Test Server] Listening on ${host}:${port}`);
resolve(server); resolve(server);
}); });
}); });
} }
// Helper function to create a test client connection // Helper: Creates a test client connection.
function createTestClient(port: number, data: string): Promise<string> { function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = new net.Socket(); const client = new net.Socket();
let response = ''; let response = '';
client.connect(port, 'localhost', () => { client.connect(port, 'localhost', () => {
console.log('[Test Client] Connected to server'); console.log('[Test Client] Connected to server');
client.write(data); client.write(data);
@ -41,47 +40,44 @@ function createTestClient(port: number, data: string): Promise<string> {
response += chunk.toString(); response += chunk.toString();
client.end(); client.end();
}); });
client.on('end', () => { client.on('end', () => resolve(response));
resolve(response); client.on('error', (error) => reject(error));
});
client.on('error', (error) => {
reject(error);
});
}); });
} }
// Setup test environment // SETUP: Create a test server and a PortProxy instance.
tap.test('setup port proxy test environment', async () => { tap.test('setup port proxy test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT); testServer = await createTestServer(TEST_SERVER_PORT);
portProxy = new PortProxy({ portProxy = new PortProxy({
fromPort: PROXY_PORT, fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
targetIP: 'localhost', targetIP: 'localhost',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: [] globalPortRanges: []
}); });
}); });
// Test that the proxy starts and its servers are listening.
tap.test('should start port proxy', async () => { tap.test('should start port proxy', async () => {
await portProxy.start(); await portProxy.start();
// Since netServers is private, we cast to any to verify that all created servers are listening.
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue(); expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
}); });
// Test basic TCP forwarding.
tap.test('should forward TCP connections and data to localhost', async () => { tap.test('should forward TCP connections and data to localhost', async () => {
const response = await createTestClient(PROXY_PORT, TEST_DATA); const response = await createTestClient(PROXY_PORT, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`); expect(response).toEqual(`Echo: ${TEST_DATA}`);
}); });
// Test proxy with a custom target host.
tap.test('should forward TCP connections to custom host', async () => { tap.test('should forward TCP connections to custom host', async () => {
// Create a new proxy instance with a custom host (targetIP)
const customHostProxy = new PortProxy({ const customHostProxy = new PortProxy({
fromPort: PROXY_PORT + 1, fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
targetIP: '127.0.0.1', targetIP: '127.0.0.1',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: [] globalPortRanges: []
@ -93,153 +89,151 @@ tap.test('should forward TCP connections to custom host', async () => {
await customHostProxy.stop(); await customHostProxy.stop();
}); });
tap.test('should forward connections based on domain-specific target IP', async () => { // Test forced domain routing via port-range configuration.
// Create a second test server on a different port // In this test, we want to forward to a different IP (using '127.0.0.2')
const TEST_SERVER_PORT_2 = TEST_SERVER_PORT + 100; // while keeping the same port. We create a test server on '127.0.0.2'.
const testServer2 = await createTestServer(TEST_SERVER_PORT_2); tap.test('should forward connections based on domain-specific target IP (forced domain via port-range)', async () => {
const forcedProxyPort = PROXY_PORT + 2;
// Create a test server listening on '127.0.0.2' at forcedProxyPort.
const testServer2 = await createTestServer(forcedProxyPort, '127.0.0.2');
// Create a proxy with domain-specific target IPs
const domainProxy = new PortProxy({ const domainProxy = new PortProxy({
fromPort: PROXY_PORT + 2, fromPort: forcedProxyPort,
toPort: TEST_SERVER_PORT, // default port (for non-port-range handling) toPort: TEST_SERVER_PORT, // default target port (unused for forced domain)
targetIP: 'localhost', // default target IP targetIP: 'localhost',
domains: [{ domainConfigs: [{
domain: 'domain1.test', domains: ['forced.test'],
allowedIPs: ['127.0.0.1'], allowedIPs: ['127.0.0.1'],
targetIP: '127.0.0.1' targetIPs: ['127.0.0.2'], // Use a different IP than the default.
}, { portRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
domain: 'domain2.test',
allowedIPs: ['127.0.0.1'],
targetIP: 'localhost'
}], }],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: [] globalPortRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
}); });
await domainProxy.start(); await domainProxy.start();
// Test default connection (should use default targetIP) // When connecting to forcedProxyPort, forced domain handling triggers,
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA); // so the proxy will connect to '127.0.0.2' on the same port.
expect(response1).toEqual(`Echo: ${TEST_DATA}`); const response = await createTestClient(forcedProxyPort, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
// Create another proxy with a different default targetIP
const domainProxy2 = new PortProxy({
fromPort: PROXY_PORT + 3,
toPort: TEST_SERVER_PORT,
targetIP: '127.0.0.1',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await domainProxy2.start();
const response2 = await createTestClient(PROXY_PORT + 3, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await domainProxy.stop(); await domainProxy.stop();
await domainProxy2.stop();
await new Promise<void>((resolve) => testServer2.close(() => resolve())); await new Promise<void>((resolve) => testServer2.close(() => resolve()));
}); });
// Test handling of multiple concurrent connections.
tap.test('should handle multiple concurrent connections', async () => { tap.test('should handle multiple concurrent connections', async () => {
const concurrentRequests = 5; const concurrentRequests = 5;
const requests = Array(concurrentRequests).fill(null).map((_, i) => const requests = Array(concurrentRequests).fill(null).map((_, i) =>
createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`) createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`)
); );
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
responses.forEach((response, i) => { responses.forEach((response, i) => {
expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`); expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`);
}); });
}); });
// Test connection timeout handling.
tap.test('should handle connection timeouts', async () => { tap.test('should handle connection timeouts', async () => {
const client = new net.Socket(); const client = new net.Socket();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
client.connect(PROXY_PORT, 'localhost', () => { client.connect(PROXY_PORT, 'localhost', () => {
// Don't send any data, just wait for timeout // Do not send any data to trigger a timeout.
client.on('close', () => { client.on('close', () => resolve());
resolve();
});
}); });
}); });
}); });
// Test stopping the port proxy.
tap.test('should stop port proxy', async () => { tap.test('should stop port proxy', async () => {
await portProxy.stop(); await portProxy.stop();
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue(); expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
}); });
// Cleanup chained proxies tests // Test chained proxies with and without source IP preservation.
tap.test('should support optional source IP preservation in chained proxies', async () => { tap.test('should support optional source IP preservation in chained proxies', async () => {
// Test 1: Without IP preservation (default behavior) // Chained proxies without IP preservation.
const firstProxyDefault = new PortProxy({ const firstProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 4, fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5, toPort: PROXY_PORT + 5,
targetIP: 'localhost', targetIP: 'localhost',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: [] globalPortRanges: []
}); });
const secondProxyDefault = new PortProxy({ const secondProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 5, fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
targetIP: 'localhost', targetIP: 'localhost',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: [] globalPortRanges: []
}); });
await secondProxyDefault.start(); await secondProxyDefault.start();
await firstProxyDefault.start(); await firstProxyDefault.start();
// This should work because we explicitly allow both IPv4 and IPv6 formats
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA); const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`); expect(response1).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyDefault.stop(); await firstProxyDefault.stop();
await secondProxyDefault.stop(); await secondProxyDefault.stop();
// Test 2: With IP preservation // Chained proxies with IP preservation.
const firstProxyPreserved = new PortProxy({ const firstProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 6, fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7, toPort: PROXY_PORT + 7,
targetIP: 'localhost', targetIP: 'localhost',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true, preserveSourceIP: true,
globalPortRanges: [] globalPortRanges: []
}); });
const secondProxyPreserved = new PortProxy({ const secondProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 7, fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
targetIP: 'localhost', targetIP: 'localhost',
domains: [], domainConfigs: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true, preserveSourceIP: true,
globalPortRanges: [] globalPortRanges: []
}); });
await secondProxyPreserved.start(); await secondProxyPreserved.start();
await firstProxyPreserved.start(); await firstProxyPreserved.start();
// This should work with just IPv4 because source IP is preserved
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA); const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`); expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyPreserved.stop(); await firstProxyPreserved.stop();
await secondProxyPreserved.stop(); await secondProxyPreserved.stop();
}); });
// Test round-robin behavior for multiple target IPs in a domain config.
tap.test('should use round robin for multiple target IPs in domain config', async () => {
const domainConfig = {
domains: ['rr.test'],
allowedIPs: ['127.0.0.1'],
targetIPs: ['hostA', 'hostB']
} as any;
const proxyInstance = new PortProxy({
fromPort: 0,
toPort: 0,
targetIP: 'localhost',
domainConfigs: [domainConfig],
sniEnabled: false,
defaultAllowedIPs: [],
globalPortRanges: []
});
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
expect(firstTarget).toEqual('hostA');
expect(secondTarget).toEqual('hostB');
});
// CLEANUP: Tear down the test server.
tap.test('cleanup port proxy test environment', async () => { tap.test('cleanup port proxy test environment', async () => {
await new Promise<void>((resolve) => testServer.close(() => resolve())); await new Promise<void>((resolve) => testServer.close(() => resolve()));
}); });
@ -248,7 +242,6 @@ process.on('exit', () => {
if (testServer) { if (testServer) {
testServer.close(); testServer.close();
} }
// Use a cast to access the private property for cleanup.
if (portProxy && (portProxy as any).netServers) { if (portProxy && (portProxy as any).netServers) {
portProxy.stop(); portProxy.stop();
} }

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '3.18.0', version: '3.18.1',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
} }

View File

@ -2,10 +2,10 @@ import * as plugins from './plugins.js';
/** Domain configuration with perdomain allowed port ranges */ /** Domain configuration with perdomain allowed port ranges */
export interface IDomainConfig { export interface IDomainConfig {
domain: string | string[]; // Glob pattern or patterns for domain(s) domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
portRanges?: Array<{ from: number; to: number }>; // Optional domain-specific allowed port ranges portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
} }
/** Port proxy settings including global allowed port ranges */ /** Port proxy settings including global allowed port ranges */
@ -13,7 +13,7 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number; fromPort: number;
toPort: number; toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost' targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domains: IDomainConfig[]; domainConfigs: IDomainConfig[];
sniEnabled?: boolean; sniEnabled?: boolean;
defaultAllowedIPs?: string[]; defaultAllowedIPs?: string[];
preserveSourceIP?: boolean; preserveSourceIP?: boolean;
@ -102,6 +102,9 @@ export class PortProxy {
private connectionRecords: Set<IConnectionRecord> = new Set(); private connectionRecords: Set<IConnectionRecord> = new Set();
private connectionLogger: NodeJS.Timeout | null = null; private connectionLogger: NodeJS.Timeout | null = null;
// Map to track round robin indices for each domain config.
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
private terminationStats: { private terminationStats: {
incoming: Record<string, number>; incoming: Record<string, number>;
outgoing: Record<string, number>; outgoing: Record<string, number>;
@ -122,6 +125,16 @@ export class PortProxy {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1; this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
} }
private getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP!;
}
public async start() { public async start() {
// Define a unified connection handler for all listening ports. // Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => { const connectionHandler = (socket: plugins.net.Socket) => {
@ -214,18 +227,14 @@ export class PortProxy {
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup. // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain const domainConfig = forcedDomain
? forcedDomain ? forcedDomain
: (serverName ? this.settings.domains.find(config => { : (serverName ? this.settings.domainConfigs.find(config =>
if (typeof config.domain === 'string') { config.domains.some(d => plugins.minimatch(serverName, d))
return plugins.minimatch(serverName, config.domain); ) : undefined);
} else {
return config.domain.some(d => plugins.minimatch(serverName, d));
}
}) : undefined);
// If a matching domain config exists, check its allowedIPs. // If a matching domain config exists, check its allowedIPs.
if (domainConfig) { if (domainConfig) {
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) { if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${Array.isArray(domainConfig.domain) ? domainConfig.domain.join(', ') : domainConfig.domain}`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
} }
} else if (this.settings.defaultAllowedIPs) { } else if (this.settings.defaultAllowedIPs) {
// Only check default allowed IPs if no domain config matched. // Only check default allowed IPs if no domain config matched.
@ -233,7 +242,7 @@ export class PortProxy {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
} }
} }
const targetHost = domainConfig?.targetIP || this.settings.targetIP!; const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = { const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost, host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort, port: overridePort !== undefined ? overridePort : this.settings.toPort,
@ -248,7 +257,7 @@ export class PortProxy {
console.log( console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` + `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain})` : ''}` `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
); );
if (initialChunk) { if (initialChunk) {
@ -330,24 +339,24 @@ export class PortProxy {
} }
console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`); console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
setupConnection('', undefined, { setupConnection('', undefined, {
domain: 'global', domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [], allowedIPs: this.settings.defaultAllowedIPs || [],
targetIP: this.settings.targetIP, targetIPs: [this.settings.targetIP!],
portRanges: [] portRanges: []
}, localPort); }, localPort);
return; return;
} else { } else {
// Attempt to find a matching forced domain config based on the local port. // Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domains.find( const forcedDomain = this.settings.domainConfigs.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges) domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
); );
if (forcedDomain) { if (forcedDomain) {
if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) { if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain} on port ${localPort}.`); console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
socket.end(); socket.end();
return; return;
} }
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain}.`); console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
setupConnection('', undefined, forcedDomain, localPort); setupConnection('', undefined, forcedDomain, localPort);
return; return;
} }