@push.rocks/smartproxy 4.2.4 → 4.3.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.
@@ -1,9 +1,58 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { IncomingMessage, ServerResponse } from 'http';
2
3
 
3
4
  /**
4
- * Represents a domain certificate with various status information
5
+ * Custom error classes for better error handling
6
+ */
7
+ export class Port80HandlerError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = 'Port80HandlerError';
11
+ }
12
+ }
13
+
14
+ export class CertificateError extends Port80HandlerError {
15
+ constructor(
16
+ message: string,
17
+ public readonly domain: string,
18
+ public readonly isRenewal: boolean = false
19
+ ) {
20
+ super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
21
+ this.name = 'CertificateError';
22
+ }
23
+ }
24
+
25
+ export class ServerError extends Port80HandlerError {
26
+ constructor(message: string, public readonly code?: string) {
27
+ super(message);
28
+ this.name = 'ServerError';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Domain forwarding configuration
34
+ */
35
+ export interface IForwardConfig {
36
+ ip: string;
37
+ port: number;
38
+ }
39
+
40
+ /**
41
+ * Domain configuration options
42
+ */
43
+ export interface IDomainOptions {
44
+ domainName: string;
45
+ sslRedirect: boolean; // if true redirects the request to port 443
46
+ acmeMaintenance: boolean; // tries to always have a valid cert for this domain
47
+ forward?: IForwardConfig; // forwards all http requests to that target
48
+ acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
49
+ }
50
+
51
+ /**
52
+ * Represents a domain configuration with certificate status information
5
53
  */
6
54
  interface IDomainCertificate {
55
+ options: IDomainOptions;
7
56
  certObtained: boolean;
8
57
  obtainingInProgress: boolean;
9
58
  certificate?: string;
@@ -15,9 +64,9 @@ interface IDomainCertificate {
15
64
  }
16
65
 
17
66
  /**
18
- * Configuration options for the ACME Certificate Manager
67
+ * Configuration options for the Port80Handler
19
68
  */
20
- interface IAcmeCertManagerOptions {
69
+ interface IPort80HandlerOptions {
21
70
  port?: number;
22
71
  contactEmail?: string;
23
72
  useProduction?: boolean;
@@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions {
29
78
  /**
30
79
  * Certificate data that can be emitted via events or set from outside
31
80
  */
32
- interface ICertificateData {
81
+ export interface ICertificateData {
33
82
  domain: string;
34
83
  certificate: string;
35
84
  privateKey: string;
@@ -37,34 +86,54 @@ interface ICertificateData {
37
86
  }
38
87
 
39
88
  /**
40
- * Events emitted by the ACME Certificate Manager
89
+ * Events emitted by the Port80Handler
41
90
  */
42
- export enum CertManagerEvents {
91
+ export enum Port80HandlerEvents {
43
92
  CERTIFICATE_ISSUED = 'certificate-issued',
44
93
  CERTIFICATE_RENEWED = 'certificate-renewed',
45
94
  CERTIFICATE_FAILED = 'certificate-failed',
46
95
  CERTIFICATE_EXPIRING = 'certificate-expiring',
47
96
  MANAGER_STARTED = 'manager-started',
48
97
  MANAGER_STOPPED = 'manager-stopped',
98
+ REQUEST_FORWARDED = 'request-forwarded',
49
99
  }
50
100
 
51
101
  /**
52
- * Improved ACME Certificate Manager with event emission and external certificate management
102
+ * Certificate failure payload type
53
103
  */
54
- export class AcmeCertManager extends plugins.EventEmitter {
104
+ export interface ICertificateFailure {
105
+ domain: string;
106
+ error: string;
107
+ isRenewal: boolean;
108
+ }
109
+
110
+ /**
111
+ * Certificate expiry payload type
112
+ */
113
+ export interface ICertificateExpiring {
114
+ domain: string;
115
+ expiryDate: Date;
116
+ daysRemaining: number;
117
+ }
118
+
119
+ /**
120
+ * Port80Handler with ACME certificate management and request forwarding capabilities
121
+ * Now with glob pattern support for domain matching
122
+ */
123
+ export class Port80Handler extends plugins.EventEmitter {
55
124
  private domainCertificates: Map<string, IDomainCertificate>;
56
125
  private server: plugins.http.Server | null = null;
57
126
  private acmeClient: plugins.acme.Client | null = null;
58
127
  private accountKey: string | null = null;
59
128
  private renewalTimer: NodeJS.Timeout | null = null;
60
129
  private isShuttingDown: boolean = false;
61
- private options: Required<IAcmeCertManagerOptions>;
130
+ private options: Required<IPort80HandlerOptions>;
62
131
 
63
132
  /**
64
- * Creates a new ACME Certificate Manager
133
+ * Creates a new Port80Handler
65
134
  * @param options Configuration options
66
135
  */
67
- constructor(options: IAcmeCertManagerOptions = {}) {
136
+ constructor(options: IPort80HandlerOptions = {}) {
68
137
  super();
69
138
  this.domainCertificates = new Map<string, IDomainCertificate>();
70
139
 
@@ -73,7 +142,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
73
142
  port: options.port ?? 80,
74
143
  contactEmail: options.contactEmail ?? 'admin@example.com',
75
144
  useProduction: options.useProduction ?? false, // Safer default: staging
76
- renewThresholdDays: options.renewThresholdDays ?? 30,
145
+ renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
77
146
  httpsRedirectPort: options.httpsRedirectPort ?? 443,
78
147
  renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
79
148
  };
@@ -84,11 +153,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
84
153
  */
85
154
  public async start(): Promise<void> {
86
155
  if (this.server) {
87
- throw new Error('Server is already running');
156
+ throw new ServerError('Server is already running');
88
157
  }
89
158
 
90
159
  if (this.isShuttingDown) {
91
- throw new Error('Server is shutting down');
160
+ throw new ServerError('Server is shutting down');
92
161
  }
93
162
 
94
163
  return new Promise((resolve, reject) => {
@@ -97,22 +166,39 @@ export class AcmeCertManager extends plugins.EventEmitter {
97
166
 
98
167
  this.server.on('error', (error: NodeJS.ErrnoException) => {
99
168
  if (error.code === 'EACCES') {
100
- reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`));
169
+ reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
101
170
  } else if (error.code === 'EADDRINUSE') {
102
- reject(new Error(`Port ${this.options.port} is already in use.`));
171
+ reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
103
172
  } else {
104
- reject(error);
173
+ reject(new ServerError(error.message, error.code));
105
174
  }
106
175
  });
107
176
 
108
177
  this.server.listen(this.options.port, () => {
109
- console.log(`AcmeCertManager is listening on port ${this.options.port}`);
178
+ console.log(`Port80Handler is listening on port ${this.options.port}`);
110
179
  this.startRenewalTimer();
111
- this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port);
180
+ this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
181
+
182
+ // Start certificate process for domains with acmeMaintenance enabled
183
+ for (const [domain, domainInfo] of this.domainCertificates.entries()) {
184
+ // Skip glob patterns for certificate issuance
185
+ if (this.isGlobPattern(domain)) {
186
+ console.log(`Skipping initial certificate for glob pattern: ${domain}`);
187
+ continue;
188
+ }
189
+
190
+ if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
191
+ this.obtainCertificate(domain).catch(err => {
192
+ console.error(`Error obtaining initial certificate for ${domain}:`, err);
193
+ });
194
+ }
195
+ }
196
+
112
197
  resolve();
113
198
  });
114
199
  } catch (error) {
115
- reject(error);
200
+ const message = error instanceof Error ? error.message : 'Unknown error starting server';
201
+ reject(new ServerError(message));
116
202
  }
117
203
  });
118
204
  }
@@ -138,7 +224,7 @@ export class AcmeCertManager extends plugins.EventEmitter {
138
224
  this.server.close(() => {
139
225
  this.server = null;
140
226
  this.isShuttingDown = false;
141
- this.emit(CertManagerEvents.MANAGER_STOPPED);
227
+ this.emit(Port80HandlerEvents.MANAGER_STOPPED);
142
228
  resolve();
143
229
  });
144
230
  } else {
@@ -149,13 +235,41 @@ export class AcmeCertManager extends plugins.EventEmitter {
149
235
  }
150
236
 
151
237
  /**
152
- * Adds a domain to be managed for certificates
153
- * @param domain The domain to add
238
+ * Adds a domain with configuration options
239
+ * @param options Domain configuration options
154
240
  */
155
- public addDomain(domain: string): void {
156
- if (!this.domainCertificates.has(domain)) {
157
- this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
158
- console.log(`Domain added: ${domain}`);
241
+ public addDomain(options: IDomainOptions): void {
242
+ if (!options.domainName || typeof options.domainName !== 'string') {
243
+ throw new Port80HandlerError('Invalid domain name');
244
+ }
245
+
246
+ const domainName = options.domainName;
247
+
248
+ if (!this.domainCertificates.has(domainName)) {
249
+ this.domainCertificates.set(domainName, {
250
+ options,
251
+ certObtained: false,
252
+ obtainingInProgress: false
253
+ });
254
+
255
+ console.log(`Domain added: ${domainName} with configuration:`, {
256
+ sslRedirect: options.sslRedirect,
257
+ acmeMaintenance: options.acmeMaintenance,
258
+ hasForward: !!options.forward,
259
+ hasAcmeForward: !!options.acmeForward
260
+ });
261
+
262
+ // If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
263
+ if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
264
+ this.obtainCertificate(domainName).catch(err => {
265
+ console.error(`Error obtaining initial certificate for ${domainName}:`, err);
266
+ });
267
+ }
268
+ } else {
269
+ // Update existing domain with new options
270
+ const existing = this.domainCertificates.get(domainName)!;
271
+ existing.options = options;
272
+ console.log(`Domain ${domainName} configuration updated`);
159
273
  }
160
274
  }
161
275
 
@@ -177,10 +291,30 @@ export class AcmeCertManager extends plugins.EventEmitter {
177
291
  * @param expiryDate Optional expiry date
178
292
  */
179
293
  public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
294
+ if (!domain || !certificate || !privateKey) {
295
+ throw new Port80HandlerError('Domain, certificate and privateKey are required');
296
+ }
297
+
298
+ // Don't allow setting certificates for glob patterns
299
+ if (this.isGlobPattern(domain)) {
300
+ throw new Port80HandlerError('Cannot set certificate for glob pattern domains');
301
+ }
302
+
180
303
  let domainInfo = this.domainCertificates.get(domain);
181
304
 
182
305
  if (!domainInfo) {
183
- domainInfo = { certObtained: false, obtainingInProgress: false };
306
+ // Create default domain options if not already configured
307
+ const defaultOptions: IDomainOptions = {
308
+ domainName: domain,
309
+ sslRedirect: true,
310
+ acmeMaintenance: true
311
+ };
312
+
313
+ domainInfo = {
314
+ options: defaultOptions,
315
+ certObtained: false,
316
+ obtainingInProgress: false
317
+ };
184
318
  this.domainCertificates.set(domain, domainInfo);
185
319
  }
186
320
 
@@ -192,27 +326,18 @@ export class AcmeCertManager extends plugins.EventEmitter {
192
326
  if (expiryDate) {
193
327
  domainInfo.expiryDate = expiryDate;
194
328
  } else {
195
- // Try to extract expiry date from certificate
196
- try {
197
- // This is a simplistic approach - in a real implementation, use a proper
198
- // certificate parsing library like node-forge or x509
199
- const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
200
- if (matches && matches[1]) {
201
- domainInfo.expiryDate = new Date(matches[1]);
202
- }
203
- } catch (error) {
204
- console.warn(`Failed to extract expiry date from certificate for ${domain}`);
205
- }
329
+ // Extract expiry date from certificate
330
+ domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
206
331
  }
207
332
 
208
333
  console.log(`Certificate set for ${domain}`);
209
334
 
210
335
  // Emit certificate event
211
- this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, {
336
+ this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
212
337
  domain,
213
338
  certificate,
214
339
  privateKey,
215
- expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
340
+ expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
216
341
  });
217
342
  }
218
343
 
@@ -221,6 +346,11 @@ export class AcmeCertManager extends plugins.EventEmitter {
221
346
  * @param domain The domain to get the certificate for
222
347
  */
223
348
  public getCertificate(domain: string): ICertificateData | null {
349
+ // Can't get certificates for glob patterns
350
+ if (this.isGlobPattern(domain)) {
351
+ return null;
352
+ }
353
+
224
354
  const domainInfo = this.domainCertificates.get(domain);
225
355
 
226
356
  if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
@@ -231,10 +361,69 @@ export class AcmeCertManager extends plugins.EventEmitter {
231
361
  domain,
232
362
  certificate: domainInfo.certificate,
233
363
  privateKey: domainInfo.privateKey,
234
- expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
364
+ expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
235
365
  };
236
366
  }
237
367
 
368
+ /**
369
+ * Check if a domain is a glob pattern
370
+ * @param domain Domain to check
371
+ * @returns True if the domain is a glob pattern
372
+ */
373
+ private isGlobPattern(domain: string): boolean {
374
+ return domain.includes('*');
375
+ }
376
+
377
+ /**
378
+ * Get domain info for a specific domain, using glob pattern matching if needed
379
+ * @param requestDomain The actual domain from the request
380
+ * @returns The domain info or null if not found
381
+ */
382
+ private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
383
+ // Try direct match first
384
+ if (this.domainCertificates.has(requestDomain)) {
385
+ return {
386
+ domainInfo: this.domainCertificates.get(requestDomain)!,
387
+ pattern: requestDomain
388
+ };
389
+ }
390
+
391
+ // Then try glob patterns
392
+ for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
393
+ if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
394
+ return { domainInfo, pattern };
395
+ }
396
+ }
397
+
398
+ return null;
399
+ }
400
+
401
+ /**
402
+ * Check if a domain matches a glob pattern
403
+ * @param domain The domain to check
404
+ * @param pattern The pattern to match against
405
+ * @returns True if the domain matches the pattern
406
+ */
407
+ private domainMatchesPattern(domain: string, pattern: string): boolean {
408
+ // Handle different glob pattern styles
409
+ if (pattern.startsWith('*.')) {
410
+ // *.example.com matches any subdomain
411
+ const suffix = pattern.substring(2);
412
+ return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
413
+ } else if (pattern.endsWith('.*')) {
414
+ // example.* matches any TLD
415
+ const prefix = pattern.substring(0, pattern.length - 2);
416
+ const domainParts = domain.split('.');
417
+ return domain.startsWith(prefix + '.') && domainParts.length >= 2;
418
+ } else if (pattern === '*') {
419
+ // Wildcard matches everything
420
+ return true;
421
+ } else {
422
+ // Exact match (shouldn't reach here as we check exact matches first)
423
+ return domain === pattern;
424
+ }
425
+ }
426
+
238
427
  /**
239
428
  * Lazy initialization of the ACME client
240
429
  * @returns An ACME client instance
@@ -244,23 +433,28 @@ export class AcmeCertManager extends plugins.EventEmitter {
244
433
  return this.acmeClient;
245
434
  }
246
435
 
247
- // Generate a new account key
248
- this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
249
-
250
- this.acmeClient = new plugins.acme.Client({
251
- directoryUrl: this.options.useProduction
252
- ? plugins.acme.directory.letsencrypt.production
253
- : plugins.acme.directory.letsencrypt.staging,
254
- accountKey: this.accountKey,
255
- });
256
-
257
- // Create a new account
258
- await this.acmeClient.createAccount({
259
- termsOfServiceAgreed: true,
260
- contact: [`mailto:${this.options.contactEmail}`],
261
- });
262
-
263
- return this.acmeClient;
436
+ try {
437
+ // Generate a new account key
438
+ this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
439
+
440
+ this.acmeClient = new plugins.acme.Client({
441
+ directoryUrl: this.options.useProduction
442
+ ? plugins.acme.directory.letsencrypt.production
443
+ : plugins.acme.directory.letsencrypt.staging,
444
+ accountKey: this.accountKey,
445
+ });
446
+
447
+ // Create a new account
448
+ await this.acmeClient.createAccount({
449
+ termsOfServiceAgreed: true,
450
+ contact: [`mailto:${this.options.contactEmail}`],
451
+ });
452
+
453
+ return this.acmeClient;
454
+ } catch (error) {
455
+ const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
456
+ throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
457
+ }
264
458
  }
265
459
 
266
460
  /**
@@ -279,22 +473,42 @@ export class AcmeCertManager extends plugins.EventEmitter {
279
473
  // Extract domain (ignoring any port in the Host header)
280
474
  const domain = hostHeader.split(':')[0];
281
475
 
282
- // If the request is for an ACME HTTP-01 challenge, handle it
283
- if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
284
- this.handleAcmeChallenge(req, res, domain);
285
- return;
286
- }
287
-
288
- if (!this.domainCertificates.has(domain)) {
476
+ // Get domain config, using glob pattern matching if needed
477
+ const domainMatch = this.getDomainInfoForRequest(domain);
478
+
479
+ if (!domainMatch) {
289
480
  res.statusCode = 404;
290
481
  res.end('Domain not configured');
291
482
  return;
292
483
  }
293
484
 
294
- const domainInfo = this.domainCertificates.get(domain)!;
485
+ const { domainInfo, pattern } = domainMatch;
486
+ const options = domainInfo.options;
295
487
 
296
- // If certificate exists, redirect to HTTPS
297
- if (domainInfo.certObtained) {
488
+ // If the request is for an ACME HTTP-01 challenge, handle it
489
+ if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) {
490
+ // Check if we should forward ACME requests
491
+ if (options.acmeForward) {
492
+ this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
493
+ return;
494
+ }
495
+
496
+ // Only handle ACME challenges for non-glob patterns
497
+ if (!this.isGlobPattern(pattern)) {
498
+ this.handleAcmeChallenge(req, res, domain);
499
+ return;
500
+ }
501
+ }
502
+
503
+ // Check if we should forward non-ACME requests
504
+ if (options.forward) {
505
+ this.forwardRequest(req, res, options.forward, 'HTTP');
506
+ return;
507
+ }
508
+
509
+ // If certificate exists and sslRedirect is enabled, redirect to HTTPS
510
+ // (Skip for glob patterns as they won't have certificates)
511
+ if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
298
512
  const httpsPort = this.options.httpsRedirectPort;
299
513
  const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
300
514
  const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
@@ -302,17 +516,94 @@ export class AcmeCertManager extends plugins.EventEmitter {
302
516
  res.statusCode = 301;
303
517
  res.setHeader('Location', redirectUrl);
304
518
  res.end(`Redirecting to ${redirectUrl}`);
305
- } else {
519
+ return;
520
+ }
521
+
522
+ // Handle case where certificate maintenance is enabled but not yet obtained
523
+ // (Skip for glob patterns as they can't have certificates)
524
+ if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
306
525
  // Trigger certificate issuance if not already running
307
526
  if (!domainInfo.obtainingInProgress) {
308
527
  this.obtainCertificate(domain).catch(err => {
309
- this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message });
528
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
529
+ this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
530
+ domain,
531
+ error: errorMessage,
532
+ isRenewal: false
533
+ });
310
534
  console.error(`Error obtaining certificate for ${domain}:`, err);
311
535
  });
312
536
  }
313
537
 
314
538
  res.statusCode = 503;
315
539
  res.end('Certificate issuance in progress, please try again later.');
540
+ return;
541
+ }
542
+
543
+ // Default response for unhandled request
544
+ res.statusCode = 404;
545
+ res.end('No handlers configured for this request');
546
+ }
547
+
548
+ /**
549
+ * Forwards an HTTP request to the specified target
550
+ * @param req The original request
551
+ * @param res The response object
552
+ * @param target The forwarding target (IP and port)
553
+ * @param requestType Type of request for logging
554
+ */
555
+ private forwardRequest(
556
+ req: plugins.http.IncomingMessage,
557
+ res: plugins.http.ServerResponse,
558
+ target: IForwardConfig,
559
+ requestType: string
560
+ ): void {
561
+ const options = {
562
+ hostname: target.ip,
563
+ port: target.port,
564
+ path: req.url,
565
+ method: req.method,
566
+ headers: { ...req.headers }
567
+ };
568
+
569
+ const domain = req.headers.host?.split(':')[0] || 'unknown';
570
+ console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
571
+
572
+ const proxyReq = plugins.http.request(options, (proxyRes) => {
573
+ // Copy status code
574
+ res.statusCode = proxyRes.statusCode || 500;
575
+
576
+ // Copy headers
577
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
578
+ if (value) res.setHeader(key, value);
579
+ }
580
+
581
+ // Pipe response data
582
+ proxyRes.pipe(res);
583
+
584
+ this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
585
+ domain,
586
+ requestType,
587
+ target: `${target.ip}:${target.port}`,
588
+ statusCode: proxyRes.statusCode
589
+ });
590
+ });
591
+
592
+ proxyReq.on('error', (error) => {
593
+ console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
594
+ if (!res.headersSent) {
595
+ res.statusCode = 502;
596
+ res.end(`Proxy error: ${error.message}`);
597
+ } else {
598
+ res.end();
599
+ }
600
+ });
601
+
602
+ // Pipe original request to proxy request
603
+ if (req.readable) {
604
+ req.pipe(proxyReq);
605
+ } else {
606
+ proxyReq.end();
316
607
  }
317
608
  }
318
609
 
@@ -351,10 +642,21 @@ export class AcmeCertManager extends plugins.EventEmitter {
351
642
  * @param isRenewal Whether this is a renewal attempt
352
643
  */
353
644
  private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
645
+ // Don't allow certificate issuance for glob patterns
646
+ if (this.isGlobPattern(domain)) {
647
+ throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
648
+ }
649
+
354
650
  // Get the domain info
355
651
  const domainInfo = this.domainCertificates.get(domain);
356
652
  if (!domainInfo) {
357
- throw new Error(`Domain not found: ${domain}`);
653
+ throw new CertificateError('Domain not found', domain, isRenewal);
654
+ }
655
+
656
+ // Verify that acmeMaintenance is enabled
657
+ if (!domainInfo.options.acmeMaintenance) {
658
+ console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
659
+ return;
358
660
  }
359
661
 
360
662
  // Prevent concurrent certificate issuance
@@ -377,40 +679,8 @@ export class AcmeCertManager extends plugins.EventEmitter {
377
679
  // Get the authorizations for the order
378
680
  const authorizations = await client.getAuthorizations(order);
379
681
 
380
- for (const authz of authorizations) {
381
- const challenge = authz.challenges.find(ch => ch.type === 'http-01');
382
- if (!challenge) {
383
- throw new Error('HTTP-01 challenge not found');
384
- }
385
-
386
- // Get the key authorization for the challenge
387
- const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
388
-
389
- // Store the challenge data
390
- domainInfo.challengeToken = challenge.token;
391
- domainInfo.challengeKeyAuthorization = keyAuthorization;
392
-
393
- // ACME client type definition workaround - use compatible approach
394
- // First check if challenge verification is needed
395
- const authzUrl = authz.url;
396
-
397
- try {
398
- // Check if authzUrl exists and perform verification
399
- if (authzUrl) {
400
- await client.verifyChallenge(authz, challenge);
401
- }
402
-
403
- // Complete the challenge
404
- await client.completeChallenge(challenge);
405
-
406
- // Wait for validation
407
- await client.waitForValidStatus(challenge);
408
- console.log(`HTTP-01 challenge completed for ${domain}`);
409
- } catch (error) {
410
- console.error(`Challenge error for ${domain}:`, error);
411
- throw error;
412
- }
413
- }
682
+ // Process each authorization
683
+ await this.processAuthorizations(client, domain, authorizations);
414
684
 
415
685
  // Generate a CSR and private key
416
686
  const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
@@ -436,28 +706,20 @@ export class AcmeCertManager extends plugins.EventEmitter {
436
706
  delete domainInfo.challengeKeyAuthorization;
437
707
 
438
708
  // Extract expiry date from certificate
439
- try {
440
- const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
441
- if (matches && matches[1]) {
442
- domainInfo.expiryDate = new Date(matches[1]);
443
- console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
444
- }
445
- } catch (error) {
446
- console.warn(`Failed to extract expiry date from certificate for ${domain}`);
447
- }
709
+ domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
448
710
 
449
711
  console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
450
712
 
451
713
  // Emit the appropriate event
452
714
  const eventType = isRenewal
453
- ? CertManagerEvents.CERTIFICATE_RENEWED
454
- : CertManagerEvents.CERTIFICATE_ISSUED;
715
+ ? Port80HandlerEvents.CERTIFICATE_RENEWED
716
+ : Port80HandlerEvents.CERTIFICATE_ISSUED;
455
717
 
456
718
  this.emitCertificateEvent(eventType, {
457
719
  domain,
458
720
  certificate,
459
721
  privateKey,
460
- expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
722
+ expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
461
723
  });
462
724
 
463
725
  } catch (error: any) {
@@ -473,17 +735,76 @@ export class AcmeCertManager extends plugins.EventEmitter {
473
735
  }
474
736
 
475
737
  // Emit failure event
476
- this.emit(CertManagerEvents.CERTIFICATE_FAILED, {
738
+ this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
477
739
  domain,
478
740
  error: error.message || 'Unknown error',
479
741
  isRenewal
480
- });
742
+ } as ICertificateFailure);
743
+
744
+ throw new CertificateError(
745
+ error.message || 'Certificate issuance failed',
746
+ domain,
747
+ isRenewal
748
+ );
481
749
  } finally {
482
750
  // Reset flag whether successful or not
483
751
  domainInfo.obtainingInProgress = false;
484
752
  }
485
753
  }
486
754
 
755
+ /**
756
+ * Process ACME authorizations by verifying and completing challenges
757
+ * @param client ACME client
758
+ * @param domain Domain name
759
+ * @param authorizations Authorizations to process
760
+ */
761
+ private async processAuthorizations(
762
+ client: plugins.acme.Client,
763
+ domain: string,
764
+ authorizations: plugins.acme.Authorization[]
765
+ ): Promise<void> {
766
+ const domainInfo = this.domainCertificates.get(domain);
767
+ if (!domainInfo) {
768
+ throw new CertificateError('Domain not found during authorization', domain);
769
+ }
770
+
771
+ for (const authz of authorizations) {
772
+ const challenge = authz.challenges.find(ch => ch.type === 'http-01');
773
+ if (!challenge) {
774
+ throw new CertificateError('HTTP-01 challenge not found', domain);
775
+ }
776
+
777
+ // Get the key authorization for the challenge
778
+ const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
779
+
780
+ // Store the challenge data
781
+ domainInfo.challengeToken = challenge.token;
782
+ domainInfo.challengeKeyAuthorization = keyAuthorization;
783
+
784
+ // ACME client type definition workaround - use compatible approach
785
+ // First check if challenge verification is needed
786
+ const authzUrl = authz.url;
787
+
788
+ try {
789
+ // Check if authzUrl exists and perform verification
790
+ if (authzUrl) {
791
+ await client.verifyChallenge(authz, challenge);
792
+ }
793
+
794
+ // Complete the challenge
795
+ await client.completeChallenge(challenge);
796
+
797
+ // Wait for validation
798
+ await client.waitForValidStatus(challenge);
799
+ console.log(`HTTP-01 challenge completed for ${domain}`);
800
+ } catch (error) {
801
+ const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
802
+ console.error(`Challenge error for ${domain}:`, error);
803
+ throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
804
+ }
805
+ }
806
+ }
807
+
487
808
  /**
488
809
  * Starts the certificate renewal timer
489
810
  */
@@ -519,6 +840,16 @@ export class AcmeCertManager extends plugins.EventEmitter {
519
840
  const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
520
841
 
521
842
  for (const [domain, domainInfo] of this.domainCertificates.entries()) {
843
+ // Skip glob patterns
844
+ if (this.isGlobPattern(domain)) {
845
+ continue;
846
+ }
847
+
848
+ // Skip domains with acmeMaintenance disabled
849
+ if (!domainInfo.options.acmeMaintenance) {
850
+ continue;
851
+ }
852
+
522
853
  // Skip domains without certificates or already in renewal
523
854
  if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
524
855
  continue;
@@ -534,26 +865,67 @@ export class AcmeCertManager extends plugins.EventEmitter {
534
865
  // Check if certificate is near expiry
535
866
  if (timeUntilExpiry <= renewThresholdMs) {
536
867
  console.log(`Certificate for ${domain} expires soon, renewing...`);
537
- this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, {
868
+
869
+ const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000));
870
+
871
+ this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, {
538
872
  domain,
539
873
  expiryDate: domainInfo.expiryDate,
540
- daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000))
541
- });
874
+ daysRemaining
875
+ } as ICertificateExpiring);
542
876
 
543
877
  // Start renewal process
544
878
  this.obtainCertificate(domain, true).catch(err => {
545
- console.error(`Error renewing certificate for ${domain}:`, err);
879
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
880
+ console.error(`Error renewing certificate for ${domain}:`, errorMessage);
546
881
  });
547
882
  }
548
883
  }
549
884
  }
550
885
 
886
+ /**
887
+ * Extract expiry date from certificate using a more robust approach
888
+ * @param certificate Certificate PEM string
889
+ * @param domain Domain for logging
890
+ * @returns Extracted expiry date or default
891
+ */
892
+ private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
893
+ try {
894
+ // This is still using regex, but in a real implementation you would use
895
+ // a library like node-forge or x509 to properly parse the certificate
896
+ const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
897
+ if (matches && matches[1]) {
898
+ const expiryDate = new Date(matches[1]);
899
+
900
+ // Validate that we got a valid date
901
+ if (!isNaN(expiryDate.getTime())) {
902
+ console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
903
+ return expiryDate;
904
+ }
905
+ }
906
+
907
+ console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
908
+ return this.getDefaultExpiryDate();
909
+ } catch (error) {
910
+ console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
911
+ return this.getDefaultExpiryDate();
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Get a default expiry date (90 days from now)
917
+ * @returns Default expiry date
918
+ */
919
+ private getDefaultExpiryDate(): Date {
920
+ return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
921
+ }
922
+
551
923
  /**
552
924
  * Emits a certificate event with the certificate data
553
925
  * @param eventType The event type to emit
554
926
  * @param data The certificate data
555
927
  */
556
- private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void {
928
+ private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
557
929
  this.emit(eventType, data);
558
930
  }
559
931
  }