@push.rocks/smartproxy 19.3.12 → 19.3.13

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.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartproxy',
6
- version: '19.3.12',
6
+ version: '19.3.13',
7
7
  description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
8
8
  }
@@ -416,6 +416,33 @@ export class SmartCertManager {
416
416
  if (!this.challengeRoute) {
417
417
  throw new Error('Challenge route not initialized');
418
418
  }
419
+
420
+ // Get the challenge port
421
+ const challengePort = this.globalAcmeDefaults?.port || 80;
422
+
423
+ // Check if any existing routes are already using this port
424
+ const portInUseByRoutes = this.routes.some(route => {
425
+ const routePorts = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
426
+ return routePorts.some(p => {
427
+ // Handle both number and port range objects
428
+ if (typeof p === 'number') {
429
+ return p === challengePort;
430
+ } else if (typeof p === 'object' && 'from' in p && 'to' in p) {
431
+ // Port range case - check if challengePort is in range
432
+ return challengePort >= p.from && challengePort <= p.to;
433
+ }
434
+ return false;
435
+ });
436
+ });
437
+
438
+ if (portInUseByRoutes) {
439
+ logger.log('info', `Port ${challengePort} is already used by another route, merging ACME challenge route`, {
440
+ port: challengePort,
441
+ component: 'certificate-manager'
442
+ });
443
+ }
444
+
445
+ // Add the challenge route
419
446
  const challengeRoute = this.challengeRoute;
420
447
 
421
448
  try {
@@ -430,10 +457,27 @@ export class SmartCertManager {
430
457
 
431
458
  logger.log('info', 'ACME challenge route successfully added', { component: 'certificate-manager' });
432
459
  } catch (error) {
433
- logger.log('error', `Failed to add challenge route: ${error.message}`, { error: error.message, component: 'certificate-manager' });
460
+ // Handle specific EADDRINUSE errors differently based on whether it's an internal conflict
434
461
  if ((error as any).code === 'EADDRINUSE') {
435
- throw new Error(`Port ${this.globalAcmeDefaults?.port || 80} is already in use for ACME challenges`);
462
+ logger.log('error', `Failed to add challenge route on port ${challengePort}: ${error.message}`, {
463
+ error: error.message,
464
+ port: challengePort,
465
+ component: 'certificate-manager'
466
+ });
467
+
468
+ // Provide a more informative error message
469
+ throw new Error(
470
+ `Port ${challengePort} is already in use. ` +
471
+ `If it's in use by an external process, configure a different port in the ACME settings. ` +
472
+ `If it's in use by SmartProxy, there may be a route configuration issue.`
473
+ );
436
474
  }
475
+
476
+ // Log and rethrow other errors
477
+ logger.log('error', `Failed to add challenge route: ${error.message}`, {
478
+ error: error.message,
479
+ component: 'certificate-manager'
480
+ });
437
481
  throw error;
438
482
  }
439
483
  }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import type { ISmartProxyOptions } from './models/interfaces.js';
3
3
  import { RouteConnectionHandler } from './route-connection-handler.js';
4
+ import { logger } from '../../core/utils/logger.js';
4
5
 
5
6
  /**
6
7
  * PortManager handles the dynamic creation and removal of port listeners
@@ -8,12 +9,17 @@ import { RouteConnectionHandler } from './route-connection-handler.js';
8
9
  * This class provides methods to add and remove listening ports at runtime,
9
10
  * allowing SmartProxy to adapt to configuration changes without requiring
10
11
  * a full restart.
12
+ *
13
+ * It includes a reference counting system to track how many routes are using
14
+ * each port, so ports can be automatically released when they are no longer needed.
11
15
  */
12
16
  export class PortManager {
13
17
  private servers: Map<number, plugins.net.Server> = new Map();
14
18
  private settings: ISmartProxyOptions;
15
19
  private routeConnectionHandler: RouteConnectionHandler;
16
20
  private isShuttingDown: boolean = false;
21
+ // Track how many routes are using each port
22
+ private portRefCounts: Map<number, number> = new Map();
17
23
 
18
24
  /**
19
25
  * Create a new PortManager
@@ -38,10 +44,18 @@ export class PortManager {
38
44
  public async addPort(port: number): Promise<void> {
39
45
  // Check if we're already listening on this port
40
46
  if (this.servers.has(port)) {
41
- console.log(`PortManager: Already listening on port ${port}`);
47
+ // Port is already bound, just increment the reference count
48
+ this.incrementPortRefCount(port);
49
+ logger.log('debug', `PortManager: Port ${port} is already bound by SmartProxy, reusing binding`, {
50
+ port,
51
+ component: 'port-manager'
52
+ });
42
53
  return;
43
54
  }
44
55
 
56
+ // Initialize reference count for new port
57
+ this.portRefCounts.set(port, 1);
58
+
45
59
  // Create a server for this port
46
60
  const server = plugins.net.createServer((socket) => {
47
61
  // Check if shutting down
@@ -54,24 +68,56 @@ export class PortManager {
54
68
  // Delegate to route connection handler
55
69
  this.routeConnectionHandler.handleConnection(socket);
56
70
  }).on('error', (err: Error) => {
57
- console.log(`Server Error on port ${port}: ${err.message}`);
71
+ logger.log('error', `Server Error on port ${port}: ${err.message}`, {
72
+ port,
73
+ error: err.message,
74
+ component: 'port-manager'
75
+ });
58
76
  });
59
77
 
60
78
  // Start listening on the port
61
79
  return new Promise<void>((resolve, reject) => {
62
80
  server.listen(port, () => {
63
81
  const isHttpProxyPort = this.settings.useHttpProxy?.includes(port);
64
- console.log(
65
- `SmartProxy -> OK: Now listening on port ${port}${
66
- isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
67
- }`
68
- );
82
+ logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${
83
+ isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
84
+ }`, {
85
+ port,
86
+ isHttpProxyPort: !!isHttpProxyPort,
87
+ component: 'port-manager'
88
+ });
69
89
 
70
90
  // Store the server reference
71
91
  this.servers.set(port, server);
72
92
  resolve();
73
93
  }).on('error', (err) => {
74
- console.log(`Failed to listen on port ${port}: ${err.message}`);
94
+ // Check if this is an external conflict
95
+ const { isConflict, isExternal } = this.isPortConflict(err);
96
+
97
+ if (isConflict && !isExternal) {
98
+ // This is an internal conflict (port already bound by SmartProxy)
99
+ // This shouldn't normally happen because we check servers.has(port) above
100
+ logger.log('warn', `Port ${port} binding conflict: already in use by SmartProxy`, {
101
+ port,
102
+ component: 'port-manager'
103
+ });
104
+ // Still increment reference count to maintain tracking
105
+ this.incrementPortRefCount(port);
106
+ resolve();
107
+ return;
108
+ }
109
+
110
+ // Log the error and propagate it
111
+ logger.log('error', `Failed to listen on port ${port}: ${err.message}`, {
112
+ port,
113
+ error: err.message,
114
+ code: (err as any).code,
115
+ component: 'port-manager'
116
+ });
117
+
118
+ // Clean up reference count since binding failed
119
+ this.portRefCounts.delete(port);
120
+
75
121
  reject(err);
76
122
  });
77
123
  });
@@ -84,10 +130,28 @@ export class PortManager {
84
130
  * @returns Promise that resolves when the server is closed
85
131
  */
86
132
  public async removePort(port: number): Promise<void> {
133
+ // Decrement the reference count first
134
+ const newRefCount = this.decrementPortRefCount(port);
135
+
136
+ // If there are still references to this port, keep it open
137
+ if (newRefCount > 0) {
138
+ logger.log('debug', `PortManager: Port ${port} still has ${newRefCount} references, keeping open`, {
139
+ port,
140
+ refCount: newRefCount,
141
+ component: 'port-manager'
142
+ });
143
+ return;
144
+ }
145
+
87
146
  // Get the server for this port
88
147
  const server = this.servers.get(port);
89
148
  if (!server) {
90
- console.log(`PortManager: Not listening on port ${port}`);
149
+ logger.log('warn', `PortManager: Not listening on port ${port}`, {
150
+ port,
151
+ component: 'port-manager'
152
+ });
153
+ // Ensure reference count is reset
154
+ this.portRefCounts.delete(port);
91
155
  return;
92
156
  }
93
157
 
@@ -95,13 +159,21 @@ export class PortManager {
95
159
  return new Promise<void>((resolve) => {
96
160
  server.close((err) => {
97
161
  if (err) {
98
- console.log(`Error closing server on port ${port}: ${err.message}`);
162
+ logger.log('error', `Error closing server on port ${port}: ${err.message}`, {
163
+ port,
164
+ error: err.message,
165
+ component: 'port-manager'
166
+ });
99
167
  } else {
100
- console.log(`SmartProxy -> Stopped listening on port ${port}`);
168
+ logger.log('info', `SmartProxy -> Stopped listening on port ${port}`, {
169
+ port,
170
+ component: 'port-manager'
171
+ });
101
172
  }
102
173
 
103
- // Remove the server reference
174
+ // Remove the server reference and clean up reference counting
104
175
  this.servers.delete(port);
176
+ this.portRefCounts.delete(port);
105
177
  resolve();
106
178
  });
107
179
  });
@@ -192,4 +264,89 @@ export class PortManager {
192
264
  public getServers(): Map<number, plugins.net.Server> {
193
265
  return new Map(this.servers);
194
266
  }
267
+
268
+ /**
269
+ * Check if a port is bound by this SmartProxy instance
270
+ *
271
+ * @param port The port number to check
272
+ * @returns True if the port is currently bound by SmartProxy
273
+ */
274
+ public isPortBoundBySmartProxy(port: number): boolean {
275
+ return this.servers.has(port);
276
+ }
277
+
278
+ /**
279
+ * Get the current reference count for a port
280
+ *
281
+ * @param port The port number to check
282
+ * @returns The number of routes using this port, 0 if none
283
+ */
284
+ public getPortRefCount(port: number): number {
285
+ return this.portRefCounts.get(port) || 0;
286
+ }
287
+
288
+ /**
289
+ * Increment the reference count for a port
290
+ *
291
+ * @param port The port number to increment
292
+ * @returns The new reference count
293
+ */
294
+ public incrementPortRefCount(port: number): number {
295
+ const currentCount = this.portRefCounts.get(port) || 0;
296
+ const newCount = currentCount + 1;
297
+ this.portRefCounts.set(port, newCount);
298
+
299
+ logger.log('debug', `Port ${port} reference count increased to ${newCount}`, {
300
+ port,
301
+ refCount: newCount,
302
+ component: 'port-manager'
303
+ });
304
+
305
+ return newCount;
306
+ }
307
+
308
+ /**
309
+ * Decrement the reference count for a port
310
+ *
311
+ * @param port The port number to decrement
312
+ * @returns The new reference count
313
+ */
314
+ public decrementPortRefCount(port: number): number {
315
+ const currentCount = this.portRefCounts.get(port) || 0;
316
+
317
+ if (currentCount <= 0) {
318
+ logger.log('warn', `Attempted to decrement reference count for port ${port} below zero`, {
319
+ port,
320
+ component: 'port-manager'
321
+ });
322
+ return 0;
323
+ }
324
+
325
+ const newCount = currentCount - 1;
326
+ this.portRefCounts.set(port, newCount);
327
+
328
+ logger.log('debug', `Port ${port} reference count decreased to ${newCount}`, {
329
+ port,
330
+ refCount: newCount,
331
+ component: 'port-manager'
332
+ });
333
+
334
+ return newCount;
335
+ }
336
+
337
+ /**
338
+ * Determine if a port binding error is due to an external or internal conflict
339
+ *
340
+ * @param error The error object from a failed port binding
341
+ * @returns Object indicating if this is a conflict and if it's external
342
+ */
343
+ private isPortConflict(error: any): { isConflict: boolean; isExternal: boolean } {
344
+ if (error.code !== 'EADDRINUSE') {
345
+ return { isConflict: false, isExternal: false };
346
+ }
347
+
348
+ // Check if we already have this port
349
+ const isBoundInternally = this.servers.has(Number(error.port));
350
+ return { isConflict: true, isExternal: !isBoundInternally };
351
+ }
195
352
  }
@@ -64,6 +64,9 @@ export class SmartProxy extends plugins.EventEmitter {
64
64
  private routeUpdateLock: any = null; // Will be initialized as AsyncMutex
65
65
  private acmeStateManager: AcmeStateManager;
66
66
 
67
+ // Track port usage across route updates
68
+ private portUsageMap: Map<number, Set<string>> = new Map();
69
+
67
70
  /**
68
71
  * Constructor for SmartProxy
69
72
  *
@@ -342,6 +345,16 @@ export class SmartProxy extends plugins.EventEmitter {
342
345
  // Get listening ports from RouteManager
343
346
  const listeningPorts = this.routeManager.getListeningPorts();
344
347
 
348
+ // Initialize port usage tracking
349
+ this.portUsageMap = this.updatePortUsageMap(this.settings.routes);
350
+
351
+ // Log port usage for startup
352
+ logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, {
353
+ portCount: listeningPorts.length,
354
+ ports: listeningPorts,
355
+ component: 'smart-proxy'
356
+ });
357
+
345
358
  // Provision NFTables rules for routes that use NFTables
346
359
  for (const route of this.settings.routes) {
347
360
  if (route.action.forwardingEngine === 'nftables') {
@@ -548,6 +561,18 @@ export class SmartProxy extends plugins.EventEmitter {
548
561
  return this.routeUpdateLock.runExclusive(async () => {
549
562
  logger.log('info', `Updating routes (${newRoutes.length} routes)`, { routeCount: newRoutes.length, component: 'route-manager' });
550
563
 
564
+ // Track port usage before and after updates
565
+ const oldPortUsage = this.updatePortUsageMap(this.settings.routes);
566
+ const newPortUsage = this.updatePortUsageMap(newRoutes);
567
+
568
+ // Find orphaned ports - ports that no longer have any routes
569
+ const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
570
+
571
+ // Find new ports that need binding
572
+ const currentPorts = new Set(this.portManager.getListeningPorts());
573
+ const newPortsSet = new Set(newPortUsage.keys());
574
+ const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
575
+
551
576
  // Get existing routes that use NFTables
552
577
  const oldNfTablesRoutes = this.settings.routes.filter(
553
578
  r => r.action.forwardingEngine === 'nftables'
@@ -584,14 +609,29 @@ export class SmartProxy extends plugins.EventEmitter {
584
609
  // Update routes in RouteManager
585
610
  this.routeManager.updateRoutes(newRoutes);
586
611
 
587
- // Get the new set of required ports
588
- const requiredPorts = this.routeManager.getListeningPorts();
589
-
590
- // Update port listeners to match the new configuration
591
- await this.portManager.updatePorts(requiredPorts);
612
+ // Release orphaned ports first
613
+ if (orphanedPorts.length > 0) {
614
+ logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, {
615
+ ports: orphanedPorts,
616
+ component: 'smart-proxy'
617
+ });
618
+ await this.portManager.removePorts(orphanedPorts);
619
+ }
620
+
621
+ // Add new ports
622
+ if (newBindingPorts.length > 0) {
623
+ logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, {
624
+ ports: newBindingPorts,
625
+ component: 'smart-proxy'
626
+ });
627
+ await this.portManager.addPorts(newBindingPorts);
628
+ }
592
629
 
593
630
  // Update settings with the new routes
594
631
  this.settings.routes = newRoutes;
632
+
633
+ // Save the new port usage map for future reference
634
+ this.portUsageMap = newPortUsage;
595
635
 
596
636
  // If HttpProxy is initialized, resync the configurations
597
637
  if (this.httpProxyBridge.getHttpProxy()) {
@@ -637,6 +677,78 @@ export class SmartProxy extends plugins.EventEmitter {
637
677
 
638
678
  await this.certManager.provisionCertificate(route);
639
679
  }
680
+
681
+ /**
682
+ * Update the port usage map based on the provided routes
683
+ *
684
+ * This tracks which ports are used by which routes, allowing us to
685
+ * detect when a port is no longer needed and can be released.
686
+ */
687
+ private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
688
+ // Reset the usage map
689
+ const portUsage = new Map<number, Set<string>>();
690
+
691
+ for (const route of routes) {
692
+ // Get the ports for this route
693
+ const portsConfig = Array.isArray(route.match.ports)
694
+ ? route.match.ports
695
+ : [route.match.ports];
696
+
697
+ // Expand port range objects to individual port numbers
698
+ const expandedPorts: number[] = [];
699
+ for (const portConfig of portsConfig) {
700
+ if (typeof portConfig === 'number') {
701
+ expandedPorts.push(portConfig);
702
+ } else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
703
+ // Expand the port range
704
+ for (let p = portConfig.from; p <= portConfig.to; p++) {
705
+ expandedPorts.push(p);
706
+ }
707
+ }
708
+ }
709
+
710
+ // Use route name if available, otherwise generate a unique ID
711
+ const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
712
+
713
+ // Add each port to the usage map
714
+ for (const port of expandedPorts) {
715
+ if (!portUsage.has(port)) {
716
+ portUsage.set(port, new Set());
717
+ }
718
+ portUsage.get(port)!.add(routeName);
719
+ }
720
+ }
721
+
722
+ // Log port usage for debugging
723
+ for (const [port, routes] of portUsage.entries()) {
724
+ logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
725
+ port,
726
+ routeCount: routes.size,
727
+ component: 'smart-proxy'
728
+ });
729
+ }
730
+
731
+ return portUsage;
732
+ }
733
+
734
+ /**
735
+ * Find ports that have no routes in the new configuration
736
+ */
737
+ private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
738
+ const orphanedPorts: number[] = [];
739
+
740
+ for (const [port, routes] of oldUsage.entries()) {
741
+ if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
742
+ orphanedPorts.push(port);
743
+ logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
744
+ port,
745
+ component: 'smart-proxy'
746
+ });
747
+ }
748
+ }
749
+
750
+ return orphanedPorts;
751
+ }
640
752
 
641
753
  /**
642
754
  * Force renewal of a certificate