@push.rocks/smartproxy 19.2.4 → 19.2.6

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.
@@ -0,0 +1,112 @@
1
+ import type { IRouteConfig } from './models/route-types.js';
2
+
3
+ /**
4
+ * Global state store for ACME operations
5
+ * Tracks active challenge routes and port allocations
6
+ */
7
+ export class AcmeStateManager {
8
+ private activeChallengeRoutes: Map<string, IRouteConfig> = new Map();
9
+ private acmePortAllocations: Set<number> = new Set();
10
+ private primaryChallengeRoute: IRouteConfig | null = null;
11
+
12
+ /**
13
+ * Check if a challenge route is active
14
+ */
15
+ public isChallengeRouteActive(): boolean {
16
+ return this.activeChallengeRoutes.size > 0;
17
+ }
18
+
19
+ /**
20
+ * Register a challenge route as active
21
+ */
22
+ public addChallengeRoute(route: IRouteConfig): void {
23
+ this.activeChallengeRoutes.set(route.name, route);
24
+
25
+ // Track the primary challenge route
26
+ if (!this.primaryChallengeRoute || route.priority > (this.primaryChallengeRoute.priority || 0)) {
27
+ this.primaryChallengeRoute = route;
28
+ }
29
+
30
+ // Track port allocations
31
+ const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
32
+ ports.forEach(port => this.acmePortAllocations.add(port));
33
+ }
34
+
35
+ /**
36
+ * Remove a challenge route
37
+ */
38
+ public removeChallengeRoute(routeName: string): void {
39
+ const route = this.activeChallengeRoutes.get(routeName);
40
+ if (!route) return;
41
+
42
+ this.activeChallengeRoutes.delete(routeName);
43
+
44
+ // Update primary challenge route if needed
45
+ if (this.primaryChallengeRoute?.name === routeName) {
46
+ this.primaryChallengeRoute = null;
47
+ // Find new primary route with highest priority
48
+ let highestPriority = -1;
49
+ for (const [_, activeRoute] of this.activeChallengeRoutes) {
50
+ const priority = activeRoute.priority || 0;
51
+ if (priority > highestPriority) {
52
+ highestPriority = priority;
53
+ this.primaryChallengeRoute = activeRoute;
54
+ }
55
+ }
56
+ }
57
+
58
+ // Update port allocations - only remove if no other routes use this port
59
+ const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
60
+ ports.forEach(port => {
61
+ let portStillUsed = false;
62
+ for (const [_, activeRoute] of this.activeChallengeRoutes) {
63
+ const activePorts = Array.isArray(activeRoute.match.ports) ?
64
+ activeRoute.match.ports : [activeRoute.match.ports];
65
+ if (activePorts.includes(port)) {
66
+ portStillUsed = true;
67
+ break;
68
+ }
69
+ }
70
+ if (!portStillUsed) {
71
+ this.acmePortAllocations.delete(port);
72
+ }
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Get all active challenge routes
78
+ */
79
+ public getActiveChallengeRoutes(): IRouteConfig[] {
80
+ return Array.from(this.activeChallengeRoutes.values());
81
+ }
82
+
83
+ /**
84
+ * Get the primary challenge route
85
+ */
86
+ public getPrimaryChallengeRoute(): IRouteConfig | null {
87
+ return this.primaryChallengeRoute;
88
+ }
89
+
90
+ /**
91
+ * Check if a port is allocated for ACME
92
+ */
93
+ public isPortAllocatedForAcme(port: number): boolean {
94
+ return this.acmePortAllocations.has(port);
95
+ }
96
+
97
+ /**
98
+ * Get all ACME ports
99
+ */
100
+ public getAcmePorts(): number[] {
101
+ return Array.from(this.acmePortAllocations);
102
+ }
103
+
104
+ /**
105
+ * Clear all state (for shutdown or reset)
106
+ */
107
+ public clear(): void {
108
+ this.activeChallengeRoutes.clear();
109
+ this.acmePortAllocations.clear();
110
+ this.primaryChallengeRoute = null;
111
+ }
112
+ }
@@ -3,6 +3,7 @@ import { NetworkProxy } from '../network-proxy/index.js';
3
3
  import type { IRouteConfig, IRouteTls } from './models/route-types.js';
4
4
  import type { IAcmeOptions } from './models/interfaces.js';
5
5
  import { CertStore } from './cert-store.js';
6
+ import type { AcmeStateManager } from './acme-state-manager.js';
6
7
 
7
8
  export interface ICertStatus {
8
9
  domain: string;
@@ -44,6 +45,9 @@ export class SmartCertManager {
44
45
  // Flag to track if provisioning is in progress
45
46
  private isProvisioning: boolean = false;
46
47
 
48
+ // ACME state manager reference
49
+ private acmeStateManager: AcmeStateManager | null = null;
50
+
47
51
  constructor(
48
52
  private routes: IRouteConfig[],
49
53
  private certDir: string = './certs',
@@ -51,15 +55,39 @@ export class SmartCertManager {
51
55
  email?: string;
52
56
  useProduction?: boolean;
53
57
  port?: number;
58
+ },
59
+ private initialState?: {
60
+ challengeRouteActive?: boolean;
54
61
  }
55
62
  ) {
56
63
  this.certStore = new CertStore(certDir);
64
+
65
+ // Apply initial state if provided
66
+ if (initialState) {
67
+ this.challengeRouteActive = initialState.challengeRouteActive || false;
68
+ }
57
69
  }
58
70
 
59
71
  public setNetworkProxy(networkProxy: NetworkProxy): void {
60
72
  this.networkProxy = networkProxy;
61
73
  }
62
74
 
75
+ /**
76
+ * Get the current state of the certificate manager
77
+ */
78
+ public getState(): { challengeRouteActive: boolean } {
79
+ return {
80
+ challengeRouteActive: this.challengeRouteActive
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Set the ACME state manager
86
+ */
87
+ public setAcmeStateManager(stateManager: AcmeStateManager): void {
88
+ this.acmeStateManager = stateManager;
89
+ }
90
+
63
91
  /**
64
92
  * Set global ACME defaults from top-level configuration
65
93
  */
@@ -103,9 +131,13 @@ export class SmartCertManager {
103
131
 
104
132
  await this.smartAcme.start();
105
133
 
106
- // Add challenge route once at initialization
107
- console.log('Adding ACME challenge route during initialization');
108
- await this.addChallengeRoute();
134
+ // Add challenge route once at initialization if not already active
135
+ if (!this.challengeRouteActive) {
136
+ console.log('Adding ACME challenge route during initialization');
137
+ await this.addChallengeRoute();
138
+ } else {
139
+ console.log('Challenge route already active from previous instance');
140
+ }
109
141
  }
110
142
 
111
143
  // Provision certificates for all routes
@@ -350,8 +382,15 @@ export class SmartCertManager {
350
382
  * Add challenge route to SmartProxy
351
383
  */
352
384
  private async addChallengeRoute(): Promise<void> {
385
+ // Check with state manager first
386
+ if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) {
387
+ console.log('Challenge route already active in global state, skipping');
388
+ this.challengeRouteActive = true;
389
+ return;
390
+ }
391
+
353
392
  if (this.challengeRouteActive) {
354
- console.log('Challenge route already active, skipping');
393
+ console.log('Challenge route already active locally, skipping');
355
394
  return;
356
395
  }
357
396
 
@@ -368,6 +407,12 @@ export class SmartCertManager {
368
407
  const updatedRoutes = [...this.routes, challengeRoute];
369
408
  await this.updateRoutesCallback(updatedRoutes);
370
409
  this.challengeRouteActive = true;
410
+
411
+ // Register with state manager
412
+ if (this.acmeStateManager) {
413
+ this.acmeStateManager.addChallengeRoute(challengeRoute);
414
+ }
415
+
371
416
  console.log('ACME challenge route successfully added');
372
417
  } catch (error) {
373
418
  console.error('Failed to add challenge route:', error);
@@ -395,6 +440,12 @@ export class SmartCertManager {
395
440
  const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
396
441
  await this.updateRoutesCallback(filteredRoutes);
397
442
  this.challengeRouteActive = false;
443
+
444
+ // Remove from state manager
445
+ if (this.acmeStateManager) {
446
+ this.acmeStateManager.removeChallengeRoute('acme-challenge');
447
+ }
448
+
398
449
  console.log('ACME challenge route successfully removed');
399
450
  } catch (error) {
400
451
  console.error('Failed to remove challenge route:', error);
@@ -20,6 +20,12 @@ import type {
20
20
  } from './models/interfaces.js';
21
21
  import type { IRouteConfig } from './models/route-types.js';
22
22
 
23
+ // Import mutex for route update synchronization
24
+ import { Mutex } from './utils/mutex.js';
25
+
26
+ // Import ACME state manager
27
+ import { AcmeStateManager } from './acme-state-manager.js';
28
+
23
29
  /**
24
30
  * SmartProxy - Pure route-based API
25
31
  *
@@ -52,6 +58,11 @@ export class SmartProxy extends plugins.EventEmitter {
52
58
  // Certificate manager for ACME and static certificates
53
59
  private certManager: SmartCertManager | null = null;
54
60
 
61
+ // Global challenge route tracking
62
+ private globalChallengeRouteActive: boolean = false;
63
+ private routeUpdateLock: any = null; // Will be initialized as AsyncMutex
64
+ private acmeStateManager: AcmeStateManager;
65
+
55
66
  /**
56
67
  * Constructor for SmartProxy
57
68
  *
@@ -171,6 +182,12 @@ export class SmartProxy extends plugins.EventEmitter {
171
182
 
172
183
  // Initialize NFTablesManager
173
184
  this.nftablesManager = new NFTablesManager(this.settings);
185
+
186
+ // Initialize route update mutex for synchronization
187
+ this.routeUpdateLock = new Mutex();
188
+
189
+ // Initialize ACME state manager
190
+ this.acmeStateManager = new AcmeStateManager();
174
191
  }
175
192
 
176
193
  /**
@@ -185,9 +202,10 @@ export class SmartProxy extends plugins.EventEmitter {
185
202
  private async createCertificateManager(
186
203
  routes: IRouteConfig[],
187
204
  certStore: string = './certs',
188
- acmeOptions?: any
205
+ acmeOptions?: any,
206
+ initialState?: { challengeRouteActive?: boolean }
189
207
  ): Promise<SmartCertManager> {
190
- const certManager = new SmartCertManager(routes, certStore, acmeOptions);
208
+ const certManager = new SmartCertManager(routes, certStore, acmeOptions, initialState);
191
209
 
192
210
  // Always set up the route update callback for ACME challenges
193
211
  certManager.setUpdateRoutesCallback(async (routes) => {
@@ -199,6 +217,9 @@ export class SmartProxy extends plugins.EventEmitter {
199
217
  certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
200
218
  }
201
219
 
220
+ // Set the ACME state manager
221
+ certManager.setAcmeStateManager(this.acmeStateManager);
222
+
202
223
  // Pass down the global ACME config if available
203
224
  if (this.settings.acme) {
204
225
  certManager.setGlobalAcmeDefaults(this.settings.acme);
@@ -441,7 +462,9 @@ export class SmartProxy extends plugins.EventEmitter {
441
462
 
442
463
  // Stop NetworkProxy
443
464
  await this.networkProxyBridge.stop();
444
-
465
+
466
+ // Clear ACME state manager
467
+ this.acmeStateManager.clear();
445
468
 
446
469
  console.log('SmartProxy shutdown complete.');
447
470
  }
@@ -456,6 +479,29 @@ export class SmartProxy extends plugins.EventEmitter {
456
479
  throw new Error('updateDomainConfigs() is deprecated - use updateRoutes() instead');
457
480
  }
458
481
 
482
+ /**
483
+ * Verify the challenge route has been properly removed from routes
484
+ */
485
+ private async verifyChallengeRouteRemoved(): Promise<void> {
486
+ const maxRetries = 10;
487
+ const retryDelay = 100; // milliseconds
488
+
489
+ for (let i = 0; i < maxRetries; i++) {
490
+ // Check if the challenge route is still in the active routes
491
+ const challengeRouteExists = this.settings.routes.some(r => r.name === 'acme-challenge');
492
+
493
+ if (!challengeRouteExists) {
494
+ console.log('Challenge route successfully removed from routes');
495
+ return;
496
+ }
497
+
498
+ // Wait before retrying
499
+ await plugins.smartdelay.delayFor(retryDelay);
500
+ }
501
+
502
+ throw new Error('Failed to verify challenge route removal after ' + maxRetries + ' attempts');
503
+ }
504
+
459
505
  /**
460
506
  * Update routes with new configuration
461
507
  *
@@ -480,70 +526,81 @@ export class SmartProxy extends plugins.EventEmitter {
480
526
  * ```
481
527
  */
482
528
  public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
483
- console.log(`Updating routes (${newRoutes.length} routes)`);
529
+ return this.routeUpdateLock.runExclusive(async () => {
530
+ console.log(`Updating routes (${newRoutes.length} routes)`);
484
531
 
485
- // Get existing routes that use NFTables
486
- const oldNfTablesRoutes = this.settings.routes.filter(
487
- r => r.action.forwardingEngine === 'nftables'
488
- );
489
-
490
- // Get new routes that use NFTables
491
- const newNfTablesRoutes = newRoutes.filter(
492
- r => r.action.forwardingEngine === 'nftables'
493
- );
494
-
495
- // Find routes to remove, update, or add
496
- for (const oldRoute of oldNfTablesRoutes) {
497
- const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
532
+ // Get existing routes that use NFTables
533
+ const oldNfTablesRoutes = this.settings.routes.filter(
534
+ r => r.action.forwardingEngine === 'nftables'
535
+ );
498
536
 
499
- if (!newRoute) {
500
- // Route was removed
501
- await this.nftablesManager.deprovisionRoute(oldRoute);
502
- } else {
503
- // Route was updated
504
- await this.nftablesManager.updateRoute(oldRoute, newRoute);
537
+ // Get new routes that use NFTables
538
+ const newNfTablesRoutes = newRoutes.filter(
539
+ r => r.action.forwardingEngine === 'nftables'
540
+ );
541
+
542
+ // Find routes to remove, update, or add
543
+ for (const oldRoute of oldNfTablesRoutes) {
544
+ const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
545
+
546
+ if (!newRoute) {
547
+ // Route was removed
548
+ await this.nftablesManager.deprovisionRoute(oldRoute);
549
+ } else {
550
+ // Route was updated
551
+ await this.nftablesManager.updateRoute(oldRoute, newRoute);
552
+ }
505
553
  }
506
- }
507
-
508
- // Find new routes to add
509
- for (const newRoute of newNfTablesRoutes) {
510
- const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
511
554
 
512
- if (!oldRoute) {
513
- // New route
514
- await this.nftablesManager.provisionRoute(newRoute);
555
+ // Find new routes to add
556
+ for (const newRoute of newNfTablesRoutes) {
557
+ const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
558
+
559
+ if (!oldRoute) {
560
+ // New route
561
+ await this.nftablesManager.provisionRoute(newRoute);
562
+ }
515
563
  }
516
- }
517
564
 
518
- // Update routes in RouteManager
519
- this.routeManager.updateRoutes(newRoutes);
565
+ // Update routes in RouteManager
566
+ this.routeManager.updateRoutes(newRoutes);
520
567
 
521
- // Get the new set of required ports
522
- const requiredPorts = this.routeManager.getListeningPorts();
568
+ // Get the new set of required ports
569
+ const requiredPorts = this.routeManager.getListeningPorts();
523
570
 
524
- // Update port listeners to match the new configuration
525
- await this.portManager.updatePorts(requiredPorts);
526
-
527
- // Update settings with the new routes
528
- this.settings.routes = newRoutes;
571
+ // Update port listeners to match the new configuration
572
+ await this.portManager.updatePorts(requiredPorts);
573
+
574
+ // Update settings with the new routes
575
+ this.settings.routes = newRoutes;
529
576
 
530
- // If NetworkProxy is initialized, resync the configurations
531
- if (this.networkProxyBridge.getNetworkProxy()) {
532
- await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes);
533
- }
577
+ // If NetworkProxy is initialized, resync the configurations
578
+ if (this.networkProxyBridge.getNetworkProxy()) {
579
+ await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes);
580
+ }
534
581
 
535
- // Update certificate manager with new routes
536
- if (this.certManager) {
537
- const existingAcmeOptions = this.certManager.getAcmeOptions();
538
- await this.certManager.stop();
539
-
540
- // Use the helper method to create and configure the certificate manager
541
- this.certManager = await this.createCertificateManager(
542
- newRoutes,
543
- './certs',
544
- existingAcmeOptions
545
- );
546
- }
582
+ // Update certificate manager with new routes
583
+ if (this.certManager) {
584
+ const existingAcmeOptions = this.certManager.getAcmeOptions();
585
+ const existingState = this.certManager.getState();
586
+
587
+ // Store global state before stopping
588
+ this.globalChallengeRouteActive = existingState.challengeRouteActive;
589
+
590
+ await this.certManager.stop();
591
+
592
+ // Verify the challenge route has been properly removed
593
+ await this.verifyChallengeRouteRemoved();
594
+
595
+ // Create new certificate manager with preserved state
596
+ this.certManager = await this.createCertificateManager(
597
+ newRoutes,
598
+ './certs',
599
+ existingAcmeOptions,
600
+ { challengeRouteActive: this.globalChallengeRouteActive }
601
+ );
602
+ }
603
+ });
547
604
  }
548
605
 
549
606
  /**
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Simple mutex implementation for async operations
3
+ */
4
+ export class Mutex {
5
+ private isLocked: boolean = false;
6
+ private waitQueue: Array<() => void> = [];
7
+
8
+ /**
9
+ * Acquire the lock
10
+ */
11
+ async acquire(): Promise<void> {
12
+ return new Promise<void>((resolve) => {
13
+ if (!this.isLocked) {
14
+ this.isLocked = true;
15
+ resolve();
16
+ } else {
17
+ this.waitQueue.push(resolve);
18
+ }
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Release the lock
24
+ */
25
+ release(): void {
26
+ this.isLocked = false;
27
+ const nextResolve = this.waitQueue.shift();
28
+ if (nextResolve) {
29
+ this.isLocked = true;
30
+ nextResolve();
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Run a function exclusively with the lock
36
+ */
37
+ async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
38
+ await this.acquire();
39
+ try {
40
+ return await fn();
41
+ } finally {
42
+ this.release();
43
+ }
44
+ }
45
+ }
@@ -1,111 +0,0 @@
1
- import * as plugins from '../plugins.js';
2
-
3
- import type {
4
- IForwardConfig as ILegacyForwardConfig,
5
- IDomainOptions
6
- } from './types.js';
7
-
8
- import type {
9
- IForwardConfig
10
- } from '../forwarding/config/forwarding-types.js';
11
-
12
- /**
13
- * Converts a forwarding configuration target to the legacy format
14
- * for Port80Handler
15
- */
16
- export function convertToLegacyForwardConfig(
17
- forwardConfig: IForwardConfig
18
- ): ILegacyForwardConfig {
19
- // Determine host from the target configuration
20
- const host = Array.isArray(forwardConfig.target.host)
21
- ? forwardConfig.target.host[0] // Use the first host in the array
22
- : forwardConfig.target.host;
23
-
24
- // Extract port number, handling different port formats
25
- let port: number;
26
- if (typeof forwardConfig.target.port === 'function') {
27
- // Use a default port for function-based ports in adapter context
28
- port = 80;
29
- } else if (forwardConfig.target.port === 'preserve') {
30
- // For 'preserve', use the default port 80 in this adapter context
31
- port = 80;
32
- } else {
33
- port = forwardConfig.target.port;
34
- }
35
-
36
- return {
37
- ip: host,
38
- port: port
39
- };
40
- }
41
-
42
- /**
43
- * Creates Port80Handler domain options from a domain name and forwarding config
44
- */
45
- export function createPort80HandlerOptions(
46
- domain: string,
47
- forwardConfig: IForwardConfig
48
- ): IDomainOptions {
49
- // Determine if we should redirect HTTP to HTTPS
50
- let sslRedirect = false;
51
- if (forwardConfig.http?.redirectToHttps) {
52
- sslRedirect = true;
53
- }
54
-
55
- // Determine if ACME maintenance should be enabled
56
- // Enable by default for termination types, unless explicitly disabled
57
- const requiresTls =
58
- forwardConfig.type === 'https-terminate-to-http' ||
59
- forwardConfig.type === 'https-terminate-to-https';
60
-
61
- const acmeMaintenance =
62
- requiresTls &&
63
- forwardConfig.acme?.enabled !== false;
64
-
65
- // Set up forwarding configuration
66
- const options: IDomainOptions = {
67
- domainName: domain,
68
- sslRedirect,
69
- acmeMaintenance
70
- };
71
-
72
- // Add ACME challenge forwarding if configured
73
- if (forwardConfig.acme?.forwardChallenges) {
74
- options.acmeForward = {
75
- ip: Array.isArray(forwardConfig.acme.forwardChallenges.host)
76
- ? forwardConfig.acme.forwardChallenges.host[0]
77
- : forwardConfig.acme.forwardChallenges.host,
78
- port: forwardConfig.acme.forwardChallenges.port
79
- };
80
- }
81
-
82
- // Add HTTP forwarding if this is an HTTP-only config or if HTTP is enabled
83
- const supportsHttp =
84
- forwardConfig.type === 'http-only' ||
85
- (forwardConfig.http?.enabled !== false &&
86
- (forwardConfig.type === 'https-terminate-to-http' ||
87
- forwardConfig.type === 'https-terminate-to-https'));
88
-
89
- if (supportsHttp) {
90
- // Determine port value handling different formats
91
- let port: number;
92
- if (typeof forwardConfig.target.port === 'function') {
93
- // Use a default port for function-based ports
94
- port = 80;
95
- } else if (forwardConfig.target.port === 'preserve') {
96
- // For 'preserve', use 80 in this adapter context
97
- port = 80;
98
- } else {
99
- port = forwardConfig.target.port;
100
- }
101
-
102
- options.forward = {
103
- ip: Array.isArray(forwardConfig.target.host)
104
- ? forwardConfig.target.host[0]
105
- : forwardConfig.target.host,
106
- port: port
107
- };
108
- }
109
-
110
- return options;
111
- }