import { expect, tap } from '@push.rocks/tapbundle'; import * as net from 'net'; import { PortProxy } from '../ts/classes.portproxy.js'; let testServer: net.Server; let portProxy: PortProxy; const TEST_SERVER_PORT = 4000; const PROXY_PORT = 4001; const TEST_DATA = 'Hello through port proxy!'; // Track all created servers and proxies for proper cleanup const allServers: net.Server[] = []; const allProxies: PortProxy[] = []; // Helper: Creates a test TCP server that listens on a given port and host. function createTestServer(port: number, host: string = 'localhost'): Promise { return new Promise((resolve) => { const server = net.createServer((socket) => { socket.on('data', (data) => { // Echo the received data back with a prefix. socket.write(`Echo: ${data.toString()}`); }); socket.on('error', (error) => { console.error(`[Test Server] Socket error on ${host}:${port}:`, error); }); }); server.listen(port, host, () => { console.log(`[Test Server] Listening on ${host}:${port}`); allServers.push(server); // Track this server resolve(server); }); }); } // Helper: Creates a test client connection. function createTestClient(port: number, data: string): Promise { return new Promise((resolve, reject) => { const client = new net.Socket(); let response = ''; const timeout = setTimeout(() => { client.destroy(); reject(new Error(`Client connection timeout to port ${port}`)); }, 5000); client.connect(port, 'localhost', () => { console.log('[Test Client] Connected to server'); client.write(data); }); client.on('data', (chunk) => { response += chunk.toString(); client.end(); }); client.on('end', () => { clearTimeout(timeout); resolve(response); }); client.on('error', (error) => { clearTimeout(timeout); reject(error); }); }); } // SETUP: Create a test server and a PortProxy instance. tap.test('setup port proxy test environment', async () => { testServer = await createTestServer(TEST_SERVER_PORT); portProxy = new PortProxy({ fromPort: PROXY_PORT, toPort: TEST_SERVER_PORT, targetIP: 'localhost', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1'], globalPortRanges: [] }); allProxies.push(portProxy); // Track this proxy }); // Test that the proxy starts and its servers are listening. tap.test('should start port proxy', async () => { await portProxy.start(); 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 () => { const response = await createTestClient(PROXY_PORT, 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 () => { const customHostProxy = new PortProxy({ fromPort: PROXY_PORT + 1, toPort: TEST_SERVER_PORT, targetIP: '127.0.0.1', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1'], globalPortRanges: [] }); allProxies.push(customHostProxy); // Track this proxy await customHostProxy.start(); const response = await createTestClient(PROXY_PORT + 1, TEST_DATA); expect(response).toEqual(`Echo: ${TEST_DATA}`); await customHostProxy.stop(); // Remove from tracking after stopping const index = allProxies.indexOf(customHostProxy); if (index !== -1) allProxies.splice(index, 1); }); // Test custom IP forwarding // Modified to work in Docker/CI environments without needing 127.0.0.2 tap.test('should forward connections to custom IP', async () => { // Set up ports that are FAR apart to avoid any possible confusion const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port // Create a test server listening on a unique port on 127.0.0.1 (works in all environments) const testServer2 = await createTestServer(targetServerPort, '127.0.0.1'); // We're simulating routing to a different IP by using a different port // This tests the core functionality without requiring multiple IPs const domainProxy = new PortProxy({ fromPort: forcedProxyPort, // 4003 - Listen on this port toPort: targetServerPort, // 4200 - Forward to this port targetIP: '127.0.0.1', // Always use localhost (works in Docker) domainConfigs: [], // No domain configs to confuse things sniEnabled: false, defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost // We'll test the functionality WITHOUT port ranges this time globalPortRanges: [] }); allProxies.push(domainProxy); // Track this proxy await domainProxy.start(); // Send a single test connection const response = await createTestClient(forcedProxyPort, TEST_DATA); expect(response).toEqual(`Echo: ${TEST_DATA}`); await domainProxy.stop(); // Remove from tracking after stopping const proxyIndex = allProxies.indexOf(domainProxy); if (proxyIndex !== -1) allProxies.splice(proxyIndex, 1); // Close the test server await new Promise((resolve) => testServer2.close(() => resolve())); // Remove from tracking const serverIndex = allServers.indexOf(testServer2); if (serverIndex !== -1) allServers.splice(serverIndex, 1); }); // Test handling of multiple concurrent connections. tap.test('should handle multiple concurrent connections', async () => { const concurrentRequests = 5; const requests = Array(concurrentRequests).fill(null).map((_, i) => createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`) ); const responses = await Promise.all(requests); responses.forEach((response, i) => { expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`); }); }); // Test connection timeout handling. tap.test('should handle connection timeouts', async () => { const client = new net.Socket(); await new Promise((resolve) => { // Add a timeout to ensure we don't hang here const timeout = setTimeout(() => { client.destroy(); resolve(); }, 3000); client.connect(PROXY_PORT, 'localhost', () => { // Do not send any data to trigger a timeout. client.on('close', () => { clearTimeout(timeout); resolve(); }); }); client.on('error', () => { clearTimeout(timeout); client.destroy(); resolve(); }); }); }); // Test stopping the port proxy. tap.test('should stop port proxy', async () => { await portProxy.stop(); expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue(); // Remove from tracking const index = allProxies.indexOf(portProxy); if (index !== -1) allProxies.splice(index, 1); }); // Test chained proxies with and without source IP preservation. tap.test('should support optional source IP preservation in chained proxies', async () => { // Chained proxies without IP preservation. const firstProxyDefault = new PortProxy({ fromPort: PROXY_PORT + 4, toPort: PROXY_PORT + 5, targetIP: 'localhost', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], globalPortRanges: [] }); const secondProxyDefault = new PortProxy({ fromPort: PROXY_PORT + 5, toPort: TEST_SERVER_PORT, targetIP: 'localhost', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], globalPortRanges: [] }); allProxies.push(firstProxyDefault, secondProxyDefault); // Track these proxies await secondProxyDefault.start(); await firstProxyDefault.start(); const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA); expect(response1).toEqual(`Echo: ${TEST_DATA}`); await firstProxyDefault.stop(); await secondProxyDefault.stop(); // Remove from tracking const index1 = allProxies.indexOf(firstProxyDefault); if (index1 !== -1) allProxies.splice(index1, 1); const index2 = allProxies.indexOf(secondProxyDefault); if (index2 !== -1) allProxies.splice(index2, 1); // Chained proxies with IP preservation. const firstProxyPreserved = new PortProxy({ fromPort: PROXY_PORT + 6, toPort: PROXY_PORT + 7, targetIP: 'localhost', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1'], preserveSourceIP: true, globalPortRanges: [] }); const secondProxyPreserved = new PortProxy({ fromPort: PROXY_PORT + 7, toPort: TEST_SERVER_PORT, targetIP: 'localhost', domainConfigs: [], sniEnabled: false, defaultAllowedIPs: ['127.0.0.1'], preserveSourceIP: true, globalPortRanges: [] }); allProxies.push(firstProxyPreserved, secondProxyPreserved); // Track these proxies await secondProxyPreserved.start(); await firstProxyPreserved.start(); const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA); expect(response2).toEqual(`Echo: ${TEST_DATA}`); await firstProxyPreserved.stop(); await secondProxyPreserved.stop(); // Remove from tracking const index3 = allProxies.indexOf(firstProxyPreserved); if (index3 !== -1) allProxies.splice(index3, 1); const index4 = allProxies.indexOf(secondProxyPreserved); if (index4 !== -1) allProxies.splice(index4, 1); }); // 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: [] }); // Don't track this proxy as it doesn't actually start or listen 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 all servers and proxies tap.test('cleanup port proxy test environment', async () => { // Stop all remaining proxies for (const proxy of [...allProxies]) { try { await proxy.stop(); const index = allProxies.indexOf(proxy); if (index !== -1) allProxies.splice(index, 1); } catch (err) { console.error(`Error stopping proxy: ${err}`); } } // Close all remaining servers for (const server of [...allServers]) { try { await new Promise((resolve) => { if (server.listening) { server.close(() => resolve()); } else { resolve(); } }); const index = allServers.indexOf(server); if (index !== -1) allServers.splice(index, 1); } catch (err) { console.error(`Error closing server: ${err}`); } } // Verify all resources are cleaned up expect(allProxies.length).toEqual(0); expect(allServers.length).toEqual(0); }); export default tap.start();