@push.rocks/smartproxy 3.23.1 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,28 +6,76 @@ import { fileURLToPath } from 'url';
6
6
 
7
7
  export interface INetworkProxyOptions {
8
8
  port: number;
9
+ maxConnections?: number;
10
+ keepAliveTimeout?: number;
11
+ headersTimeout?: number;
12
+ logLevel?: 'error' | 'warn' | 'info' | 'debug';
13
+ cors?: {
14
+ allowOrigin?: string;
15
+ allowMethods?: string;
16
+ allowHeaders?: string;
17
+ maxAge?: number;
18
+ };
9
19
  }
10
20
 
11
21
  interface IWebSocketWithHeartbeat extends plugins.wsDefault {
12
22
  lastPong: number;
23
+ isAlive: boolean;
13
24
  }
14
25
 
15
26
  export class NetworkProxy {
27
+ // Configuration
16
28
  public options: INetworkProxyOptions;
17
29
  public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
30
+ public defaultHeaders: { [key: string]: string } = {};
31
+
32
+ // Server instances
18
33
  public httpsServer: plugins.https.Server;
34
+ public wsServer: plugins.ws.WebSocketServer;
35
+
36
+ // State tracking
19
37
  public router = new ProxyRouter();
20
38
  public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
21
- public defaultHeaders: { [key: string]: string } = {};
22
- public heartbeatInterval: NodeJS.Timeout;
39
+ public activeContexts: Set<string> = new Set();
40
+ public connectedClients: number = 0;
41
+ public startTime: number = 0;
42
+ public requestsServed: number = 0;
43
+ public failedRequests: number = 0;
44
+
45
+ // Timers and intervals
46
+ private heartbeatInterval: NodeJS.Timeout;
47
+ private metricsInterval: NodeJS.Timeout;
48
+
49
+ // Certificates
23
50
  private defaultCertificates: { key: string; cert: string };
51
+ private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
24
52
 
25
- public alreadyAddedReverseConfigs: {
26
- [hostName: string]: plugins.tsclass.network.IReverseProxyConfig;
27
- } = {};
28
-
53
+ /**
54
+ * Creates a new NetworkProxy instance
55
+ */
29
56
  constructor(optionsArg: INetworkProxyOptions) {
30
- this.options = optionsArg;
57
+ // Set default options
58
+ this.options = {
59
+ port: optionsArg.port,
60
+ maxConnections: optionsArg.maxConnections || 10000,
61
+ keepAliveTimeout: optionsArg.keepAliveTimeout || 120000, // 2 minutes
62
+ headersTimeout: optionsArg.headersTimeout || 60000, // 1 minute
63
+ logLevel: optionsArg.logLevel || 'info',
64
+ cors: optionsArg.cors || {
65
+ allowOrigin: '*',
66
+ allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
67
+ allowHeaders: 'Content-Type, Authorization',
68
+ maxAge: 86400
69
+ }
70
+ };
71
+
72
+ this.loadDefaultCertificates();
73
+ }
74
+
75
+ /**
76
+ * Loads default certificates from the filesystem
77
+ */
78
+ private loadDefaultCertificates(): void {
31
79
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
80
  const certPath = path.join(__dirname, '..', 'assets', 'certs');
33
81
 
@@ -36,334 +84,761 @@ export class NetworkProxy {
36
84
  key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
37
85
  cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
38
86
  };
87
+ this.log('info', 'Default certificates loaded successfully');
39
88
  } catch (error) {
40
- console.error('Error loading certificates:', error);
41
- throw error;
89
+ this.log('error', 'Error loading default certificates', error);
90
+
91
+ // Generate self-signed fallback certificates
92
+ try {
93
+ // This is a placeholder for actual certificate generation code
94
+ // In a real implementation, you would use a library like selfsigned to generate certs
95
+ this.defaultCertificates = {
96
+ key: "FALLBACK_KEY_CONTENT",
97
+ cert: "FALLBACK_CERT_CONTENT"
98
+ };
99
+ this.log('warn', 'Using fallback self-signed certificates');
100
+ } catch (fallbackError) {
101
+ this.log('error', 'Failed to generate fallback certificates', fallbackError);
102
+ throw new Error('Could not load or generate SSL certificates');
103
+ }
42
104
  }
43
105
  }
44
106
 
45
- public async start() {
46
- // Instead of marking the callback async (which Node won't await),
47
- // we call our async handler and catch errors.
107
+ /**
108
+ * Starts the proxy server
109
+ */
110
+ public async start(): Promise<void> {
111
+ this.startTime = Date.now();
112
+
113
+ // Create the HTTPS server
48
114
  this.httpsServer = plugins.https.createServer(
49
115
  {
50
116
  key: this.defaultCertificates.key,
51
117
  cert: this.defaultCertificates.cert
52
118
  },
53
- (originRequest, originResponse) => {
54
- this.handleRequest(originRequest, originResponse).catch((error) => {
55
- console.error('Unhandled error in request handler:', error);
56
- try {
57
- originResponse.end();
58
- } catch (err) {
59
- // ignore errors during cleanup
60
- }
61
- });
62
- },
119
+ (req, res) => this.handleRequest(req, res)
63
120
  );
64
121
 
65
- // Enable websockets
66
- const wsServer = new plugins.ws.WebSocketServer({ server: this.httpsServer });
122
+ // Configure server timeouts
123
+ this.httpsServer.keepAliveTimeout = this.options.keepAliveTimeout;
124
+ this.httpsServer.headersTimeout = this.options.headersTimeout;
125
+
126
+ // Setup connection tracking
127
+ this.setupConnectionTracking();
128
+
129
+ // Setup WebSocket support
130
+ this.setupWebsocketSupport();
131
+
132
+ // Start metrics collection
133
+ this.setupMetricsCollection();
67
134
 
68
- // Set up the heartbeat interval
69
- this.heartbeatInterval = setInterval(() => {
70
- wsServer.clients.forEach((ws: plugins.wsDefault) => {
71
- const wsIncoming = ws as IWebSocketWithHeartbeat;
72
- if (!wsIncoming.lastPong) {
73
- wsIncoming.lastPong = Date.now();
74
- }
75
- if (Date.now() - wsIncoming.lastPong > 5 * 60 * 1000) {
76
- console.log('Terminating websocket due to missing pong for 5 minutes.');
77
- wsIncoming.terminate();
78
- } else {
79
- wsIncoming.ping();
135
+ // Start the server
136
+ return new Promise((resolve) => {
137
+ this.httpsServer.listen(this.options.port, () => {
138
+ this.log('info', `NetworkProxy started on port ${this.options.port}`);
139
+ resolve();
140
+ });
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Sets up tracking of TCP connections
146
+ */
147
+ private setupConnectionTracking(): void {
148
+ this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
149
+ // Check if max connections reached
150
+ if (this.socketMap.getArray().length >= this.options.maxConnections) {
151
+ this.log('warn', `Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
152
+ connection.destroy();
153
+ return;
154
+ }
155
+
156
+ // Add connection to tracking
157
+ this.socketMap.add(connection);
158
+ this.connectedClients = this.socketMap.getArray().length;
159
+ this.log('debug', `New connection. Currently ${this.connectedClients} active connections`);
160
+
161
+ // Setup connection cleanup handlers
162
+ const cleanupConnection = () => {
163
+ if (this.socketMap.checkForObject(connection)) {
164
+ this.socketMap.remove(connection);
165
+ this.connectedClients = this.socketMap.getArray().length;
166
+ this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
80
167
  }
168
+ };
169
+
170
+ connection.on('close', cleanupConnection);
171
+ connection.on('error', (err) => {
172
+ this.log('debug', 'Connection error', err);
173
+ cleanupConnection();
81
174
  });
82
- }, 60000); // runs every 1 minute
83
-
84
- wsServer.on(
85
- 'connection',
86
- (wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
87
- console.log(
88
- `wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`,
89
- );
90
-
91
- wsIncoming.lastPong = Date.now();
92
- wsIncoming.on('pong', () => {
93
- wsIncoming.lastPong = Date.now();
94
- });
175
+ connection.on('end', cleanupConnection);
176
+ connection.on('timeout', () => {
177
+ this.log('debug', 'Connection timeout');
178
+ cleanupConnection();
179
+ });
180
+ });
181
+ }
95
182
 
96
- let wsOutgoing: plugins.wsDefault;
97
- const outGoingDeferred = plugins.smartpromise.defer();
183
+ /**
184
+ * Sets up WebSocket support
185
+ */
186
+ private setupWebsocketSupport(): void {
187
+ // Create WebSocket server
188
+ this.wsServer = new plugins.ws.WebSocketServer({
189
+ server: this.httpsServer,
190
+ // Add WebSocket specific timeout
191
+ clientTracking: true
192
+ });
98
193
 
99
- // --- Improvement 2: Only call routeReq once ---
100
- const wsDestinationConfig = this.router.routeReq(reqArg);
101
- if (!wsDestinationConfig) {
102
- wsIncoming.terminate();
103
- return;
194
+ // Handle WebSocket connections
195
+ this.wsServer.on('connection', (wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
196
+ this.handleWebSocketConnection(wsIncoming, reqArg);
197
+ });
198
+
199
+ // Set up the heartbeat interval (check every 30 seconds, terminate after 2 minutes of inactivity)
200
+ this.heartbeatInterval = setInterval(() => {
201
+ if (this.wsServer.clients.size === 0) {
202
+ return; // Skip if no active connections
203
+ }
204
+
205
+ this.log('debug', `WebSocket heartbeat check for ${this.wsServer.clients.size} clients`);
206
+ this.wsServer.clients.forEach((ws: plugins.wsDefault) => {
207
+ const wsWithHeartbeat = ws as IWebSocketWithHeartbeat;
208
+
209
+ if (wsWithHeartbeat.isAlive === false) {
210
+ this.log('debug', 'Terminating inactive WebSocket connection');
211
+ return wsWithHeartbeat.terminate();
104
212
  }
105
- try {
106
- wsOutgoing = new plugins.wsDefault(
107
- `ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`,
108
- );
109
- console.log('wss proxy: initiated outgoing proxy');
110
- wsOutgoing.on('open', async () => {
111
- outGoingDeferred.resolve();
112
- });
113
- } catch (err) {
114
- console.error('Error initiating outgoing WebSocket:', err);
213
+
214
+ wsWithHeartbeat.isAlive = false;
215
+ wsWithHeartbeat.ping();
216
+ });
217
+ }, 30000);
218
+ }
219
+
220
+ /**
221
+ * Sets up metrics collection
222
+ */
223
+ private setupMetricsCollection(): void {
224
+ this.metricsInterval = setInterval(() => {
225
+ const uptime = Math.floor((Date.now() - this.startTime) / 1000);
226
+ const metrics = {
227
+ uptime,
228
+ activeConnections: this.connectedClients,
229
+ totalRequests: this.requestsServed,
230
+ failedRequests: this.failedRequests,
231
+ activeWebSockets: this.wsServer?.clients.size || 0,
232
+ memoryUsage: process.memoryUsage(),
233
+ activeContexts: Array.from(this.activeContexts)
234
+ };
235
+
236
+ this.log('debug', 'Proxy metrics', metrics);
237
+ }, 60000); // Log metrics every minute
238
+ }
239
+
240
+ /**
241
+ * Handles an incoming WebSocket connection
242
+ */
243
+ private handleWebSocketConnection(wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage): void {
244
+ const wsPath = reqArg.url;
245
+ const wsHost = reqArg.headers.host;
246
+
247
+ this.log('info', `WebSocket connection for ${wsHost}${wsPath}`);
248
+
249
+ // Setup heartbeat tracking
250
+ wsIncoming.isAlive = true;
251
+ wsIncoming.lastPong = Date.now();
252
+ wsIncoming.on('pong', () => {
253
+ wsIncoming.isAlive = true;
254
+ wsIncoming.lastPong = Date.now();
255
+ });
256
+
257
+ // Get the destination configuration
258
+ const wsDestinationConfig = this.router.routeReq(reqArg);
259
+ if (!wsDestinationConfig) {
260
+ this.log('warn', `No route found for WebSocket ${wsHost}${wsPath}`);
261
+ wsIncoming.terminate();
262
+ return;
263
+ }
264
+
265
+ // Check authentication if required
266
+ if (wsDestinationConfig.authentication) {
267
+ try {
268
+ if (!this.authenticateRequest(reqArg, wsDestinationConfig)) {
269
+ this.log('warn', `WebSocket authentication failed for ${wsHost}${wsPath}`);
115
270
  wsIncoming.terminate();
116
271
  return;
117
272
  }
273
+ } catch (error) {
274
+ this.log('error', 'WebSocket authentication error', error);
275
+ wsIncoming.terminate();
276
+ return;
277
+ }
278
+ }
118
279
 
119
- wsIncoming.on('message', async (message, isBinary) => {
120
- try {
121
- await outGoingDeferred.promise;
122
- wsOutgoing.send(message, { binary: isBinary });
123
- } catch (error) {
124
- console.error('Error sending message to wsOutgoing:', error);
125
- }
126
- });
127
-
128
- wsOutgoing.on('message', async (message, isBinary) => {
129
- try {
130
- wsIncoming.send(message, { binary: isBinary });
131
- } catch (error) {
132
- console.error('Error sending message to wsIncoming:', error);
133
- }
134
- });
280
+ // Setup outgoing WebSocket connection
281
+ let wsOutgoing: plugins.wsDefault;
282
+ const outGoingDeferred = plugins.smartpromise.defer();
135
283
 
136
- const terminateWsOutgoing = () => {
137
- if (wsOutgoing) {
138
- wsOutgoing.terminate();
139
- console.log('Terminated outgoing ws.');
140
- }
141
- };
142
- wsIncoming.on('error', terminateWsOutgoing);
143
- wsIncoming.on('close', terminateWsOutgoing);
284
+ try {
285
+ const wsTarget = `ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`;
286
+ this.log('debug', `Proxying WebSocket to ${wsTarget}`);
287
+
288
+ wsOutgoing = new plugins.wsDefault(wsTarget);
289
+
290
+ wsOutgoing.on('open', () => {
291
+ this.log('debug', 'Outgoing WebSocket connection established');
292
+ outGoingDeferred.resolve();
293
+ });
294
+
295
+ wsOutgoing.on('error', (error) => {
296
+ this.log('error', 'Outgoing WebSocket error', error);
297
+ outGoingDeferred.reject(error);
298
+ if (wsIncoming.readyState === wsIncoming.OPEN) {
299
+ wsIncoming.terminate();
300
+ }
301
+ });
302
+ } catch (err) {
303
+ this.log('error', 'Failed to create outgoing WebSocket connection', err);
304
+ wsIncoming.terminate();
305
+ return;
306
+ }
144
307
 
145
- const terminateWsIncoming = () => {
146
- if (wsIncoming) {
147
- wsIncoming.terminate();
148
- console.log('Terminated incoming ws.');
149
- }
150
- };
151
- wsOutgoing.on('error', terminateWsIncoming);
152
- wsOutgoing.on('close', terminateWsIncoming);
153
- },
154
- );
308
+ // Handle message forwarding from client to backend
309
+ wsIncoming.on('message', async (message, isBinary) => {
310
+ try {
311
+ // Wait for outgoing connection to be ready
312
+ await outGoingDeferred.promise;
313
+
314
+ // Only forward if both connections are still open
315
+ if (wsOutgoing.readyState === wsOutgoing.OPEN) {
316
+ wsOutgoing.send(message, { binary: isBinary });
317
+ }
318
+ } catch (error) {
319
+ this.log('error', 'Error forwarding WebSocket message to backend', error);
320
+ }
321
+ });
155
322
 
156
- this.httpsServer.keepAliveTimeout = 600 * 1000;
157
- this.httpsServer.headersTimeout = 600 * 1000;
323
+ // Handle message forwarding from backend to client
324
+ wsOutgoing.on('message', (message, isBinary) => {
325
+ try {
326
+ // Only forward if the incoming connection is still open
327
+ if (wsIncoming.readyState === wsIncoming.OPEN) {
328
+ wsIncoming.send(message, { binary: isBinary });
329
+ }
330
+ } catch (error) {
331
+ this.log('error', 'Error forwarding WebSocket message to client', error);
332
+ }
333
+ });
158
334
 
159
- this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
160
- this.socketMap.add(connection);
161
- console.log(`Added connection. Now ${this.socketMap.getArray().length} sockets connected.`);
162
- const cleanupConnection = () => {
163
- if (this.socketMap.checkForObject(connection)) {
164
- this.socketMap.remove(connection);
165
- console.log(`Removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
166
- connection.destroy();
335
+ // Clean up connections when either side closes
336
+ wsIncoming.on('close', (code, reason) => {
337
+ this.log('debug', `Incoming WebSocket closed: ${code} - ${reason}`);
338
+ if (wsOutgoing && wsOutgoing.readyState !== wsOutgoing.CLOSED) {
339
+ try {
340
+ // Validate close code (must be 1000-4999) or use 1000 as default
341
+ const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
342
+ wsOutgoing.close(validCode, reason.toString() || '');
343
+ } catch (error) {
344
+ this.log('error', 'Error closing outgoing WebSocket', error);
345
+ wsOutgoing.terminate();
167
346
  }
168
- };
169
- connection.on('close', cleanupConnection);
170
- connection.on('error', cleanupConnection);
171
- connection.on('end', cleanupConnection);
172
- connection.on('timeout', cleanupConnection);
347
+ }
173
348
  });
174
349
 
175
- this.httpsServer.listen(this.options.port);
176
- console.log(
177
- `NetworkProxy -> OK: now listening for new connections on port ${this.options.port}`,
178
- );
350
+ wsOutgoing.on('close', (code, reason) => {
351
+ this.log('debug', `Outgoing WebSocket closed: ${code} - ${reason}`);
352
+ if (wsIncoming && wsIncoming.readyState !== wsIncoming.CLOSED) {
353
+ try {
354
+ // Validate close code (must be 1000-4999) or use 1000 as default
355
+ const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
356
+ wsIncoming.close(validCode, reason.toString() || '');
357
+ } catch (error) {
358
+ this.log('error', 'Error closing incoming WebSocket', error);
359
+ wsIncoming.terminate();
360
+ }
361
+ }
362
+ });
179
363
  }
180
364
 
181
365
  /**
182
- * Internal async handler for processing HTTP/HTTPS requests.
366
+ * Handles an HTTP/HTTPS request
183
367
  */
184
368
  private async handleRequest(
185
369
  originRequest: plugins.http.IncomingMessage,
186
- originResponse: plugins.http.ServerResponse,
370
+ originResponse: plugins.http.ServerResponse
187
371
  ): Promise<void> {
188
- const endOriginReqRes = (
189
- statusArg: number = 404,
190
- messageArg: string = 'This route is not available on this server.',
191
- headers: plugins.http.OutgoingHttpHeaders = {},
192
- ) => {
193
- originResponse.writeHead(statusArg, messageArg);
194
- originResponse.end(messageArg);
195
- if (originRequest.socket !== originResponse.socket) {
196
- console.log('hey, something is strange.');
372
+ this.requestsServed++;
373
+ const startTime = Date.now();
374
+ const reqId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
375
+
376
+ try {
377
+ const reqPath = plugins.url.parse(originRequest.url).path;
378
+ this.log('info', `[${reqId}] ${originRequest.method} ${originRequest.headers.host}${reqPath}`);
379
+
380
+ // Handle preflight OPTIONS requests for CORS
381
+ if (originRequest.method === 'OPTIONS' && this.options.cors) {
382
+ this.handleCorsRequest(originRequest, originResponse);
383
+ return;
197
384
  }
198
- originResponse.destroy();
199
- };
200
-
201
- console.log(
202
- `got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`,
203
- );
204
- const destinationConfig = this.router.routeReq(originRequest);
205
-
206
- if (!destinationConfig) {
207
- console.log(
208
- `${originRequest.headers.host} can't be routed properly. Terminating request.`,
209
- );
210
- endOriginReqRes();
211
- return;
212
- }
213
-
214
- // authentication
215
- if (destinationConfig.authentication) {
216
- const authInfo = destinationConfig.authentication;
217
- switch (authInfo.type) {
218
- case 'Basic': {
219
- const authHeader = originRequest.headers.authorization;
220
- if (!authHeader) {
221
- return endOriginReqRes(401, 'Authentication required', {
222
- 'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
223
- });
224
- }
225
- if (!authHeader.includes('Basic ')) {
226
- return endOriginReqRes(401, 'Authentication required', {
227
- 'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
385
+
386
+ // Get destination configuration
387
+ const destinationConfig = this.router.routeReq(originRequest);
388
+ if (!destinationConfig) {
389
+ this.log('warn', `[${reqId}] No route found for ${originRequest.headers.host}`);
390
+ this.sendErrorResponse(originResponse, 404, 'Not Found: No matching route');
391
+ this.failedRequests++;
392
+ return;
393
+ }
394
+
395
+ // Handle authentication if configured
396
+ if (destinationConfig.authentication) {
397
+ try {
398
+ if (!this.authenticateRequest(originRequest, destinationConfig)) {
399
+ this.sendErrorResponse(originResponse, 401, 'Unauthorized', {
400
+ 'WWW-Authenticate': 'Basic realm="Access to the proxy site", charset="UTF-8"'
228
401
  });
402
+ this.failedRequests++;
403
+ return;
229
404
  }
230
- const authStringBase64 = authHeader.replace('Basic ', '');
231
- const authString: string = plugins.smartstring.base64.decode(authStringBase64);
232
- const userPassArray = authString.split(':');
233
- const user = userPassArray[0];
234
- const pass = userPassArray[1];
235
- if (user === authInfo.user && pass === authInfo.pass) {
236
- console.log('Request successfully authenticated');
237
- } else {
238
- return endOriginReqRes(403, 'Forbidden: Wrong credentials');
239
- }
240
- break;
405
+ } catch (error) {
406
+ this.log('error', `[${reqId}] Authentication error`, error);
407
+ this.sendErrorResponse(originResponse, 500, 'Internal Server Error: Authentication failed');
408
+ this.failedRequests++;
409
+ return;
241
410
  }
242
- default:
243
- return endOriginReqRes(
244
- 403,
245
- 'Forbidden: unsupported authentication method configured. Please report to the admin.',
246
- );
247
411
  }
412
+
413
+ // Construct destination URL
414
+ const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
415
+ this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
416
+
417
+ // Forward the request
418
+ await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
419
+
420
+ const processingTime = Date.now() - startTime;
421
+ this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
422
+ } catch (error) {
423
+ this.log('error', `[${reqId}] Unhandled error in request handler`, error);
424
+ try {
425
+ this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Server error');
426
+ } catch (responseError) {
427
+ this.log('error', `[${reqId}] Failed to send error response`, responseError);
428
+ }
429
+ this.failedRequests++;
248
430
  }
431
+ }
249
432
 
250
- let destinationUrl: string;
251
- if (destinationConfig) {
252
- destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
253
- } else {
254
- return endOriginReqRes();
433
+ /**
434
+ * Handles a CORS preflight request
435
+ */
436
+ private handleCorsRequest(
437
+ req: plugins.http.IncomingMessage,
438
+ res: plugins.http.ServerResponse
439
+ ): void {
440
+ const cors = this.options.cors;
441
+
442
+ // Set CORS headers
443
+ res.setHeader('Access-Control-Allow-Origin', cors.allowOrigin);
444
+ res.setHeader('Access-Control-Allow-Methods', cors.allowMethods);
445
+ res.setHeader('Access-Control-Allow-Headers', cors.allowHeaders);
446
+ res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
447
+
448
+ // Handle preflight request
449
+ res.statusCode = 204;
450
+ res.end();
451
+
452
+ // Count this as a request served
453
+ this.requestsServed++;
454
+ }
455
+
456
+ /**
457
+ * Authenticates a request against the destination config
458
+ */
459
+ private authenticateRequest(
460
+ req: plugins.http.IncomingMessage,
461
+ config: plugins.tsclass.network.IReverseProxyConfig
462
+ ): boolean {
463
+ const authInfo = config.authentication;
464
+ if (!authInfo) {
465
+ return true; // No authentication required
466
+ }
467
+
468
+ switch (authInfo.type) {
469
+ case 'Basic': {
470
+ const authHeader = req.headers.authorization;
471
+ if (!authHeader || !authHeader.includes('Basic ')) {
472
+ return false;
473
+ }
474
+
475
+ const authStringBase64 = authHeader.replace('Basic ', '');
476
+ const authString: string = plugins.smartstring.base64.decode(authStringBase64);
477
+ const [user, pass] = authString.split(':');
478
+
479
+ // Use constant-time comparison to prevent timing attacks
480
+ const userMatch = user === authInfo.user;
481
+ const passMatch = pass === authInfo.pass;
482
+
483
+ return userMatch && passMatch;
484
+ }
485
+ default:
486
+ throw new Error(`Unsupported authentication method: ${authInfo.type}`);
255
487
  }
256
- console.log(destinationUrl);
488
+ }
489
+
490
+ /**
491
+ * Forwards a request to the destination
492
+ */
493
+ private async forwardRequest(
494
+ reqId: string,
495
+ originRequest: plugins.http.IncomingMessage,
496
+ originResponse: plugins.http.ServerResponse,
497
+ destinationUrl: string
498
+ ): Promise<void> {
257
499
  try {
258
- const proxyResponse = await plugins.smartrequest.request(
500
+ const proxyRequest = await plugins.smartrequest.request(
259
501
  destinationUrl,
260
502
  {
261
503
  method: originRequest.method,
262
- headers: {
263
- ...originRequest.headers,
264
- 'X-Forwarded-Host': originRequest.headers.host,
265
- 'X-Forwarded-Proto': 'https',
266
- },
504
+ headers: this.prepareForwardHeaders(originRequest),
267
505
  keepAlive: true,
506
+ timeout: 30000 // 30 second timeout
268
507
  },
269
- true, // streaming (keepAlive)
270
- (proxyRequest) => {
271
- originRequest.on('data', (data) => {
272
- proxyRequest.write(data);
273
- });
274
- originRequest.on('end', () => {
275
- proxyRequest.end();
276
- });
277
- originRequest.on('error', () => {
278
- proxyRequest.end();
279
- });
280
- originRequest.on('close', () => {
281
- proxyRequest.end();
282
- });
283
- originRequest.on('timeout', () => {
284
- proxyRequest.end();
285
- originRequest.destroy();
286
- });
287
- proxyRequest.on('error', () => {
288
- endOriginReqRes();
289
- });
290
- },
508
+ true, // streaming
509
+ (proxyRequestStream) => this.setupRequestStreaming(originRequest, proxyRequestStream)
291
510
  );
292
- originResponse.statusCode = proxyResponse.statusCode;
293
- console.log(proxyResponse.statusCode);
294
- for (const defaultHeader of Object.keys(this.defaultHeaders)) {
295
- originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]);
511
+
512
+ // Handle the response
513
+ this.processProxyResponse(reqId, originResponse, proxyRequest);
514
+ } catch (error) {
515
+ this.log('error', `[${reqId}] Error forwarding request`, error);
516
+ this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
517
+ throw error; // Let the main handler catch this
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Prepares headers to forward to the backend
523
+ */
524
+ private prepareForwardHeaders(req: plugins.http.IncomingMessage): plugins.http.OutgoingHttpHeaders {
525
+ const safeHeaders = { ...req.headers };
526
+
527
+ // Add forwarding headers
528
+ safeHeaders['X-Forwarded-Host'] = req.headers.host;
529
+ safeHeaders['X-Forwarded-Proto'] = 'https';
530
+ safeHeaders['X-Forwarded-For'] = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
531
+
532
+ // Add proxy-specific headers
533
+ safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
534
+
535
+ // Remove sensitive headers we don't want to forward
536
+ const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
537
+ for (const header of sensitiveHeaders) {
538
+ delete safeHeaders[header];
539
+ }
540
+
541
+ return safeHeaders;
542
+ }
543
+
544
+ /**
545
+ * Sets up request streaming for the proxy
546
+ */
547
+ private setupRequestStreaming(
548
+ originRequest: plugins.http.IncomingMessage,
549
+ proxyRequest: plugins.http.ClientRequest
550
+ ): void {
551
+ // Forward request body data
552
+ originRequest.on('data', (chunk) => {
553
+ proxyRequest.write(chunk);
554
+ });
555
+
556
+ // End the request when done
557
+ originRequest.on('end', () => {
558
+ proxyRequest.end();
559
+ });
560
+
561
+ // Handle request errors
562
+ originRequest.on('error', (error) => {
563
+ this.log('error', 'Error in client request stream', error);
564
+ proxyRequest.destroy(error);
565
+ });
566
+
567
+ // Handle client abort/timeout
568
+ originRequest.on('close', () => {
569
+ if (!originRequest.complete) {
570
+ this.log('debug', 'Client closed connection before request completed');
571
+ proxyRequest.destroy();
296
572
  }
297
- for (const header of Object.keys(proxyResponse.headers)) {
298
- originResponse.setHeader(header, proxyResponse.headers[header]);
573
+ });
574
+
575
+ originRequest.on('timeout', () => {
576
+ this.log('debug', 'Client request timeout');
577
+ proxyRequest.destroy(new Error('Client request timeout'));
578
+ });
579
+
580
+ // Handle proxy request errors
581
+ proxyRequest.on('error', (error) => {
582
+ this.log('error', 'Error in outgoing proxy request', error);
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Processes a proxy response
588
+ */
589
+ private processProxyResponse(
590
+ reqId: string,
591
+ originResponse: plugins.http.ServerResponse,
592
+ proxyResponse: plugins.http.IncomingMessage
593
+ ): void {
594
+ this.log('debug', `[${reqId}] Received upstream response: ${proxyResponse.statusCode}`);
595
+
596
+ // Set status code
597
+ originResponse.statusCode = proxyResponse.statusCode;
598
+
599
+ // Add default headers
600
+ for (const [headerName, headerValue] of Object.entries(this.defaultHeaders)) {
601
+ originResponse.setHeader(headerName, headerValue);
602
+ }
603
+
604
+ // Add CORS headers if enabled
605
+ if (this.options.cors) {
606
+ originResponse.setHeader('Access-Control-Allow-Origin', this.options.cors.allowOrigin);
607
+ }
608
+
609
+ // Copy response headers
610
+ for (const [headerName, headerValue] of Object.entries(proxyResponse.headers)) {
611
+ // Skip hop-by-hop headers
612
+ const hopByHopHeaders = ['connection', 'keep-alive', 'transfer-encoding', 'te',
613
+ 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'];
614
+ if (!hopByHopHeaders.includes(headerName.toLowerCase())) {
615
+ originResponse.setHeader(headerName, headerValue);
616
+ }
617
+ }
618
+
619
+ // Stream response body
620
+ proxyResponse.on('data', (chunk) => {
621
+ const canContinue = originResponse.write(chunk);
622
+
623
+ // Apply backpressure if needed
624
+ if (!canContinue) {
625
+ proxyResponse.pause();
626
+ originResponse.once('drain', () => {
627
+ proxyResponse.resume();
628
+ });
629
+ }
630
+ });
631
+
632
+ // End the response when done
633
+ proxyResponse.on('end', () => {
634
+ originResponse.end();
635
+ });
636
+
637
+ // Handle response errors
638
+ proxyResponse.on('error', (error) => {
639
+ this.log('error', `[${reqId}] Error in proxy response stream`, error);
640
+ originResponse.destroy(error);
641
+ });
642
+
643
+ originResponse.on('error', (error) => {
644
+ this.log('error', `[${reqId}] Error in client response stream`, error);
645
+ proxyResponse.destroy();
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Sends an error response to the client
651
+ */
652
+ private sendErrorResponse(
653
+ res: plugins.http.ServerResponse,
654
+ statusCode: number = 500,
655
+ message: string = 'Internal Server Error',
656
+ headers: plugins.http.OutgoingHttpHeaders = {}
657
+ ): void {
658
+ try {
659
+ // If headers already sent, just end the response
660
+ if (res.headersSent) {
661
+ res.end();
662
+ return;
663
+ }
664
+
665
+ // Add default headers
666
+ for (const [key, value] of Object.entries(this.defaultHeaders)) {
667
+ res.setHeader(key, value);
668
+ }
669
+
670
+ // Add provided headers
671
+ for (const [key, value] of Object.entries(headers)) {
672
+ res.setHeader(key, value);
673
+ }
674
+
675
+ // Send error response
676
+ res.writeHead(statusCode, message);
677
+
678
+ // Send error body as JSON for API clients
679
+ if (res.getHeader('Content-Type') === 'application/json') {
680
+ res.end(JSON.stringify({ error: { status: statusCode, message } }));
681
+ } else {
682
+ // Send as plain text
683
+ res.end(message);
299
684
  }
300
- proxyResponse.on('data', (data) => {
301
- originResponse.write(data);
302
- });
303
- proxyResponse.on('end', () => {
304
- originResponse.end();
305
- });
306
- proxyResponse.on('error', () => {
307
- originResponse.destroy();
308
- });
309
- proxyResponse.on('close', () => {
310
- originResponse.end();
311
- });
312
- proxyResponse.on('timeout', () => {
313
- originResponse.end();
314
- originResponse.destroy();
315
- });
316
685
  } catch (error) {
317
- console.error('Error while processing request:', error);
318
- endOriginReqRes(502, 'Bad Gateway: Error processing the request');
686
+ this.log('error', 'Error sending error response', error);
687
+ try {
688
+ res.destroy();
689
+ } catch (destroyError) {
690
+ // Last resort - nothing more we can do
691
+ }
319
692
  }
320
693
  }
321
694
 
695
+ /**
696
+ * Updates proxy configurations
697
+ */
322
698
  public async updateProxyConfigs(
323
- proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[],
324
- ) {
325
- console.log(`got new proxy configs`);
699
+ proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]
700
+ ): Promise<void> {
701
+ this.log('info', `Updating proxy configurations (${proxyConfigsArg.length} configs)`);
702
+
703
+ // Update internal configs
326
704
  this.proxyConfigs = proxyConfigsArg;
327
705
  this.router.setNewProxyConfigs(proxyConfigsArg);
328
- for (const hostCandidate of this.proxyConfigs) {
329
- const existingHostNameConfig = this.alreadyAddedReverseConfigs[hostCandidate.hostName];
330
-
331
- if (!existingHostNameConfig) {
332
- this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
333
- } else {
334
- if (
335
- existingHostNameConfig.publicKey === hostCandidate.publicKey &&
336
- existingHostNameConfig.privateKey === hostCandidate.privateKey
337
- ) {
338
- continue;
339
- } else {
340
- this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
706
+
707
+ // Collect all hostnames for cleanup later
708
+ const currentHostNames = new Set<string>();
709
+
710
+ // Add/update SSL contexts for each host
711
+ for (const config of proxyConfigsArg) {
712
+ currentHostNames.add(config.hostName);
713
+
714
+ try {
715
+ // Check if we need to update the cert
716
+ const currentCert = this.certificateCache.get(config.hostName);
717
+ const shouldUpdate = !currentCert ||
718
+ currentCert.key !== config.privateKey ||
719
+ currentCert.cert !== config.publicKey;
720
+
721
+ if (shouldUpdate) {
722
+ this.log('debug', `Updating SSL context for ${config.hostName}`);
723
+
724
+ // Update the HTTPS server context
725
+ this.httpsServer.addContext(config.hostName, {
726
+ key: config.privateKey,
727
+ cert: config.publicKey
728
+ });
729
+
730
+ // Update the cache
731
+ this.certificateCache.set(config.hostName, {
732
+ key: config.privateKey,
733
+ cert: config.publicKey
734
+ });
735
+
736
+ this.activeContexts.add(config.hostName);
341
737
  }
738
+ } catch (error) {
739
+ this.log('error', `Failed to add SSL context for ${config.hostName}`, error);
740
+ }
741
+ }
742
+
743
+ // Clean up removed contexts
744
+ // Note: Node.js doesn't officially support removing contexts
745
+ // This would require server restart in production
746
+ for (const hostname of this.activeContexts) {
747
+ if (!currentHostNames.has(hostname)) {
748
+ this.log('info', `Hostname ${hostname} removed from configuration`);
749
+ this.activeContexts.delete(hostname);
750
+ this.certificateCache.delete(hostname);
342
751
  }
343
-
344
- this.httpsServer.addContext(hostCandidate.hostName, {
345
- cert: hostCandidate.publicKey,
346
- key: hostCandidate.privateKey,
347
- });
348
752
  }
349
753
  }
350
754
 
351
- public async addDefaultHeaders(headersArg: { [key: string]: string }) {
352
- for (const headerKey of Object.keys(headersArg)) {
353
- this.defaultHeaders[headerKey] = headersArg[headerKey];
354
- }
755
+ /**
756
+ * Adds default headers to be included in all responses
757
+ */
758
+ public async addDefaultHeaders(headersArg: { [key: string]: string }): Promise<void> {
759
+ this.log('info', 'Adding default headers', headersArg);
760
+ this.defaultHeaders = {
761
+ ...this.defaultHeaders,
762
+ ...headersArg
763
+ };
355
764
  }
356
765
 
357
- public async stop() {
358
- const done = plugins.smartpromise.defer();
359
- this.httpsServer.close(() => {
360
- done.resolve();
361
- });
766
+ /**
767
+ * Stops the proxy server
768
+ */
769
+ public async stop(): Promise<void> {
770
+ this.log('info', 'Stopping NetworkProxy server');
771
+
772
+ // Clear intervals
773
+ if (this.heartbeatInterval) {
774
+ clearInterval(this.heartbeatInterval);
775
+ }
776
+
777
+ if (this.metricsInterval) {
778
+ clearInterval(this.metricsInterval);
779
+ }
780
+
781
+ // Close WebSocket server if exists
782
+ if (this.wsServer) {
783
+ for (const client of this.wsServer.clients) {
784
+ try {
785
+ client.terminate();
786
+ } catch (error) {
787
+ this.log('error', 'Error terminating WebSocket client', error);
788
+ }
789
+ }
790
+ }
791
+
792
+ // Close all tracked sockets
362
793
  for (const socket of this.socketMap.getArray()) {
363
- socket.destroy();
794
+ try {
795
+ socket.destroy();
796
+ } catch (error) {
797
+ this.log('error', 'Error destroying socket', error);
798
+ }
799
+ }
800
+
801
+ // Close the HTTPS server
802
+ return new Promise((resolve) => {
803
+ this.httpsServer.close(() => {
804
+ this.log('info', 'NetworkProxy server stopped successfully');
805
+ resolve();
806
+ });
807
+ });
808
+ }
809
+
810
+ /**
811
+ * Logs a message according to the configured log level
812
+ */
813
+ private log(level: 'error' | 'warn' | 'info' | 'debug', message: string, data?: any): void {
814
+ const logLevels = {
815
+ error: 0,
816
+ warn: 1,
817
+ info: 2,
818
+ debug: 3
819
+ };
820
+
821
+ // Skip if log level is higher than configured
822
+ if (logLevels[level] > logLevels[this.options.logLevel]) {
823
+ return;
824
+ }
825
+
826
+ const timestamp = new Date().toISOString();
827
+ const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
828
+
829
+ switch (level) {
830
+ case 'error':
831
+ console.error(`${prefix} ${message}`, data || '');
832
+ break;
833
+ case 'warn':
834
+ console.warn(`${prefix} ${message}`, data || '');
835
+ break;
836
+ case 'info':
837
+ console.log(`${prefix} ${message}`, data || '');
838
+ break;
839
+ case 'debug':
840
+ console.log(`${prefix} ${message}`, data || '');
841
+ break;
364
842
  }
365
- await done.promise;
366
- clearInterval(this.heartbeatInterval);
367
- console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.');
368
843
  }
369
844
  }