feat(NetworkProxy): Integrate Port80Handler for automatic ACME certificate management
- Add ACME certificate management capabilities to NetworkProxy - Implement automatic certificate issuance and renewal - Add SNI support for serving the correct certificates - Create certificate storage and caching system - Enable dynamic certificate issuance for new domains - Support automatic HTTP-to-HTTPS redirects for secured domains 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
27a2bcb556
commit
6a693f4d86
@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { ProxyRouter } from './classes.router.js';
|
||||
import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@ -20,6 +21,18 @@ export interface INetworkProxyOptions {
|
||||
// New settings for PortProxy integration
|
||||
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
||||
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
|
||||
|
||||
// ACME certificate management options
|
||||
acme?: {
|
||||
enabled?: boolean; // Whether to enable automatic certificate management
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
contactEmail?: string; // Email for Let's Encrypt account
|
||||
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
|
||||
certificateStore?: string; // Directory to store certificates (default: ./certs)
|
||||
skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured
|
||||
};
|
||||
}
|
||||
|
||||
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||
@ -59,6 +72,10 @@ export class NetworkProxy {
|
||||
private defaultCertificates: { key: string; cert: string };
|
||||
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
||||
|
||||
// ACME certificate manager
|
||||
private certManager: AcmeCertManager | null = null;
|
||||
private certificateStoreDir: string;
|
||||
|
||||
// New connection pool for backend connections
|
||||
private connectionPool: Map<string, Array<{
|
||||
socket: plugins.net.Socket;
|
||||
@ -88,9 +105,33 @@ export class NetworkProxy {
|
||||
},
|
||||
// New defaults for PortProxy integration
|
||||
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false
|
||||
portProxyIntegration: optionsArg.portProxyIntegration || false,
|
||||
// Default ACME options
|
||||
acme: {
|
||||
enabled: optionsArg.acme?.enabled || false,
|
||||
port: optionsArg.acme?.port || 80,
|
||||
contactEmail: optionsArg.acme?.contactEmail || 'admin@example.com',
|
||||
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
|
||||
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
|
||||
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
|
||||
certificateStore: optionsArg.acme?.certificateStore || './certs',
|
||||
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
|
||||
}
|
||||
};
|
||||
|
||||
// Set up certificate store directory
|
||||
this.certificateStoreDir = path.resolve(this.options.acme.certificateStore);
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
try {
|
||||
if (!fs.existsSync(this.certificateStoreDir)) {
|
||||
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
|
||||
this.log('info', `Created certificate store directory: ${this.certificateStoreDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to create certificate store directory: ${error}`);
|
||||
}
|
||||
|
||||
this.loadDefaultCertificates();
|
||||
}
|
||||
|
||||
@ -333,17 +374,230 @@ export class NetworkProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ACME certificate manager for automatic certificate issuance
|
||||
* @private
|
||||
*/
|
||||
private async initializeAcmeManager(): Promise<void> {
|
||||
if (!this.options.acme.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create certificate manager
|
||||
this.certManager = new AcmeCertManager({
|
||||
port: this.options.acme.port,
|
||||
contactEmail: this.options.acme.contactEmail,
|
||||
useProduction: this.options.acme.useProduction,
|
||||
renewThresholdDays: this.options.acme.renewThresholdDays,
|
||||
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
|
||||
renewCheckIntervalHours: 24 // Check daily for renewals
|
||||
});
|
||||
|
||||
// Register event handlers
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
|
||||
this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => {
|
||||
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
|
||||
});
|
||||
|
||||
// Start the manager
|
||||
try {
|
||||
await this.certManager.start();
|
||||
this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`);
|
||||
|
||||
// Add domains from proxy configs
|
||||
this.registerDomainsWithAcmeManager();
|
||||
} catch (error) {
|
||||
this.log('error', `Failed to start ACME Certificate Manager: ${error}`);
|
||||
this.certManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers domains from proxy configs with the ACME manager
|
||||
* @private
|
||||
*/
|
||||
private registerDomainsWithAcmeManager(): void {
|
||||
if (!this.certManager) return;
|
||||
|
||||
// Get all hostnames from proxy configs
|
||||
this.proxyConfigs.forEach(config => {
|
||||
const hostname = config.hostName;
|
||||
|
||||
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
||||
if (hostname.includes('*')) {
|
||||
this.log('info', `Skipping wildcard domain for ACME: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip domains already with certificates if configured to do so
|
||||
if (this.options.acme.skipConfiguredCerts) {
|
||||
const cachedCert = this.certificateCache.get(hostname);
|
||||
if (cachedCert) {
|
||||
this.log('info', `Skipping domain with existing certificate: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing certificate in the store
|
||||
const certPath = path.join(this.certificateStoreDir, `${hostname}.cert.pem`);
|
||||
const keyPath = path.join(this.certificateStoreDir, `${hostname}.key.pem`);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
// Load existing certificate and key
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
|
||||
// Extract expiry date from certificate if possible
|
||||
let expiryDate: Date | undefined;
|
||||
try {
|
||||
const matches = cert.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||
if (matches && matches[1]) {
|
||||
expiryDate = new Date(matches[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
|
||||
}
|
||||
|
||||
// Update the certificate in the manager
|
||||
this.certManager.setCertificate(hostname, cert, key, expiryDate);
|
||||
|
||||
// Also update our own certificate cache
|
||||
this.updateCertificateCache(hostname, cert, key, expiryDate);
|
||||
|
||||
this.log('info', `Loaded existing certificate for ${hostname}`);
|
||||
} else {
|
||||
// Register the domain for certificate issuance
|
||||
this.certManager.addDomain(hostname);
|
||||
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles newly issued or renewed certificates from ACME manager
|
||||
* @private
|
||||
*/
|
||||
private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void {
|
||||
const { domain, certificate, privateKey, expiryDate } = data;
|
||||
|
||||
this.log('info', `Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`);
|
||||
|
||||
// Update certificate in HTTPS server
|
||||
this.updateCertificateCache(domain, certificate, privateKey, expiryDate);
|
||||
|
||||
// Save the certificate to the filesystem
|
||||
this.saveCertificateToStore(domain, certificate, privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles certificate issuance failures
|
||||
* @private
|
||||
*/
|
||||
private handleCertificateFailed(data: { domain: string; error: string }): void {
|
||||
this.log('error', `Certificate issuance failed for ${data.domain}: ${data.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves certificate and private key to the filesystem
|
||||
* @private
|
||||
*/
|
||||
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
|
||||
try {
|
||||
const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`);
|
||||
const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`);
|
||||
|
||||
fs.writeFileSync(certPath, certificate);
|
||||
fs.writeFileSync(keyPath, privateKey);
|
||||
|
||||
// Ensure private key has restricted permissions
|
||||
try {
|
||||
fs.chmodSync(keyPath, 0o600);
|
||||
} catch (error) {
|
||||
this.log('warn', `Failed to set permissions on private key for ${domain}: ${error}`);
|
||||
}
|
||||
|
||||
this.log('info', `Saved certificate for ${domain} to ${certPath}`);
|
||||
} catch (error) {
|
||||
this.log('error', `Failed to save certificate for ${domain}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SNI (Server Name Indication) for TLS connections
|
||||
* Used by the HTTPS server to select the correct certificate for each domain
|
||||
* @private
|
||||
*/
|
||||
private handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void {
|
||||
this.log('debug', `SNI request for domain: ${domain}`);
|
||||
|
||||
// Check if we have a certificate for this domain
|
||||
const certs = this.certificateCache.get(domain);
|
||||
|
||||
if (certs) {
|
||||
try {
|
||||
// Create TLS context with the cached certificate
|
||||
const context = plugins.tls.createSecureContext({
|
||||
key: certs.key,
|
||||
cert: certs.cert
|
||||
});
|
||||
|
||||
this.log('debug', `Using cached certificate for ${domain}`);
|
||||
cb(null, context);
|
||||
return;
|
||||
} catch (err) {
|
||||
this.log('error', `Error creating secure context for ${domain}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should trigger certificate issuance
|
||||
if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) {
|
||||
// Check if this domain is already registered
|
||||
const certData = this.certManager.getCertificate(domain);
|
||||
|
||||
if (!certData) {
|
||||
this.log('info', `No certificate found for ${domain}, registering for issuance`);
|
||||
this.certManager.addDomain(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default certificate
|
||||
try {
|
||||
const context = plugins.tls.createSecureContext({
|
||||
key: this.defaultCertificates.key,
|
||||
cert: this.defaultCertificates.cert
|
||||
});
|
||||
|
||||
this.log('debug', `Using default certificate for ${domain}`);
|
||||
cb(null, context);
|
||||
} catch (err) {
|
||||
this.log('error', `Error creating default secure context:`, err);
|
||||
cb(new Error('Cannot create secure context'), null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the proxy server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Initialize ACME certificate manager if enabled
|
||||
if (this.options.acme.enabled) {
|
||||
await this.initializeAcmeManager();
|
||||
}
|
||||
|
||||
// Create the HTTPS server
|
||||
this.httpsServer = plugins.https.createServer(
|
||||
{
|
||||
key: this.defaultCertificates.key,
|
||||
cert: this.defaultCertificates.cert
|
||||
cert: this.defaultCertificates.cert,
|
||||
SNICallback: (domain, cb) => this.handleSNI(domain, cb)
|
||||
},
|
||||
(req, res) => this.handleRequest(req, res)
|
||||
);
|
||||
@ -1334,6 +1588,16 @@ export class NetworkProxy {
|
||||
}
|
||||
this.connectionPool.clear();
|
||||
|
||||
// Stop ACME certificate manager if it's running
|
||||
if (this.certManager) {
|
||||
try {
|
||||
await this.certManager.stop();
|
||||
this.log('info', 'ACME Certificate Manager stopped');
|
||||
} catch (error) {
|
||||
this.log('error', 'Error stopping ACME Certificate Manager', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the HTTPS server
|
||||
return new Promise((resolve) => {
|
||||
this.httpsServer.close(() => {
|
||||
@ -1343,6 +1607,71 @@ export class NetworkProxy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a new certificate for a domain
|
||||
* This can be used to manually trigger certificate issuance
|
||||
* @param domain The domain to request a certificate for
|
||||
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
|
||||
*/
|
||||
public async requestCertificate(domain: string): Promise<boolean> {
|
||||
if (!this.options.acme.enabled) {
|
||||
this.log('warn', 'ACME certificate management is not enabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.certManager) {
|
||||
this.log('error', 'ACME certificate manager is not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
|
||||
if (domain.includes('*')) {
|
||||
this.log('error', `Cannot request certificate for wildcard domain: ${domain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.certManager.addDomain(domain);
|
||||
this.log('info', `Certificate request submitted for domain: ${domain}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log('error', `Error requesting certificate for domain ${domain}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the certificate cache for a domain
|
||||
* @param domain The domain name
|
||||
* @param certificate The certificate (PEM format)
|
||||
* @param privateKey The private key (PEM format)
|
||||
* @param expiryDate Optional expiry date
|
||||
*/
|
||||
private updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
||||
// Update certificate context in HTTPS server if it's running
|
||||
if (this.httpsServer) {
|
||||
try {
|
||||
this.httpsServer.addContext(domain, {
|
||||
key: privateKey,
|
||||
cert: certificate
|
||||
});
|
||||
this.log('debug', `Updated SSL context for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
this.log('error', `Error updating SSL context for domain ${domain}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update certificate in cache
|
||||
this.certificateCache.set(domain, {
|
||||
key: privateKey,
|
||||
cert: certificate,
|
||||
expires: expiryDate
|
||||
});
|
||||
|
||||
// Add to active contexts set
|
||||
this.activeContexts.add(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message according to the configured log level
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user