@push.rocks/smartproxy 19.3.12 → 19.3.14

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.
@@ -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,22 @@ 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
+ try {
50
+ logger.log('debug', `PortManager: Port ${port} is already bound by SmartProxy, reusing binding`, {
51
+ port,
52
+ component: 'port-manager'
53
+ });
54
+ } catch (e) {
55
+ console.log(`[DEBUG] PortManager: Port ${port} is already bound by SmartProxy, reusing binding`);
56
+ }
42
57
  return;
43
58
  }
44
59
 
60
+ // Initialize reference count for new port
61
+ this.portRefCounts.set(port, 1);
62
+
45
63
  // Create a server for this port
46
64
  const server = plugins.net.createServer((socket) => {
47
65
  // Check if shutting down
@@ -54,24 +72,66 @@ export class PortManager {
54
72
  // Delegate to route connection handler
55
73
  this.routeConnectionHandler.handleConnection(socket);
56
74
  }).on('error', (err: Error) => {
57
- console.log(`Server Error on port ${port}: ${err.message}`);
75
+ try {
76
+ logger.log('error', `Server Error on port ${port}: ${err.message}`, {
77
+ port,
78
+ error: err.message,
79
+ component: 'port-manager'
80
+ });
81
+ } catch (e) {
82
+ console.error(`[ERROR] Server Error on port ${port}: ${err.message}`);
83
+ }
58
84
  });
59
85
 
60
86
  // Start listening on the port
61
87
  return new Promise<void>((resolve, reject) => {
62
88
  server.listen(port, () => {
63
89
  const isHttpProxyPort = this.settings.useHttpProxy?.includes(port);
64
- console.log(
65
- `SmartProxy -> OK: Now listening on port ${port}${
90
+ try {
91
+ logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${
66
92
  isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
67
- }`
68
- );
93
+ }`, {
94
+ port,
95
+ isHttpProxyPort: !!isHttpProxyPort,
96
+ component: 'port-manager'
97
+ });
98
+ } catch (e) {
99
+ console.log(`[INFO] SmartProxy -> OK: Now listening on port ${port}${
100
+ isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
101
+ }`);
102
+ }
69
103
 
70
104
  // Store the server reference
71
105
  this.servers.set(port, server);
72
106
  resolve();
73
107
  }).on('error', (err) => {
74
- console.log(`Failed to listen on port ${port}: ${err.message}`);
108
+ // Check if this is an external conflict
109
+ const { isConflict, isExternal } = this.isPortConflict(err);
110
+
111
+ if (isConflict && !isExternal) {
112
+ // This is an internal conflict (port already bound by SmartProxy)
113
+ // This shouldn't normally happen because we check servers.has(port) above
114
+ logger.log('warn', `Port ${port} binding conflict: already in use by SmartProxy`, {
115
+ port,
116
+ component: 'port-manager'
117
+ });
118
+ // Still increment reference count to maintain tracking
119
+ this.incrementPortRefCount(port);
120
+ resolve();
121
+ return;
122
+ }
123
+
124
+ // Log the error and propagate it
125
+ logger.log('error', `Failed to listen on port ${port}: ${err.message}`, {
126
+ port,
127
+ error: err.message,
128
+ code: (err as any).code,
129
+ component: 'port-manager'
130
+ });
131
+
132
+ // Clean up reference count since binding failed
133
+ this.portRefCounts.delete(port);
134
+
75
135
  reject(err);
76
136
  });
77
137
  });
@@ -84,10 +144,28 @@ export class PortManager {
84
144
  * @returns Promise that resolves when the server is closed
85
145
  */
86
146
  public async removePort(port: number): Promise<void> {
147
+ // Decrement the reference count first
148
+ const newRefCount = this.decrementPortRefCount(port);
149
+
150
+ // If there are still references to this port, keep it open
151
+ if (newRefCount > 0) {
152
+ logger.log('debug', `PortManager: Port ${port} still has ${newRefCount} references, keeping open`, {
153
+ port,
154
+ refCount: newRefCount,
155
+ component: 'port-manager'
156
+ });
157
+ return;
158
+ }
159
+
87
160
  // Get the server for this port
88
161
  const server = this.servers.get(port);
89
162
  if (!server) {
90
- console.log(`PortManager: Not listening on port ${port}`);
163
+ logger.log('warn', `PortManager: Not listening on port ${port}`, {
164
+ port,
165
+ component: 'port-manager'
166
+ });
167
+ // Ensure reference count is reset
168
+ this.portRefCounts.delete(port);
91
169
  return;
92
170
  }
93
171
 
@@ -95,13 +173,21 @@ export class PortManager {
95
173
  return new Promise<void>((resolve) => {
96
174
  server.close((err) => {
97
175
  if (err) {
98
- console.log(`Error closing server on port ${port}: ${err.message}`);
176
+ logger.log('error', `Error closing server on port ${port}: ${err.message}`, {
177
+ port,
178
+ error: err.message,
179
+ component: 'port-manager'
180
+ });
99
181
  } else {
100
- console.log(`SmartProxy -> Stopped listening on port ${port}`);
182
+ logger.log('info', `SmartProxy -> Stopped listening on port ${port}`, {
183
+ port,
184
+ component: 'port-manager'
185
+ });
101
186
  }
102
187
 
103
- // Remove the server reference
188
+ // Remove the server reference and clean up reference counting
104
189
  this.servers.delete(port);
190
+ this.portRefCounts.delete(port);
105
191
  resolve();
106
192
  });
107
193
  });
@@ -192,4 +278,89 @@ export class PortManager {
192
278
  public getServers(): Map<number, plugins.net.Server> {
193
279
  return new Map(this.servers);
194
280
  }
281
+
282
+ /**
283
+ * Check if a port is bound by this SmartProxy instance
284
+ *
285
+ * @param port The port number to check
286
+ * @returns True if the port is currently bound by SmartProxy
287
+ */
288
+ public isPortBoundBySmartProxy(port: number): boolean {
289
+ return this.servers.has(port);
290
+ }
291
+
292
+ /**
293
+ * Get the current reference count for a port
294
+ *
295
+ * @param port The port number to check
296
+ * @returns The number of routes using this port, 0 if none
297
+ */
298
+ public getPortRefCount(port: number): number {
299
+ return this.portRefCounts.get(port) || 0;
300
+ }
301
+
302
+ /**
303
+ * Increment the reference count for a port
304
+ *
305
+ * @param port The port number to increment
306
+ * @returns The new reference count
307
+ */
308
+ public incrementPortRefCount(port: number): number {
309
+ const currentCount = this.portRefCounts.get(port) || 0;
310
+ const newCount = currentCount + 1;
311
+ this.portRefCounts.set(port, newCount);
312
+
313
+ logger.log('debug', `Port ${port} reference count increased to ${newCount}`, {
314
+ port,
315
+ refCount: newCount,
316
+ component: 'port-manager'
317
+ });
318
+
319
+ return newCount;
320
+ }
321
+
322
+ /**
323
+ * Decrement the reference count for a port
324
+ *
325
+ * @param port The port number to decrement
326
+ * @returns The new reference count
327
+ */
328
+ public decrementPortRefCount(port: number): number {
329
+ const currentCount = this.portRefCounts.get(port) || 0;
330
+
331
+ if (currentCount <= 0) {
332
+ logger.log('warn', `Attempted to decrement reference count for port ${port} below zero`, {
333
+ port,
334
+ component: 'port-manager'
335
+ });
336
+ return 0;
337
+ }
338
+
339
+ const newCount = currentCount - 1;
340
+ this.portRefCounts.set(port, newCount);
341
+
342
+ logger.log('debug', `Port ${port} reference count decreased to ${newCount}`, {
343
+ port,
344
+ refCount: newCount,
345
+ component: 'port-manager'
346
+ });
347
+
348
+ return newCount;
349
+ }
350
+
351
+ /**
352
+ * Determine if a port binding error is due to an external or internal conflict
353
+ *
354
+ * @param error The error object from a failed port binding
355
+ * @returns Object indicating if this is a conflict and if it's external
356
+ */
357
+ private isPortConflict(error: any): { isConflict: boolean; isExternal: boolean } {
358
+ if (error.code !== 'EADDRINUSE') {
359
+ return { isConflict: false, isExternal: false };
360
+ }
361
+
362
+ // Check if we already have this port
363
+ const isBoundInternally = this.servers.has(Number(error.port));
364
+ return { isConflict: true, isExternal: !isBoundInternally };
365
+ }
195
366
  }
@@ -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') {
@@ -508,7 +521,12 @@ export class SmartProxy extends plugins.EventEmitter {
508
521
  const challengeRouteExists = this.settings.routes.some(r => r.name === 'acme-challenge');
509
522
 
510
523
  if (!challengeRouteExists) {
511
- logger.log('info', 'Challenge route successfully removed from routes');
524
+ try {
525
+ logger.log('info', 'Challenge route successfully removed from routes');
526
+ } catch (error) {
527
+ // Silently handle logging errors
528
+ console.log('[INFO] Challenge route successfully removed from routes');
529
+ }
512
530
  return;
513
531
  }
514
532
 
@@ -517,7 +535,12 @@ export class SmartProxy extends plugins.EventEmitter {
517
535
  }
518
536
 
519
537
  const error = `Failed to verify challenge route removal after ${maxRetries} attempts`;
520
- logger.log('error', error);
538
+ try {
539
+ logger.log('error', error);
540
+ } catch (logError) {
541
+ // Silently handle logging errors
542
+ console.log(`[ERROR] ${error}`);
543
+ }
521
544
  throw new Error(error);
522
545
  }
523
546
 
@@ -546,7 +569,24 @@ export class SmartProxy extends plugins.EventEmitter {
546
569
  */
547
570
  public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
548
571
  return this.routeUpdateLock.runExclusive(async () => {
549
- logger.log('info', `Updating routes (${newRoutes.length} routes)`, { routeCount: newRoutes.length, component: 'route-manager' });
572
+ try {
573
+ logger.log('info', `Updating routes (${newRoutes.length} routes)`, { routeCount: newRoutes.length, component: 'route-manager' });
574
+ } catch (error) {
575
+ // Silently handle logging errors
576
+ console.log(`[INFO] Updating routes (${newRoutes.length} routes)`);
577
+ }
578
+
579
+ // Track port usage before and after updates
580
+ const oldPortUsage = this.updatePortUsageMap(this.settings.routes);
581
+ const newPortUsage = this.updatePortUsageMap(newRoutes);
582
+
583
+ // Find orphaned ports - ports that no longer have any routes
584
+ const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
585
+
586
+ // Find new ports that need binding
587
+ const currentPorts = new Set(this.portManager.getListeningPorts());
588
+ const newPortsSet = new Set(newPortUsage.keys());
589
+ const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
550
590
 
551
591
  // Get existing routes that use NFTables
552
592
  const oldNfTablesRoutes = this.settings.routes.filter(
@@ -584,14 +624,39 @@ export class SmartProxy extends plugins.EventEmitter {
584
624
  // Update routes in RouteManager
585
625
  this.routeManager.updateRoutes(newRoutes);
586
626
 
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);
627
+ // Release orphaned ports first
628
+ if (orphanedPorts.length > 0) {
629
+ try {
630
+ logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, {
631
+ ports: orphanedPorts,
632
+ component: 'smart-proxy'
633
+ });
634
+ } catch (error) {
635
+ // Silently handle logging errors
636
+ console.log(`[INFO] Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
637
+ }
638
+ await this.portManager.removePorts(orphanedPorts);
639
+ }
640
+
641
+ // Add new ports
642
+ if (newBindingPorts.length > 0) {
643
+ try {
644
+ logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, {
645
+ ports: newBindingPorts,
646
+ component: 'smart-proxy'
647
+ });
648
+ } catch (error) {
649
+ // Silently handle logging errors
650
+ console.log(`[INFO] Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`);
651
+ }
652
+ await this.portManager.addPorts(newBindingPorts);
653
+ }
592
654
 
593
655
  // Update settings with the new routes
594
656
  this.settings.routes = newRoutes;
657
+
658
+ // Save the new port usage map for future reference
659
+ this.portUsageMap = newPortUsage;
595
660
 
596
661
  // If HttpProxy is initialized, resync the configurations
597
662
  if (this.httpProxyBridge.getHttpProxy()) {
@@ -606,6 +671,22 @@ export class SmartProxy extends plugins.EventEmitter {
606
671
  // Store global state before stopping
607
672
  this.globalChallengeRouteActive = existingState.challengeRouteActive;
608
673
 
674
+ // Only stop the cert manager if absolutely necessary
675
+ // First check if there's an ACME route on the same port already
676
+ const acmePort = existingAcmeOptions?.port || 80;
677
+ const acmePortInUse = newPortUsage.has(acmePort) && newPortUsage.get(acmePort)!.size > 0;
678
+
679
+ try {
680
+ logger.log('debug', `ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`, {
681
+ port: acmePort,
682
+ inUse: acmePortInUse,
683
+ component: 'smart-proxy'
684
+ });
685
+ } catch (error) {
686
+ // Silently handle logging errors
687
+ console.log(`[DEBUG] ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`);
688
+ }
689
+
609
690
  await this.certManager.stop();
610
691
 
611
692
  // Verify the challenge route has been properly removed
@@ -637,6 +718,88 @@ export class SmartProxy extends plugins.EventEmitter {
637
718
 
638
719
  await this.certManager.provisionCertificate(route);
639
720
  }
721
+
722
+ /**
723
+ * Update the port usage map based on the provided routes
724
+ *
725
+ * This tracks which ports are used by which routes, allowing us to
726
+ * detect when a port is no longer needed and can be released.
727
+ */
728
+ private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
729
+ // Reset the usage map
730
+ const portUsage = new Map<number, Set<string>>();
731
+
732
+ for (const route of routes) {
733
+ // Get the ports for this route
734
+ const portsConfig = Array.isArray(route.match.ports)
735
+ ? route.match.ports
736
+ : [route.match.ports];
737
+
738
+ // Expand port range objects to individual port numbers
739
+ const expandedPorts: number[] = [];
740
+ for (const portConfig of portsConfig) {
741
+ if (typeof portConfig === 'number') {
742
+ expandedPorts.push(portConfig);
743
+ } else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
744
+ // Expand the port range
745
+ for (let p = portConfig.from; p <= portConfig.to; p++) {
746
+ expandedPorts.push(p);
747
+ }
748
+ }
749
+ }
750
+
751
+ // Use route name if available, otherwise generate a unique ID
752
+ const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
753
+
754
+ // Add each port to the usage map
755
+ for (const port of expandedPorts) {
756
+ if (!portUsage.has(port)) {
757
+ portUsage.set(port, new Set());
758
+ }
759
+ portUsage.get(port)!.add(routeName);
760
+ }
761
+ }
762
+
763
+ // Log port usage for debugging
764
+ for (const [port, routes] of portUsage.entries()) {
765
+ try {
766
+ logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
767
+ port,
768
+ routeCount: routes.size,
769
+ component: 'smart-proxy'
770
+ });
771
+ } catch (error) {
772
+ // Silently handle logging errors
773
+ console.log(`[DEBUG] Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
774
+ }
775
+ }
776
+
777
+ return portUsage;
778
+ }
779
+
780
+ /**
781
+ * Find ports that have no routes in the new configuration
782
+ */
783
+ private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
784
+ const orphanedPorts: number[] = [];
785
+
786
+ for (const [port, routes] of oldUsage.entries()) {
787
+ if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
788
+ orphanedPorts.push(port);
789
+ try {
790
+ logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
791
+ port,
792
+ component: 'smart-proxy'
793
+ });
794
+ } catch (error) {
795
+ // Silently handle logging errors
796
+ console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`);
797
+ }
798
+ }
799
+ }
800
+
801
+ return orphanedPorts;
802
+ }
640
803
 
641
804
  /**
642
805
  * Force renewal of a certificate