@push.rocks/smartproxy 3.34.0 → 3.37.1
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.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/classes.networkproxy.d.ts +85 -0
- package/dist_ts/classes.networkproxy.js +385 -6
- package/dist_ts/classes.portproxy.d.ts +31 -0
- package/dist_ts/classes.portproxy.js +196 -189
- package/dist_ts/classes.snihandler.d.ts +45 -0
- package/dist_ts/classes.snihandler.js +274 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/package.json +5 -5
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/classes.networkproxy.ts +461 -6
- package/ts/classes.portproxy.ts +232 -204
- package/ts/classes.snihandler.ts +331 -0
- package/ts/index.ts +1 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { ProxyRouter } from './classes.router.js';
|
|
3
|
+
import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js';
|
|
3
4
|
import * as fs from 'fs';
|
|
4
5
|
import * as path from 'path';
|
|
5
6
|
import { fileURLToPath } from 'url';
|
|
@@ -20,6 +21,18 @@ export interface INetworkProxyOptions {
|
|
|
20
21
|
// New settings for PortProxy integration
|
|
21
22
|
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
|
22
23
|
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
|
|
24
|
+
|
|
25
|
+
// ACME certificate management options
|
|
26
|
+
acme?: {
|
|
27
|
+
enabled?: boolean; // Whether to enable automatic certificate management
|
|
28
|
+
port?: number; // Port to listen on for ACME challenges (default: 80)
|
|
29
|
+
contactEmail?: string; // Email for Let's Encrypt account
|
|
30
|
+
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
|
|
31
|
+
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
|
|
32
|
+
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
|
|
33
|
+
certificateStore?: string; // Directory to store certificates (default: ./certs)
|
|
34
|
+
skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured
|
|
35
|
+
};
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
|
@@ -59,12 +72,19 @@ export class NetworkProxy {
|
|
|
59
72
|
private defaultCertificates: { key: string; cert: string };
|
|
60
73
|
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
|
61
74
|
|
|
75
|
+
// ACME certificate manager
|
|
76
|
+
private certManager: AcmeCertManager | null = null;
|
|
77
|
+
private certificateStoreDir: string;
|
|
78
|
+
|
|
62
79
|
// New connection pool for backend connections
|
|
63
80
|
private connectionPool: Map<string, Array<{
|
|
64
81
|
socket: plugins.net.Socket;
|
|
65
82
|
lastUsed: number;
|
|
66
83
|
isIdle: boolean;
|
|
67
84
|
}>> = new Map();
|
|
85
|
+
|
|
86
|
+
// Track round-robin positions for load balancing
|
|
87
|
+
private roundRobinPositions: Map<string, number> = new Map();
|
|
68
88
|
|
|
69
89
|
/**
|
|
70
90
|
* Creates a new NetworkProxy instance
|
|
@@ -85,9 +105,33 @@ export class NetworkProxy {
|
|
|
85
105
|
},
|
|
86
106
|
// New defaults for PortProxy integration
|
|
87
107
|
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
|
88
|
-
portProxyIntegration: optionsArg.portProxyIntegration || false
|
|
108
|
+
portProxyIntegration: optionsArg.portProxyIntegration || false,
|
|
109
|
+
// Default ACME options
|
|
110
|
+
acme: {
|
|
111
|
+
enabled: optionsArg.acme?.enabled || false,
|
|
112
|
+
port: optionsArg.acme?.port || 80,
|
|
113
|
+
contactEmail: optionsArg.acme?.contactEmail || 'admin@example.com',
|
|
114
|
+
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
|
|
115
|
+
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
|
|
116
|
+
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
|
|
117
|
+
certificateStore: optionsArg.acme?.certificateStore || './certs',
|
|
118
|
+
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
|
|
119
|
+
}
|
|
89
120
|
};
|
|
90
121
|
|
|
122
|
+
// Set up certificate store directory
|
|
123
|
+
this.certificateStoreDir = path.resolve(this.options.acme.certificateStore);
|
|
124
|
+
|
|
125
|
+
// Ensure certificate store directory exists
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(this.certificateStoreDir)) {
|
|
128
|
+
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
|
|
129
|
+
this.log('info', `Created certificate store directory: ${this.certificateStoreDir}`);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.log('warn', `Failed to create certificate store directory: ${error}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
91
135
|
this.loadDefaultCertificates();
|
|
92
136
|
}
|
|
93
137
|
|
|
@@ -330,17 +374,230 @@ export class NetworkProxy {
|
|
|
330
374
|
}
|
|
331
375
|
}
|
|
332
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Initializes the ACME certificate manager for automatic certificate issuance
|
|
379
|
+
* @private
|
|
380
|
+
*/
|
|
381
|
+
private async initializeAcmeManager(): Promise<void> {
|
|
382
|
+
if (!this.options.acme.enabled) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Create certificate manager
|
|
387
|
+
this.certManager = new AcmeCertManager({
|
|
388
|
+
port: this.options.acme.port,
|
|
389
|
+
contactEmail: this.options.acme.contactEmail,
|
|
390
|
+
useProduction: this.options.acme.useProduction,
|
|
391
|
+
renewThresholdDays: this.options.acme.renewThresholdDays,
|
|
392
|
+
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
|
|
393
|
+
renewCheckIntervalHours: 24 // Check daily for renewals
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Register event handlers
|
|
397
|
+
this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
|
|
398
|
+
this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
|
|
399
|
+
this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
|
|
400
|
+
this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => {
|
|
401
|
+
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Start the manager
|
|
405
|
+
try {
|
|
406
|
+
await this.certManager.start();
|
|
407
|
+
this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`);
|
|
408
|
+
|
|
409
|
+
// Add domains from proxy configs
|
|
410
|
+
this.registerDomainsWithAcmeManager();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
this.log('error', `Failed to start ACME Certificate Manager: ${error}`);
|
|
413
|
+
this.certManager = null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Registers domains from proxy configs with the ACME manager
|
|
419
|
+
* @private
|
|
420
|
+
*/
|
|
421
|
+
private registerDomainsWithAcmeManager(): void {
|
|
422
|
+
if (!this.certManager) return;
|
|
423
|
+
|
|
424
|
+
// Get all hostnames from proxy configs
|
|
425
|
+
this.proxyConfigs.forEach(config => {
|
|
426
|
+
const hostname = config.hostName;
|
|
427
|
+
|
|
428
|
+
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
|
429
|
+
if (hostname.includes('*')) {
|
|
430
|
+
this.log('info', `Skipping wildcard domain for ACME: ${hostname}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Skip domains already with certificates if configured to do so
|
|
435
|
+
if (this.options.acme.skipConfiguredCerts) {
|
|
436
|
+
const cachedCert = this.certificateCache.get(hostname);
|
|
437
|
+
if (cachedCert) {
|
|
438
|
+
this.log('info', `Skipping domain with existing certificate: ${hostname}`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Check for existing certificate in the store
|
|
444
|
+
const certPath = path.join(this.certificateStoreDir, `${hostname}.cert.pem`);
|
|
445
|
+
const keyPath = path.join(this.certificateStoreDir, `${hostname}.key.pem`);
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
449
|
+
// Load existing certificate and key
|
|
450
|
+
const cert = fs.readFileSync(certPath, 'utf8');
|
|
451
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
452
|
+
|
|
453
|
+
// Extract expiry date from certificate if possible
|
|
454
|
+
let expiryDate: Date | undefined;
|
|
455
|
+
try {
|
|
456
|
+
const matches = cert.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
457
|
+
if (matches && matches[1]) {
|
|
458
|
+
expiryDate = new Date(matches[1]);
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Update the certificate in the manager
|
|
465
|
+
this.certManager.setCertificate(hostname, cert, key, expiryDate);
|
|
466
|
+
|
|
467
|
+
// Also update our own certificate cache
|
|
468
|
+
this.updateCertificateCache(hostname, cert, key, expiryDate);
|
|
469
|
+
|
|
470
|
+
this.log('info', `Loaded existing certificate for ${hostname}`);
|
|
471
|
+
} else {
|
|
472
|
+
// Register the domain for certificate issuance
|
|
473
|
+
this.certManager.addDomain(hostname);
|
|
474
|
+
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Handles newly issued or renewed certificates from ACME manager
|
|
484
|
+
* @private
|
|
485
|
+
*/
|
|
486
|
+
private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void {
|
|
487
|
+
const { domain, certificate, privateKey, expiryDate } = data;
|
|
488
|
+
|
|
489
|
+
this.log('info', `Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`);
|
|
490
|
+
|
|
491
|
+
// Update certificate in HTTPS server
|
|
492
|
+
this.updateCertificateCache(domain, certificate, privateKey, expiryDate);
|
|
493
|
+
|
|
494
|
+
// Save the certificate to the filesystem
|
|
495
|
+
this.saveCertificateToStore(domain, certificate, privateKey);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Handles certificate issuance failures
|
|
500
|
+
* @private
|
|
501
|
+
*/
|
|
502
|
+
private handleCertificateFailed(data: { domain: string; error: string }): void {
|
|
503
|
+
this.log('error', `Certificate issuance failed for ${data.domain}: ${data.error}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Saves certificate and private key to the filesystem
|
|
508
|
+
* @private
|
|
509
|
+
*/
|
|
510
|
+
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
|
|
511
|
+
try {
|
|
512
|
+
const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`);
|
|
513
|
+
const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`);
|
|
514
|
+
|
|
515
|
+
fs.writeFileSync(certPath, certificate);
|
|
516
|
+
fs.writeFileSync(keyPath, privateKey);
|
|
517
|
+
|
|
518
|
+
// Ensure private key has restricted permissions
|
|
519
|
+
try {
|
|
520
|
+
fs.chmodSync(keyPath, 0o600);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
this.log('warn', `Failed to set permissions on private key for ${domain}: ${error}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
this.log('info', `Saved certificate for ${domain} to ${certPath}`);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
this.log('error', `Failed to save certificate for ${domain}: ${error}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Handles SNI (Server Name Indication) for TLS connections
|
|
533
|
+
* Used by the HTTPS server to select the correct certificate for each domain
|
|
534
|
+
* @private
|
|
535
|
+
*/
|
|
536
|
+
private handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void {
|
|
537
|
+
this.log('debug', `SNI request for domain: ${domain}`);
|
|
538
|
+
|
|
539
|
+
// Check if we have a certificate for this domain
|
|
540
|
+
const certs = this.certificateCache.get(domain);
|
|
541
|
+
|
|
542
|
+
if (certs) {
|
|
543
|
+
try {
|
|
544
|
+
// Create TLS context with the cached certificate
|
|
545
|
+
const context = plugins.tls.createSecureContext({
|
|
546
|
+
key: certs.key,
|
|
547
|
+
cert: certs.cert
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
this.log('debug', `Using cached certificate for ${domain}`);
|
|
551
|
+
cb(null, context);
|
|
552
|
+
return;
|
|
553
|
+
} catch (err) {
|
|
554
|
+
this.log('error', `Error creating secure context for ${domain}:`, err);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Check if we should trigger certificate issuance
|
|
559
|
+
if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) {
|
|
560
|
+
// Check if this domain is already registered
|
|
561
|
+
const certData = this.certManager.getCertificate(domain);
|
|
562
|
+
|
|
563
|
+
if (!certData) {
|
|
564
|
+
this.log('info', `No certificate found for ${domain}, registering for issuance`);
|
|
565
|
+
this.certManager.addDomain(domain);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Fall back to default certificate
|
|
570
|
+
try {
|
|
571
|
+
const context = plugins.tls.createSecureContext({
|
|
572
|
+
key: this.defaultCertificates.key,
|
|
573
|
+
cert: this.defaultCertificates.cert
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
this.log('debug', `Using default certificate for ${domain}`);
|
|
577
|
+
cb(null, context);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
this.log('error', `Error creating default secure context:`, err);
|
|
580
|
+
cb(new Error('Cannot create secure context'), null);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
333
584
|
/**
|
|
334
585
|
* Starts the proxy server
|
|
335
586
|
*/
|
|
336
587
|
public async start(): Promise<void> {
|
|
337
588
|
this.startTime = Date.now();
|
|
338
589
|
|
|
590
|
+
// Initialize ACME certificate manager if enabled
|
|
591
|
+
if (this.options.acme.enabled) {
|
|
592
|
+
await this.initializeAcmeManager();
|
|
593
|
+
}
|
|
594
|
+
|
|
339
595
|
// Create the HTTPS server
|
|
340
596
|
this.httpsServer = plugins.https.createServer(
|
|
341
597
|
{
|
|
342
598
|
key: this.defaultCertificates.key,
|
|
343
|
-
cert: this.defaultCertificates.cert
|
|
599
|
+
cert: this.defaultCertificates.cert,
|
|
600
|
+
SNICallback: (domain, cb) => this.handleSNI(domain, cb)
|
|
344
601
|
},
|
|
345
602
|
(req, res) => this.handleRequest(req, res)
|
|
346
603
|
);
|
|
@@ -556,7 +813,10 @@ export class NetworkProxy {
|
|
|
556
813
|
const outGoingDeferred = plugins.smartpromise.defer();
|
|
557
814
|
|
|
558
815
|
try {
|
|
559
|
-
|
|
816
|
+
// Select destination IP and port for WebSocket
|
|
817
|
+
const wsDestinationIp = this.selectDestinationIp(wsDestinationConfig);
|
|
818
|
+
const wsDestinationPort = this.selectDestinationPort(wsDestinationConfig);
|
|
819
|
+
const wsTarget = `ws://${wsDestinationIp}:${wsDestinationPort}${reqArg.url}`;
|
|
560
820
|
this.log('debug', `Proxying WebSocket to ${wsTarget}`);
|
|
561
821
|
|
|
562
822
|
wsOutgoing = new plugins.wsDefault(wsTarget);
|
|
@@ -688,8 +948,12 @@ export class NetworkProxy {
|
|
|
688
948
|
const useConnectionPool = this.options.portProxyIntegration &&
|
|
689
949
|
originRequest.socket.remoteAddress?.includes('127.0.0.1');
|
|
690
950
|
|
|
951
|
+
// Select destination IP and port from the arrays
|
|
952
|
+
const destinationIp = this.selectDestinationIp(destinationConfig);
|
|
953
|
+
const destinationPort = this.selectDestinationPort(destinationConfig);
|
|
954
|
+
|
|
691
955
|
// Construct destination URL
|
|
692
|
-
const destinationUrl = `http://${
|
|
956
|
+
const destinationUrl = `http://${destinationIp}:${destinationPort}${originRequest.url}`;
|
|
693
957
|
|
|
694
958
|
if (useConnectionPool) {
|
|
695
959
|
this.log('debug', `[${reqId}] Proxying to ${destinationUrl} (using connection pool)`);
|
|
@@ -697,8 +961,8 @@ export class NetworkProxy {
|
|
|
697
961
|
reqId,
|
|
698
962
|
originRequest,
|
|
699
963
|
originResponse,
|
|
700
|
-
|
|
701
|
-
|
|
964
|
+
destinationIp,
|
|
965
|
+
destinationPort,
|
|
702
966
|
originRequest.url
|
|
703
967
|
);
|
|
704
968
|
} else {
|
|
@@ -1084,6 +1348,80 @@ export class NetworkProxy {
|
|
|
1084
1348
|
}
|
|
1085
1349
|
}
|
|
1086
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* Selects a destination IP from the array using round-robin
|
|
1353
|
+
* @param config The proxy configuration
|
|
1354
|
+
* @returns A destination IP address
|
|
1355
|
+
*/
|
|
1356
|
+
private selectDestinationIp(config: plugins.tsclass.network.IReverseProxyConfig): string {
|
|
1357
|
+
// For array-based configs
|
|
1358
|
+
if (Array.isArray(config.destinationIps) && config.destinationIps.length > 0) {
|
|
1359
|
+
// Get the current position or initialize it
|
|
1360
|
+
const key = `ip_${config.hostName}`;
|
|
1361
|
+
let position = this.roundRobinPositions.get(key) || 0;
|
|
1362
|
+
|
|
1363
|
+
// Select the IP using round-robin
|
|
1364
|
+
const selectedIp = config.destinationIps[position];
|
|
1365
|
+
|
|
1366
|
+
// Update the position for next time
|
|
1367
|
+
position = (position + 1) % config.destinationIps.length;
|
|
1368
|
+
this.roundRobinPositions.set(key, position);
|
|
1369
|
+
|
|
1370
|
+
return selectedIp;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// For backward compatibility with test suites that rely on specific behavior
|
|
1374
|
+
// Check if there's a proxyConfigs entry that matches this hostname
|
|
1375
|
+
const matchingConfig = this.proxyConfigs.find(cfg =>
|
|
1376
|
+
cfg.hostName === config.hostName &&
|
|
1377
|
+
(cfg as any).destinationIp
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
if (matchingConfig) {
|
|
1381
|
+
return (matchingConfig as any).destinationIp;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Fallback to localhost
|
|
1385
|
+
return 'localhost';
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Selects a destination port from the array using round-robin
|
|
1390
|
+
* @param config The proxy configuration
|
|
1391
|
+
* @returns A destination port number
|
|
1392
|
+
*/
|
|
1393
|
+
private selectDestinationPort(config: plugins.tsclass.network.IReverseProxyConfig): number {
|
|
1394
|
+
// For array-based configs
|
|
1395
|
+
if (Array.isArray(config.destinationPorts) && config.destinationPorts.length > 0) {
|
|
1396
|
+
// Get the current position or initialize it
|
|
1397
|
+
const key = `port_${config.hostName}`;
|
|
1398
|
+
let position = this.roundRobinPositions.get(key) || 0;
|
|
1399
|
+
|
|
1400
|
+
// Select the port using round-robin
|
|
1401
|
+
const selectedPort = config.destinationPorts[position];
|
|
1402
|
+
|
|
1403
|
+
// Update the position for next time
|
|
1404
|
+
position = (position + 1) % config.destinationPorts.length;
|
|
1405
|
+
this.roundRobinPositions.set(key, position);
|
|
1406
|
+
|
|
1407
|
+
return selectedPort;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// For backward compatibility with test suites that rely on specific behavior
|
|
1411
|
+
// Check if there's a proxyConfigs entry that matches this hostname
|
|
1412
|
+
const matchingConfig = this.proxyConfigs.find(cfg =>
|
|
1413
|
+
cfg.hostName === config.hostName &&
|
|
1414
|
+
(cfg as any).destinationPort
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
if (matchingConfig) {
|
|
1418
|
+
return parseInt((matchingConfig as any).destinationPort, 10);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Fallback to port 80
|
|
1422
|
+
return 80;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1087
1425
|
/**
|
|
1088
1426
|
* Updates proxy configurations
|
|
1089
1427
|
*/
|
|
@@ -1144,6 +1482,48 @@ export class NetworkProxy {
|
|
|
1144
1482
|
}
|
|
1145
1483
|
}
|
|
1146
1484
|
|
|
1485
|
+
/**
|
|
1486
|
+
* Converts PortProxy domain configurations to NetworkProxy configs
|
|
1487
|
+
* @param domainConfigs PortProxy domain configs
|
|
1488
|
+
* @param sslKeyPair Default SSL key pair to use if not specified
|
|
1489
|
+
* @returns Array of NetworkProxy configs
|
|
1490
|
+
*/
|
|
1491
|
+
public convertPortProxyConfigs(
|
|
1492
|
+
domainConfigs: Array<{
|
|
1493
|
+
domains: string[];
|
|
1494
|
+
targetIPs?: string[];
|
|
1495
|
+
allowedIPs?: string[];
|
|
1496
|
+
}>,
|
|
1497
|
+
sslKeyPair?: { key: string; cert: string }
|
|
1498
|
+
): plugins.tsclass.network.IReverseProxyConfig[] {
|
|
1499
|
+
const proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
|
1500
|
+
|
|
1501
|
+
// Use default certificates if not provided
|
|
1502
|
+
const sslKey = sslKeyPair?.key || this.defaultCertificates.key;
|
|
1503
|
+
const sslCert = sslKeyPair?.cert || this.defaultCertificates.cert;
|
|
1504
|
+
|
|
1505
|
+
for (const domainConfig of domainConfigs) {
|
|
1506
|
+
// Each domain in the domains array gets its own config
|
|
1507
|
+
for (const domain of domainConfig.domains) {
|
|
1508
|
+
// Skip non-hostname patterns (like IP addresses)
|
|
1509
|
+
if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') {
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
proxyConfigs.push({
|
|
1514
|
+
hostName: domain,
|
|
1515
|
+
destinationIps: domainConfig.targetIPs || ['localhost'],
|
|
1516
|
+
destinationPorts: [this.options.port], // Use the NetworkProxy port
|
|
1517
|
+
privateKey: sslKey,
|
|
1518
|
+
publicKey: sslCert
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
this.log('info', `Converted ${domainConfigs.length} PortProxy configs to ${proxyConfigs.length} NetworkProxy configs`);
|
|
1524
|
+
return proxyConfigs;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1147
1527
|
/**
|
|
1148
1528
|
* Adds default headers to be included in all responses
|
|
1149
1529
|
*/
|
|
@@ -1208,6 +1588,16 @@ export class NetworkProxy {
|
|
|
1208
1588
|
}
|
|
1209
1589
|
this.connectionPool.clear();
|
|
1210
1590
|
|
|
1591
|
+
// Stop ACME certificate manager if it's running
|
|
1592
|
+
if (this.certManager) {
|
|
1593
|
+
try {
|
|
1594
|
+
await this.certManager.stop();
|
|
1595
|
+
this.log('info', 'ACME Certificate Manager stopped');
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
this.log('error', 'Error stopping ACME Certificate Manager', error);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1211
1601
|
// Close the HTTPS server
|
|
1212
1602
|
return new Promise((resolve) => {
|
|
1213
1603
|
this.httpsServer.close(() => {
|
|
@@ -1217,6 +1607,71 @@ export class NetworkProxy {
|
|
|
1217
1607
|
});
|
|
1218
1608
|
}
|
|
1219
1609
|
|
|
1610
|
+
/**
|
|
1611
|
+
* Requests a new certificate for a domain
|
|
1612
|
+
* This can be used to manually trigger certificate issuance
|
|
1613
|
+
* @param domain The domain to request a certificate for
|
|
1614
|
+
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
|
|
1615
|
+
*/
|
|
1616
|
+
public async requestCertificate(domain: string): Promise<boolean> {
|
|
1617
|
+
if (!this.options.acme.enabled) {
|
|
1618
|
+
this.log('warn', 'ACME certificate management is not enabled');
|
|
1619
|
+
return false;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (!this.certManager) {
|
|
1623
|
+
this.log('error', 'ACME certificate manager is not initialized');
|
|
1624
|
+
return false;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
|
1628
|
+
if (domain.includes('*')) {
|
|
1629
|
+
this.log('error', `Cannot request certificate for wildcard domain: ${domain}`);
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
try {
|
|
1634
|
+
this.certManager.addDomain(domain);
|
|
1635
|
+
this.log('info', `Certificate request submitted for domain: ${domain}`);
|
|
1636
|
+
return true;
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
this.log('error', `Error requesting certificate for domain ${domain}:`, error);
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Updates the certificate cache for a domain
|
|
1645
|
+
* @param domain The domain name
|
|
1646
|
+
* @param certificate The certificate (PEM format)
|
|
1647
|
+
* @param privateKey The private key (PEM format)
|
|
1648
|
+
* @param expiryDate Optional expiry date
|
|
1649
|
+
*/
|
|
1650
|
+
private updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
|
1651
|
+
// Update certificate context in HTTPS server if it's running
|
|
1652
|
+
if (this.httpsServer) {
|
|
1653
|
+
try {
|
|
1654
|
+
this.httpsServer.addContext(domain, {
|
|
1655
|
+
key: privateKey,
|
|
1656
|
+
cert: certificate
|
|
1657
|
+
});
|
|
1658
|
+
this.log('debug', `Updated SSL context for domain: ${domain}`);
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
this.log('error', `Error updating SSL context for domain ${domain}:`, error);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Update certificate in cache
|
|
1665
|
+
this.certificateCache.set(domain, {
|
|
1666
|
+
key: privateKey,
|
|
1667
|
+
cert: certificate,
|
|
1668
|
+
expires: expiryDate
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
// Add to active contexts set
|
|
1672
|
+
this.activeContexts.add(domain);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1220
1675
|
/**
|
|
1221
1676
|
* Logs a message according to the configured log level
|
|
1222
1677
|
*/
|