@push.rocks/smartproxy 19.0.0 → 19.2.3

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.0.0',
6
+ version: '19.2.3',
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
  }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { NetworkProxy } from '../network-proxy/index.js';
3
3
  import type { IRouteConfig, IRouteTls } from './models/route-types.js';
4
+ import type { IAcmeOptions } from './models/interfaces.js';
4
5
  import { CertStore } from './cert-store.js';
5
6
 
6
7
  export interface ICertStatus {
@@ -31,6 +32,9 @@ export class SmartCertManager {
31
32
  // Track certificate status by route name
32
33
  private certStatus: Map<string, ICertStatus> = new Map();
33
34
 
35
+ // Global ACME defaults from top-level configuration
36
+ private globalAcmeDefaults: IAcmeOptions | null = null;
37
+
34
38
  // Callback to update SmartProxy routes for challenges
35
39
  private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
36
40
 
@@ -50,6 +54,13 @@ export class SmartCertManager {
50
54
  this.networkProxy = networkProxy;
51
55
  }
52
56
 
57
+ /**
58
+ * Set global ACME defaults from top-level configuration
59
+ */
60
+ public setGlobalAcmeDefaults(defaults: IAcmeOptions): void {
61
+ this.globalAcmeDefaults = defaults;
62
+ }
63
+
53
64
  /**
54
65
  * Set callback for updating routes (used for challenge routes)
55
66
  */
@@ -146,7 +157,12 @@ export class SmartCertManager {
146
157
  domains: string[]
147
158
  ): Promise<void> {
148
159
  if (!this.smartAcme) {
149
- throw new Error('SmartAcme not initialized');
160
+ throw new Error(
161
+ 'SmartAcme not initialized. This usually means no ACME email was provided. ' +
162
+ 'Please ensure you have configured ACME with an email address either:\n' +
163
+ '1. In the top-level "acme" configuration\n' +
164
+ '2. In the route\'s "tls.acme" configuration'
165
+ );
150
166
  }
151
167
 
152
168
  const primaryDomain = domains[0];
@@ -161,7 +177,12 @@ export class SmartCertManager {
161
177
  return;
162
178
  }
163
179
 
164
- console.log(`Requesting ACME certificate for ${domains.join(', ')}`);
180
+ // Apply renewal threshold from global defaults or route config
181
+ const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
182
+ this.globalAcmeDefaults?.renewThresholdDays ||
183
+ 30;
184
+
185
+ console.log(`Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`);
165
186
  this.updateCertStatus(routeName, 'pending', 'acme');
166
187
 
167
188
  try {
@@ -303,7 +324,10 @@ export class SmartCertManager {
303
324
  */
304
325
  private isCertificateValid(cert: ICertificateData): boolean {
305
326
  const now = new Date();
306
- const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
327
+
328
+ // Use renewal threshold from global defaults or fallback to 30 days
329
+ const renewThresholdDays = this.globalAcmeDefaults?.renewThresholdDays || 30;
330
+ const expiryThreshold = new Date(now.getTime() + renewThresholdDays * 24 * 60 * 60 * 1000);
307
331
 
308
332
  return cert.expiryDate > expiryThreshold;
309
333
  }
@@ -417,12 +441,15 @@ export class SmartCertManager {
417
441
  * Setup challenge handler integration with SmartProxy routing
418
442
  */
419
443
  private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
444
+ // Use challenge port from global config or default to 80
445
+ const challengePort = this.globalAcmeDefaults?.port || 80;
446
+
420
447
  // Create a challenge route that delegates to SmartAcme's HTTP-01 handler
421
448
  const challengeRoute: IRouteConfig = {
422
449
  name: 'acme-challenge',
423
450
  priority: 1000, // High priority
424
451
  match: {
425
- ports: 80,
452
+ ports: challengePort,
426
453
  path: '/.well-known/acme-challenge/*'
427
454
  },
428
455
  action: {
@@ -2,15 +2,16 @@ import * as plugins from '../../../plugins.js';
2
2
  // Certificate types removed - define IAcmeOptions locally
3
3
  export interface IAcmeOptions {
4
4
  enabled?: boolean;
5
- email?: string;
5
+ email?: string; // Required when any route uses certificate: 'auto'
6
6
  environment?: 'production' | 'staging';
7
- port?: number;
8
- useProduction?: boolean;
9
- renewThresholdDays?: number;
10
- autoRenew?: boolean;
11
- certificateStore?: string;
7
+ accountEmail?: string; // Alias for email
8
+ port?: number; // Port for HTTP-01 challenges (default: 80)
9
+ useProduction?: boolean; // Use Let's Encrypt production (default: false)
10
+ renewThresholdDays?: number; // Days before expiry to renew (default: 30)
11
+ autoRenew?: boolean; // Enable automatic renewal (default: true)
12
+ certificateStore?: string; // Directory to store certificates (default: './certs')
12
13
  skipConfiguredCerts?: boolean;
13
- renewCheckIntervalHours?: number;
14
+ renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
14
15
  routeForwards?: any[];
15
16
  }
16
17
  import type { IRouteConfig } from './route-types.js';
@@ -97,7 +98,22 @@ export interface ISmartProxyOptions {
97
98
  useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
98
99
  networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
99
100
 
100
- // ACME configuration options for SmartProxy
101
+ /**
102
+ * Global ACME configuration options for SmartProxy
103
+ *
104
+ * When set, these options will be used as defaults for all routes
105
+ * with certificate: 'auto' that don't have their own ACME configuration.
106
+ * Route-specific ACME settings will override these defaults.
107
+ *
108
+ * Example:
109
+ * ```ts
110
+ * acme: {
111
+ * email: 'ssl@example.com',
112
+ * useProduction: false,
113
+ * port: 80
114
+ * }
115
+ * ```
116
+ */
101
117
  acme?: IAcmeOptions;
102
118
 
103
119
  /**
@@ -173,6 +173,13 @@ export class RouteManager extends plugins.EventEmitter {
173
173
  return this.portMap.get(port) || [];
174
174
  }
175
175
 
176
+ /**
177
+ * Get all routes
178
+ */
179
+ public getAllRoutes(): IRouteConfig[] {
180
+ return [...this.routes];
181
+ }
182
+
176
183
  /**
177
184
  * Test if a pattern matches a domain using glob matching
178
185
  */
@@ -115,20 +115,26 @@ export class SmartProxy extends plugins.EventEmitter {
115
115
  networkProxyPort: settingsArg.networkProxyPort || 8443,
116
116
  };
117
117
 
118
- // Set default ACME options if not provided
119
- this.settings.acme = this.settings.acme || {};
120
- if (Object.keys(this.settings.acme).length === 0) {
118
+ // Normalize ACME options if provided (support both email and accountEmail)
119
+ if (this.settings.acme) {
120
+ // Support both 'email' and 'accountEmail' fields
121
+ if (this.settings.acme.accountEmail && !this.settings.acme.email) {
122
+ this.settings.acme.email = this.settings.acme.accountEmail;
123
+ }
124
+
125
+ // Set reasonable defaults for commonly used fields
121
126
  this.settings.acme = {
122
- enabled: false,
123
- port: 80,
124
- email: 'admin@example.com',
125
- useProduction: false,
126
- renewThresholdDays: 30,
127
- autoRenew: true,
128
- certificateStore: './certs',
129
- skipConfiguredCerts: false,
130
- renewCheckIntervalHours: 24,
131
- routeForwards: []
127
+ enabled: this.settings.acme.enabled !== false, // Enable by default if acme object exists
128
+ port: this.settings.acme.port || 80,
129
+ email: this.settings.acme.email,
130
+ useProduction: this.settings.acme.useProduction || false,
131
+ renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
132
+ autoRenew: this.settings.acme.autoRenew !== false, // Enable by default
133
+ certificateStore: this.settings.acme.certificateStore || './certs',
134
+ skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
135
+ renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
136
+ routeForwards: this.settings.acme.routeForwards || [],
137
+ ...this.settings.acme // Preserve any additional fields
132
138
  };
133
139
  }
134
140
 
@@ -172,6 +178,36 @@ export class SmartProxy extends plugins.EventEmitter {
172
178
  */
173
179
  public settings: ISmartProxyOptions;
174
180
 
181
+ /**
182
+ * Helper method to create and configure certificate manager
183
+ * This ensures consistent setup including the required ACME callback
184
+ */
185
+ private async createCertificateManager(
186
+ routes: IRouteConfig[],
187
+ certStore: string = './certs',
188
+ acmeOptions?: any
189
+ ): Promise<SmartCertManager> {
190
+ const certManager = new SmartCertManager(routes, certStore, acmeOptions);
191
+
192
+ // Always set up the route update callback for ACME challenges
193
+ certManager.setUpdateRoutesCallback(async (routes) => {
194
+ await this.updateRoutes(routes);
195
+ });
196
+
197
+ // Connect with NetworkProxy if available
198
+ if (this.networkProxyBridge.getNetworkProxy()) {
199
+ certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
200
+ }
201
+
202
+ // Pass down the global ACME config if available
203
+ if (this.settings.acme) {
204
+ certManager.setGlobalAcmeDefaults(this.settings.acme);
205
+ }
206
+
207
+ await certManager.initialize();
208
+ return certManager;
209
+ }
210
+
175
211
  /**
176
212
  * Initialize certificate manager
177
213
  */
@@ -186,30 +222,50 @@ export class SmartProxy extends plugins.EventEmitter {
186
222
  return;
187
223
  }
188
224
 
189
- // Use the first auto route's ACME config as defaults
190
- const defaultAcme = autoRoutes[0]?.action.tls?.acme;
191
-
192
- this.certManager = new SmartCertManager(
193
- this.settings.routes,
194
- './certs', // Certificate directory
195
- defaultAcme ? {
196
- email: defaultAcme.email,
197
- useProduction: defaultAcme.useProduction,
198
- port: defaultAcme.challengePort || 80
199
- } : undefined
200
- );
201
-
202
- // Connect with NetworkProxy
203
- if (this.networkProxyBridge.getNetworkProxy()) {
204
- this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
225
+ // Prepare ACME options with priority:
226
+ // 1. Use top-level ACME config if available
227
+ // 2. Fall back to first auto route's ACME config
228
+ // 3. Otherwise use undefined
229
+ let acmeOptions: { email?: string; useProduction?: boolean; port?: number } | undefined;
230
+
231
+ if (this.settings.acme?.email) {
232
+ // Use top-level ACME config
233
+ acmeOptions = {
234
+ email: this.settings.acme.email,
235
+ useProduction: this.settings.acme.useProduction || false,
236
+ port: this.settings.acme.port || 80
237
+ };
238
+ console.log(`Using top-level ACME configuration with email: ${acmeOptions.email}`);
239
+ } else if (autoRoutes.length > 0) {
240
+ // Check for route-level ACME config
241
+ const routeWithAcme = autoRoutes.find(r => r.action.tls?.acme?.email);
242
+ if (routeWithAcme?.action.tls?.acme) {
243
+ const routeAcme = routeWithAcme.action.tls.acme;
244
+ acmeOptions = {
245
+ email: routeAcme.email,
246
+ useProduction: routeAcme.useProduction || false,
247
+ port: routeAcme.challengePort || 80
248
+ };
249
+ console.log(`Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`);
250
+ }
205
251
  }
206
252
 
207
- // Set route update callback for ACME challenges
208
- this.certManager.setUpdateRoutesCallback(async (routes) => {
209
- await this.updateRoutes(routes);
210
- });
253
+ // Validate we have required configuration
254
+ if (autoRoutes.length > 0 && !acmeOptions?.email) {
255
+ throw new Error(
256
+ 'ACME email is required for automatic certificate provisioning. ' +
257
+ 'Please provide email in either:\n' +
258
+ '1. Top-level "acme" configuration\n' +
259
+ '2. Individual route\'s "tls.acme" configuration'
260
+ );
261
+ }
211
262
 
212
- await this.certManager.initialize();
263
+ // Use the helper method to create and configure the certificate manager
264
+ this.certManager = await this.createCertificateManager(
265
+ this.settings.routes,
266
+ this.settings.acme?.certificateStore || './certs',
267
+ acmeOptions
268
+ );
213
269
  }
214
270
 
215
271
  /**
@@ -249,9 +305,14 @@ export class SmartProxy extends plugins.EventEmitter {
249
305
 
250
306
  // Validate the route configuration
251
307
  const configWarnings = this.routeManager.validateConfiguration();
252
- if (configWarnings.length > 0) {
253
- console.log("Route configuration warnings:");
254
- for (const warning of configWarnings) {
308
+
309
+ // Also validate ACME configuration
310
+ const acmeWarnings = this.validateAcmeConfiguration();
311
+ const allWarnings = [...configWarnings, ...acmeWarnings];
312
+
313
+ if (allWarnings.length > 0) {
314
+ console.log("Configuration warnings:");
315
+ for (const warning of allWarnings) {
255
316
  console.log(` - ${warning}`);
256
317
  }
257
318
  }
@@ -473,19 +534,15 @@ export class SmartProxy extends plugins.EventEmitter {
473
534
 
474
535
  // Update certificate manager with new routes
475
536
  if (this.certManager) {
537
+ const existingAcmeOptions = this.certManager.getAcmeOptions();
476
538
  await this.certManager.stop();
477
539
 
478
- this.certManager = new SmartCertManager(
540
+ // Use the helper method to create and configure the certificate manager
541
+ this.certManager = await this.createCertificateManager(
479
542
  newRoutes,
480
543
  './certs',
481
- this.certManager.getAcmeOptions()
544
+ existingAcmeOptions
482
545
  );
483
-
484
- if (this.networkProxyBridge.getNetworkProxy()) {
485
- this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
486
- }
487
-
488
- await this.certManager.initialize();
489
546
  }
490
547
  }
491
548
 
@@ -663,5 +720,76 @@ export class SmartProxy extends plugins.EventEmitter {
663
720
  public async getNfTablesStatus(): Promise<Record<string, any>> {
664
721
  return this.nftablesManager.getStatus();
665
722
  }
723
+
724
+ /**
725
+ * Validate ACME configuration
726
+ */
727
+ private validateAcmeConfiguration(): string[] {
728
+ const warnings: string[] = [];
729
+
730
+ // Check for routes with certificate: 'auto'
731
+ const autoRoutes = this.settings.routes.filter(r =>
732
+ r.action.tls?.certificate === 'auto'
733
+ );
734
+
735
+ if (autoRoutes.length === 0) {
736
+ return warnings;
737
+ }
738
+
739
+ // Check if we have ACME email configuration
740
+ const hasTopLevelEmail = this.settings.acme?.email;
741
+ const routesWithEmail = autoRoutes.filter(r => r.action.tls?.acme?.email);
742
+
743
+ if (!hasTopLevelEmail && routesWithEmail.length === 0) {
744
+ warnings.push(
745
+ 'Routes with certificate: "auto" require ACME email configuration. ' +
746
+ 'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.'
747
+ );
748
+ }
749
+
750
+ // Check for port 80 availability for challenges
751
+ if (autoRoutes.length > 0) {
752
+ const challengePort = this.settings.acme?.port || 80;
753
+ const portsInUse = this.routeManager.getListeningPorts();
754
+
755
+ if (!portsInUse.includes(challengePort)) {
756
+ warnings.push(
757
+ `Port ${challengePort} is not configured for any routes but is needed for ACME challenges. ` +
758
+ `Add a route listening on port ${challengePort} or ensure it's accessible for HTTP-01 challenges.`
759
+ );
760
+ }
761
+ }
762
+
763
+ // Check for mismatched environments
764
+ if (this.settings.acme?.useProduction) {
765
+ const stagingRoutes = autoRoutes.filter(r =>
766
+ r.action.tls?.acme?.useProduction === false
767
+ );
768
+ if (stagingRoutes.length > 0) {
769
+ warnings.push(
770
+ 'Top-level ACME uses production but some routes use staging. ' +
771
+ 'Consider aligning environments to avoid certificate issues.'
772
+ );
773
+ }
774
+ }
775
+
776
+ // Check for wildcard domains with auto certificates
777
+ for (const route of autoRoutes) {
778
+ const domains = Array.isArray(route.match.domains)
779
+ ? route.match.domains
780
+ : [route.match.domains];
781
+
782
+ const wildcardDomains = domains.filter(d => d?.includes('*'));
783
+ if (wildcardDomains.length > 0) {
784
+ warnings.push(
785
+ `Route "${route.name}" has wildcard domain(s) ${wildcardDomains.join(', ')} ` +
786
+ 'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' +
787
+ 'which are not currently supported. Use static certificates instead.'
788
+ );
789
+ }
790
+ }
791
+
792
+ return warnings;
793
+ }
666
794
 
667
795
  }