diff --git a/changelog.md b/changelog.md index b6b9d09..eb97715 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 2025-02-04 - 3.1.3 - fix(networkproxy) +Refactor and improve WebSocket handling and request processing + +- Improved error handling in WebSocket connection and request processing. +- Refactored the WebSocket handling in NetworkProxy to use a unified error logging mechanism. + ## 2025-02-04 - 3.1.2 - fix(core) Refactor certificate handling across the project diff --git a/test/test.ts b/test/test.ts index 111a485..4137411 100644 --- a/test/test.ts +++ b/test/test.ts @@ -14,19 +14,34 @@ let testCertificates: { privateKey: string; publicKey: string }; async function makeHttpsRequest( options: https.RequestOptions, ): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> { + console.log('[TEST] Making HTTPS request:', { + hostname: options.hostname, + port: options.port, + path: options.path, + method: options.method, + headers: options.headers, + }); return new Promise((resolve, reject) => { const req = https.request(options, (res) => { + console.log('[TEST] Received HTTPS response:', { + statusCode: res.statusCode, + headers: res.headers, + }); let data = ''; res.on('data', (chunk) => (data += chunk)); - res.on('end', () => + res.on('end', () => { + console.log('[TEST] Response completed:', { data }); resolve({ statusCode: res.statusCode!, headers: res.headers, body: data, - }), - ); + }); + }); + }); + req.on('error', (error) => { + console.error('[TEST] Request error:', error); + reject(error); }); - req.on('error', reject); req.end(); }); } @@ -37,12 +52,13 @@ tap.test('setup test environment', async () => { console.log('[TEST] Loading and validating certificates'); testCertificates = loadTestCertificates(); console.log('[TEST] Certificates loaded and validated'); + // Create a test HTTP server testServer = http.createServer((req, res) => { console.log('[TEST SERVER] Received HTTP request:', { url: req.url, method: req.method, - headers: req.headers + headers: req.headers, }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello from test server!'); @@ -59,8 +75,8 @@ tap.test('setup test environment', async () => { connection: request.headers.connection, 'sec-websocket-key': request.headers['sec-websocket-key'], 'sec-websocket-version': request.headers['sec-websocket-version'], - 'sec-websocket-protocol': request.headers['sec-websocket-protocol'] - } + 'sec-websocket-protocol': request.headers['sec-websocket-protocol'], + }, }); if (request.headers.upgrade?.toLowerCase() !== 'websocket') { @@ -76,13 +92,13 @@ tap.test('setup test environment', async () => { }); }); - // Create a WebSocket server + // Create a WebSocket server (for the test HTTP server) console.log('[TEST SERVER] Creating WebSocket server'); wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: false, clientTracking: true, - handleProtocols: () => 'echo-protocol' + handleProtocols: () => 'echo-protocol', }); wsServer.on('connection', (ws, request) => { @@ -94,8 +110,8 @@ tap.test('setup test environment', async () => { connection: request.headers.connection, 'sec-websocket-key': request.headers['sec-websocket-key'], 'sec-websocket-version': request.headers['sec-websocket-version'], - 'sec-websocket-protocol': request.headers['sec-websocket-protocol'] - } + 'sec-websocket-protocol': request.headers['sec-websocket-protocol'], + }, }); // Set up connection timeout @@ -132,7 +148,7 @@ tap.test('setup test environment', async () => { console.log('[TEST SERVER] WebSocket connection closed:', { code, reason: reason.toString(), - wasClean: code === 1000 || code === 1001 + wasClean: code === 1000 || code === 1001, }); clearConnectionTimeout(); }); @@ -175,30 +191,42 @@ tap.test('should create proxy instance', async () => { }); tap.test('should start the proxy server', async () => { + // Ensure any previous server is closed + if (testProxy && testProxy.httpsServer) { + await new Promise((resolve) => + testProxy.httpsServer.close(() => resolve()) + ); + } + console.log('[TEST] Starting the proxy server'); await testProxy.start(); console.log('[TEST] Proxy server started'); // Configure proxy with test certificates - testProxy.updateProxyConfigs([ + // Awaiting the update ensures that the SNI context is added before any requests come in. + await testProxy.updateProxyConfigs([ { destinationIp: '127.0.0.1', destinationPort: '3000', hostName: 'push.rocks', publicKey: testCertificates.publicKey, privateKey: testCertificates.privateKey, - } + }, ]); console.log('[TEST] Proxy configuration updated'); }); tap.test('should route HTTPS requests based on host header', async () => { + // IMPORTANT: Connect to localhost (where the proxy is listening) but use the Host header "push.rocks" const response = await makeHttpsRequest({ - hostname: 'push.rocks', + hostname: 'localhost', // changed from 'push.rocks' to 'localhost' port: 3001, path: '/', method: 'GET', + headers: { + host: 'push.rocks', // virtual host for routing + }, rejectUnauthorized: false, }); @@ -207,15 +235,21 @@ tap.test('should route HTTPS requests based on host header', async () => { }); tap.test('should handle unknown host headers', async () => { + // Connect to localhost but use an unknown host header. const response = await makeHttpsRequest({ - hostname: 'unknown.host', + hostname: 'localhost', // connecting to localhost port: 3001, path: '/', method: 'GET', + headers: { + host: 'unknown.host', // this should not match any proxy config + }, rejectUnauthorized: false, - }).catch((e) => e); + }); - expect(response instanceof Error).toEqual(true); + // Expect a 404 response with the appropriate error message. + expect(response.statusCode).toEqual(404); + expect(response.body).toEqual('This route is not available on this server.'); }); tap.test('should support WebSocket connections', async () => { @@ -224,39 +258,38 @@ tap.test('should support WebSocket connections', async () => { console.log('[TEST] Proxy server port:', 3001); console.log('\n[TEST] Starting WebSocket test'); - // First configure the proxy with test certificates - console.log('[TEST] Configuring proxy with test certificates'); - testProxy.updateProxyConfigs([ + // Reconfigure proxy with test certificates if necessary + await testProxy.updateProxyConfigs([ { destinationIp: '127.0.0.1', destinationPort: '3000', hostName: 'push.rocks', publicKey: testCertificates.publicKey, privateKey: testCertificates.privateKey, - } + }, ]); return new Promise((resolve, reject) => { console.log('[TEST] Creating WebSocket client'); - // Create WebSocket client with SSL/TLS options - const wsUrl = 'wss://push.rocks:3001'; + // IMPORTANT: Connect to localhost but specify the SNI servername and Host header as "push.rocks" + const wsUrl = 'wss://localhost:3001'; // changed from 'wss://push.rocks:3001' console.log('[TEST] Creating WebSocket connection to:', wsUrl); - + const ws = new WebSocket(wsUrl, { rejectUnauthorized: false, // Accept self-signed certificates handshakeTimeout: 5000, perMessageDeflate: false, headers: { - 'Host': 'push.rocks', - 'Connection': 'Upgrade', - 'Upgrade': 'websocket', - 'Sec-WebSocket-Version': '13' + Host: 'push.rocks', // required for SNI and routing on the proxy + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Version': '13', }, protocol: 'echo-protocol', agent: new https.Agent({ - rejectUnauthorized: false // Also needed for the underlying HTTPS connection - }) + rejectUnauthorized: false, // Also needed for the underlying HTTPS connection + }), }); console.log('[TEST] WebSocket client created'); @@ -286,7 +319,7 @@ tap.test('should support WebSocket connections', async () => { ws.on('upgrade', (response) => { console.log('[TEST] WebSocket upgrade response received:', { headers: response.headers, - statusCode: response.statusCode + statusCode: response.statusCode, }); }); @@ -304,7 +337,10 @@ tap.test('should support WebSocket connections', async () => { ws.on('message', (message) => { console.log('[TEST] Received message:', message.toString()); - if (message.toString() === 'Hello WebSocket') { + if ( + message.toString() === 'Hello WebSocket' || + message.toString() === 'Echo: Hello WebSocket' + ) { console.log('[TEST] Message received correctly'); clearTimeout(timeout); cleanup(); @@ -320,7 +356,7 @@ tap.test('should support WebSocket connections', async () => { ws.on('close', (code, reason) => { console.log('[TEST] WebSocket connection closed:', { code, - reason: reason.toString() + reason: reason.toString(), }); cleanup(); }); @@ -328,15 +364,18 @@ tap.test('should support WebSocket connections', async () => { }); tap.test('should handle custom headers', async () => { - testProxy.addDefaultHeaders({ + await testProxy.addDefaultHeaders({ 'X-Proxy-Header': 'test-value', }); const response = await makeHttpsRequest({ - hostname: 'push.rocks', + hostname: 'localhost', // changed to 'localhost' port: 3001, path: '/', method: 'GET', + headers: { + host: 'push.rocks', // still routing to push.rocks + }, rejectUnauthorized: false, }); @@ -353,16 +392,20 @@ tap.test('cleanup', async () => { }); console.log('[TEST] Closing WebSocket server'); - await new Promise((resolve) => wsServer.close(() => { - console.log('[TEST] WebSocket server closed'); - resolve(); - })); + await new Promise((resolve) => + wsServer.close(() => { + console.log('[TEST] WebSocket server closed'); + resolve(); + }) + ); console.log('[TEST] Closing test server'); - await new Promise((resolve) => testServer.close(() => { - console.log('[TEST] Test server closed'); - resolve(); - })); + await new Promise((resolve) => + testServer.close(() => { + console.log('[TEST] Test server closed'); + resolve(); + }) + ); console.log('[TEST] Stopping proxy'); await testProxy.stop(); @@ -376,4 +419,4 @@ process.on('exit', () => { testProxy.stop().then(() => console.log('[TEST] Proxy server stopped')); }); -tap.start(); +tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 59a58a2..20e8b6a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '3.1.2', + version: '3.1.3', description: 'a proxy for handling high workloads of proxying' } diff --git a/ts/smartproxy.classes.networkproxy.ts b/ts/smartproxy.classes.networkproxy.ts index a82b9ef..f8dfb78 100644 --- a/ts/smartproxy.classes.networkproxy.ts +++ b/ts/smartproxy.classes.networkproxy.ts @@ -8,12 +8,11 @@ export interface INetworkProxyOptions { port: number; } -interface WebSocketWithHeartbeat extends plugins.wsDefault { +interface IWebSocketWithHeartbeat extends plugins.wsDefault { lastPong: number; } export class NetworkProxy { - // INSTANCE public options: INetworkProxyOptions; public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = []; public httpsServer: plugins.https.Server; @@ -43,148 +42,23 @@ export class NetworkProxy { } } - /** - * starts the proxyInstance - */ public async start() { + // Instead of marking the callback async (which Node won't await), + // we call our async handler and catch errors. this.httpsServer = plugins.https.createServer( { key: this.defaultCertificates.key, cert: this.defaultCertificates.cert }, - async (originRequest, originResponse) => { - /** - * endRequest function - * can be used to prematurely end a request - */ - const endOriginReqRes = ( - statusArg: number = 404, - messageArg: string = 'This route is not available on this server.', - headers: plugins.http.OutgoingHttpHeaders = {}, - ) => { - originResponse.writeHead(statusArg, messageArg); - originResponse.end(messageArg); - if (originRequest.socket !== originResponse.socket) { - console.log('hey, something is strange.'); - } - originResponse.destroy(); - }; - - console.log( - `got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`, - ); - const destinationConfig = this.router.routeReq(originRequest); - - if (!destinationConfig) { - console.log( - `${originRequest.headers.host} can't be routed properly. Terminating request.`, - ); - endOriginReqRes(); - return; - } - - // authentication - if (destinationConfig.authentication) { - const authInfo = destinationConfig.authentication; - switch (authInfo.type) { - case 'Basic': - const authHeader = originRequest.headers.authorization; - if (authHeader) { - if (!authHeader.includes('Basic ')) { - return endOriginReqRes(401, 'Authentication required', { - 'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"', - }); - } - const authStringBase64 = originRequest.headers.authorization.replace('Basic ', ''); - const authString: string = plugins.smartstring.base64.decode(authStringBase64); - const userPassArray = authString.split(':'); - const user = userPassArray[0]; - const pass = userPassArray[1]; - if (user === authInfo.user && pass === authInfo.pass) { - console.log('request successfully authenticated'); - } else { - return endOriginReqRes(403, 'Forbidden: Wrong credentials'); - } - } - break; - default: - return endOriginReqRes( - 403, - 'Forbidden: unsupported authentication method configured. Please report to the admin.', - ); - } - } - - let destinationUrl: string; - if (destinationConfig) { - destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`; - } else { - return endOriginReqRes(); - } - console.log(destinationUrl); - try { - const proxyResponse = await plugins.smartrequest.request( - destinationUrl, - { - method: originRequest.method, - headers: { - ...originRequest.headers, - 'X-Forwarded-Host': originRequest.headers.host, - 'X-Forwarded-Proto': 'https', - }, - keepAlive: true, - }, - true, // lets make this streaming (keepAlive) - (proxyRequest) => { - originRequest.on('data', (data) => { - proxyRequest.write(data); - }); - originRequest.on('end', () => { - proxyRequest.end(); - }); - originRequest.on('error', () => { - proxyRequest.end(); - }); - originRequest.on('close', () => { - proxyRequest.end(); - }); - originRequest.on('timeout', () => { - proxyRequest.end(); - originRequest.destroy(); - }); - proxyRequest.on('error', () => { - endOriginReqRes(); - }); - }, - ); - originResponse.statusCode = proxyResponse.statusCode; - console.log(proxyResponse.statusCode); - for (const defaultHeader of Object.keys(this.defaultHeaders)) { - originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]); - } - for (const header of Object.keys(proxyResponse.headers)) { - originResponse.setHeader(header, proxyResponse.headers[header]); - } - proxyResponse.on('data', (data) => { - originResponse.write(data); - }); - proxyResponse.on('end', () => { + (originRequest, originResponse) => { + this.handleRequest(originRequest, originResponse).catch((error) => { + console.error('Unhandled error in request handler:', error); + try { originResponse.end(); - }); - proxyResponse.on('error', () => { - originResponse.destroy(); - }); - proxyResponse.on('close', () => { - originResponse.end(); - }); - proxyResponse.on('timeout', () => { - originResponse.end(); - originResponse.destroy(); - }); - } catch (error) { - console.error('Error while processing request:', error); - endOriginReqRes(502, 'Bad Gateway: Error processing the request'); - } + } catch (err) { + // ignore errors during cleanup + } + }); }, ); @@ -194,7 +68,7 @@ export class NetworkProxy { // Set up the heartbeat interval this.heartbeatInterval = setInterval(() => { wsServer.clients.forEach((ws: plugins.wsDefault) => { - const wsIncoming = ws as WebSocketWithHeartbeat; + const wsIncoming = ws as IWebSocketWithHeartbeat; if (!wsIncoming.lastPong) { wsIncoming.lastPong = Date.now(); } @@ -209,7 +83,7 @@ export class NetworkProxy { wsServer.on( 'connection', - async (wsIncoming: WebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => { + (wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => { console.log( `wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`, ); @@ -220,21 +94,24 @@ export class NetworkProxy { }); let wsOutgoing: plugins.wsDefault; - const outGoingDeferred = plugins.smartpromise.defer(); + // --- Improvement 2: Only call routeReq once --- + const wsDestinationConfig = this.router.routeReq(reqArg); + if (!wsDestinationConfig) { + wsIncoming.terminate(); + return; + } try { wsOutgoing = new plugins.wsDefault( - `ws://${this.router.routeReq(reqArg).destinationIp}:${ - this.router.routeReq(reqArg).destinationPort - }${reqArg.url}`, + `ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`, ); console.log('wss proxy: initiated outgoing proxy'); wsOutgoing.on('open', async () => { outGoingDeferred.resolve(); }); } catch (err) { - console.log(err); + console.error('Error initiating outgoing WebSocket:', err); wsIncoming.terminate(); return; } @@ -259,20 +136,20 @@ export class NetworkProxy { const terminateWsOutgoing = () => { if (wsOutgoing) { wsOutgoing.terminate(); - console.log('terminated outgoing ws.'); + console.log('Terminated outgoing ws.'); } }; - wsIncoming.on('error', () => terminateWsOutgoing()); - wsIncoming.on('close', () => terminateWsOutgoing()); + wsIncoming.on('error', terminateWsOutgoing); + wsIncoming.on('close', terminateWsOutgoing); const terminateWsIncoming = () => { if (wsIncoming) { wsIncoming.terminate(); - console.log('terminated incoming ws.'); + console.log('Terminated incoming ws.'); } }; - wsOutgoing.on('error', () => terminateWsIncoming()); - wsOutgoing.on('close', () => terminateWsIncoming()); + wsOutgoing.on('error', terminateWsIncoming); + wsOutgoing.on('close', terminateWsIncoming); }, ); @@ -281,26 +158,18 @@ export class NetworkProxy { this.httpsServer.on('connection', (connection: plugins.net.Socket) => { this.socketMap.add(connection); - console.log(`added connection. now ${this.socketMap.getArray().length} sockets connected.`); + console.log(`Added connection. Now ${this.socketMap.getArray().length} sockets connected.`); const cleanupConnection = () => { if (this.socketMap.checkForObject(connection)) { this.socketMap.remove(connection); - console.log(`removed connection. ${this.socketMap.getArray().length} sockets remaining.`); + console.log(`Removed connection. ${this.socketMap.getArray().length} sockets remaining.`); connection.destroy(); } }; - connection.on('close', () => { - cleanupConnection(); - }); - connection.on('error', () => { - cleanupConnection(); - }); - connection.on('end', () => { - cleanupConnection(); - }); - connection.on('timeout', () => { - cleanupConnection(); - }); + connection.on('close', cleanupConnection); + connection.on('error', cleanupConnection); + connection.on('end', cleanupConnection); + connection.on('timeout', cleanupConnection); }); this.httpsServer.listen(this.options.port); @@ -309,7 +178,150 @@ export class NetworkProxy { ); } - public async updateProxyConfigs(proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]) { + /** + * Internal async handler for processing HTTP/HTTPS requests. + */ + private async handleRequest( + originRequest: plugins.http.IncomingMessage, + originResponse: plugins.http.ServerResponse, + ): Promise { + const endOriginReqRes = ( + statusArg: number = 404, + messageArg: string = 'This route is not available on this server.', + headers: plugins.http.OutgoingHttpHeaders = {}, + ) => { + originResponse.writeHead(statusArg, messageArg); + originResponse.end(messageArg); + if (originRequest.socket !== originResponse.socket) { + console.log('hey, something is strange.'); + } + originResponse.destroy(); + }; + + console.log( + `got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`, + ); + const destinationConfig = this.router.routeReq(originRequest); + + if (!destinationConfig) { + console.log( + `${originRequest.headers.host} can't be routed properly. Terminating request.`, + ); + endOriginReqRes(); + return; + } + + // authentication + if (destinationConfig.authentication) { + const authInfo = destinationConfig.authentication; + switch (authInfo.type) { + case 'Basic': { + const authHeader = originRequest.headers.authorization; + if (!authHeader) { + return endOriginReqRes(401, 'Authentication required', { + 'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"', + }); + } + if (!authHeader.includes('Basic ')) { + return endOriginReqRes(401, 'Authentication required', { + 'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"', + }); + } + const authStringBase64 = authHeader.replace('Basic ', ''); + const authString: string = plugins.smartstring.base64.decode(authStringBase64); + const userPassArray = authString.split(':'); + const user = userPassArray[0]; + const pass = userPassArray[1]; + if (user === authInfo.user && pass === authInfo.pass) { + console.log('Request successfully authenticated'); + } else { + return endOriginReqRes(403, 'Forbidden: Wrong credentials'); + } + break; + } + default: + return endOriginReqRes( + 403, + 'Forbidden: unsupported authentication method configured. Please report to the admin.', + ); + } + } + + let destinationUrl: string; + if (destinationConfig) { + destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`; + } else { + return endOriginReqRes(); + } + console.log(destinationUrl); + try { + const proxyResponse = await plugins.smartrequest.request( + destinationUrl, + { + method: originRequest.method, + headers: { + ...originRequest.headers, + 'X-Forwarded-Host': originRequest.headers.host, + 'X-Forwarded-Proto': 'https', + }, + keepAlive: true, + }, + true, // streaming (keepAlive) + (proxyRequest) => { + originRequest.on('data', (data) => { + proxyRequest.write(data); + }); + originRequest.on('end', () => { + proxyRequest.end(); + }); + originRequest.on('error', () => { + proxyRequest.end(); + }); + originRequest.on('close', () => { + proxyRequest.end(); + }); + originRequest.on('timeout', () => { + proxyRequest.end(); + originRequest.destroy(); + }); + proxyRequest.on('error', () => { + endOriginReqRes(); + }); + }, + ); + originResponse.statusCode = proxyResponse.statusCode; + console.log(proxyResponse.statusCode); + for (const defaultHeader of Object.keys(this.defaultHeaders)) { + originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]); + } + for (const header of Object.keys(proxyResponse.headers)) { + originResponse.setHeader(header, proxyResponse.headers[header]); + } + proxyResponse.on('data', (data) => { + originResponse.write(data); + }); + proxyResponse.on('end', () => { + originResponse.end(); + }); + proxyResponse.on('error', () => { + originResponse.destroy(); + }); + proxyResponse.on('close', () => { + originResponse.end(); + }); + proxyResponse.on('timeout', () => { + originResponse.end(); + originResponse.destroy(); + }); + } catch (error) { + console.error('Error while processing request:', error); + endOriginReqRes(502, 'Bad Gateway: Error processing the request'); + } + } + + public async updateProxyConfigs( + proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[], + ) { console.log(`got new proxy configs`); this.proxyConfigs = proxyConfigsArg; this.router.setNewProxyConfigs(proxyConfigsArg); @@ -347,11 +359,11 @@ export class NetworkProxy { this.httpsServer.close(() => { done.resolve(); }); - await this.socketMap.forEach(async (socket) => { + for (const socket of this.socketMap.getArray()) { socket.destroy(); - }); + } await done.promise; clearInterval(this.heartbeatInterval); console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.'); } -} +} \ No newline at end of file