diff --git a/changelog.md b/changelog.md index a25b737..fce9251 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-05-01 - 7.2.0 - feat(ACME/Certificate) +Introduce certificate provider hook and observable certificate events; remove legacy ACME flow + +- Extended IPortProxySettings with a new certProvider callback that allows returning a static certificate or 'http01' for ACME challenges. +- Updated Port80Handler to leverage SmartAcme's getCertificateForDomain and removed outdated methods such as getAcmeClient and processAuthorizations. +- Enhanced SmartProxy to extend EventEmitter, invoking certProvider on non-wildcard domains and re-emitting certificate events (with domain, publicKey, privateKey, expiryDate, source, and isRenewal flag). +- Updated NetworkProxyBridge to support applying external certificates via a new applyExternalCertificate method. +- Revised documentation (readme.md and readme.plan.md) to include usage examples for the new certificate provider hook. + ## 2025-04-30 - 7.1.4 - fix(dependencies) Update dependency versions in package.json diff --git a/readme.md b/readme.md index 28bc5e7..a73c40b 100644 --- a/readme.md +++ b/readme.md @@ -199,6 +199,50 @@ sequenceDiagram - **IP Filtering** - Control access with IP allow/block lists using glob patterns - **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding +## Certificate Provider Hook & Events + +You can customize how certificates are provisioned per domain by using the `certProvider` callback and listen for certificate events emitted by `SmartProxy`. + +```typescript +import { SmartProxy } from '@push.rocks/smartproxy'; +import * as fs from 'fs'; + +// Example certProvider: static for a specific domain, HTTP-01 otherwise +const certProvider = async (domain: string) => { + if (domain === 'static.example.com') { + // Load from disk or vault + return { + id: 'static-cert', + domainName: domain, + created: Date.now(), + validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, + privateKey: fs.readFileSync('/etc/ssl/private/static.key', 'utf8'), + publicKey: fs.readFileSync('/etc/ssl/certs/static.crt', 'utf8'), + csr: '' + }; + } + // Fallback to ACME HTTP-01 challenge + return 'http01'; +}; + +const proxy = new SmartProxy({ + fromPort: 80, + toPort: 8080, + domainConfigs: [{ + domains: ['static.example.com', 'dynamic.example.com'], + allowedIPs: ['*'] + }], + certProvider +}); + +// Listen for certificate issuance or renewal +proxy.on('certificate', (evt) => { + console.log(`Certificate for ${evt.domain} ready, expires on ${evt.expiryDate}`); +}); + +await proxy.start(); +``` + ## Configuration Options ### backendProtocol diff --git a/readme.plan.md b/readme.plan.md index 460e302..f48bbec 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,23 +1,23 @@ ## Plan: Integrate @push.rocks/smartacme into Port80Handler -- [ ] read the complete README of @push.rocks/smartacme and understand the API. -- [ ] Add imports to ts/plugins.ts: +- [x] read the complete README of @push.rocks/smartacme and understand the API. +- [x] Add imports to ts/plugins.ts: - import * as smartacme from '@push.rocks/smartacme'; - export { smartacme }; -- [ ] In Port80Handler.start(): +- [x] In Port80Handler.start(): - Instantiate SmartAcme and use the in memory certmanager. - use the DisklessHttp01Handler implemented in classes.port80handler.ts - Call `await smartAcme.start()` before binding HTTP server. -- [ ] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code. -- [ ] Update `handleRequest()` to let DisklessHttp01Handler serve challenges. -- [ ] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo. +- [x] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code. +- [x] Update `handleRequest()` to let DisklessHttp01Handler serve challenges. +- [x] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo. ## Plan: Certificate Provider Hook & Observable Emission -- [ ] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts): +- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts): - Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`. - Add optional `certProvider?: (domain: string) => Promise`. -- [ ] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts): +- [x] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts): - Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`. - Call `super()` in constructor. - In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain: @@ -25,7 +25,7 @@ - If result is `'http01'`, register domain with `Port80Handler` for ACME challenges. - If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge` - Subscribe to `Port80HandlerEvents.CERTIFICATE_ISSUED` and `CERTIFICATE_RENEWED` and re-emit on `SmartProxy` as `'certificate'` events (include `domain`, `publicKey`, `privateKey`, `expiryDate`, `source: 'http01'`, `isRenewal` flag). -- [ ] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts): +- [x] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts): - Add public method `applyExternalCertificate(data: ICertificateData): void` to forward static certs into `NetworkProxy`. - [ ] Define `SmartProxy` `'certificate'` event interface in TypeScript and update documentation. - [ ] Update README with usage examples showing `certProvider` callback and listening for `'certificate'` events. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index bdb4cda..61e863c 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '7.1.4', + version: '7.2.0', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/port80handler/classes.port80handler.ts b/ts/port80handler/classes.port80handler.ts index c033311..3fee023 100644 --- a/ts/port80handler/classes.port80handler.ts +++ b/ts/port80handler/classes.port80handler.ts @@ -74,8 +74,6 @@ interface IDomainCertificate { obtainingInProgress: boolean; certificate?: string; privateKey?: string; - challengeToken?: string; - challengeKeyAuthorization?: string; expiryDate?: Date; lastRenewalAttempt?: Date; } @@ -94,7 +92,6 @@ interface IPort80HandlerOptions { autoRenew?: boolean; // Whether to automatically renew certificates certificateStore?: string; // Directory to store certificates skipConfiguredCerts?: boolean; // Skip domains that already have certificates - mongoDescriptor?: plugins.smartdata.IMongoDescriptor; // MongoDB config for SmartAcme } /** @@ -149,8 +146,6 @@ export class Port80Handler extends plugins.EventEmitter { // SmartAcme instance for certificate management private smartAcme: plugins.smartacme.SmartAcme | null = null; private server: plugins.http.Server | null = null; - private acmeClient: plugins.acme.Client | null = null; - private accountKey: string | null = null; private renewalTimer: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; private options: Required; @@ -197,13 +192,10 @@ export class Port80Handler extends plugins.EventEmitter { } // Initialize SmartAcme for ACME challenge management (diskless HTTP handler) if (this.options.enabled) { - if (!this.options.mongoDescriptor) { - throw new ServerError('MongoDB descriptor is required for SmartAcme'); - } this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: this.options.contactEmail, + certManager: new plugins.smartacme.MemoryCertManager(), environment: this.options.useProduction ? 'production' : 'integration', - mongoDescriptor: this.options.mongoDescriptor, challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ], challengePriority: ['http-01'], }); @@ -613,38 +605,6 @@ export class Port80Handler extends plugins.EventEmitter { } } - /** - * Lazy initialization of the ACME client - * @returns An ACME client instance - */ - private async getAcmeClient(): Promise { - if (this.acmeClient) { - return this.acmeClient; - } - - try { - // Generate a new account key - this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); - - this.acmeClient = new plugins.acme.Client({ - directoryUrl: this.options.useProduction - ? plugins.acme.directory.letsencrypt.production - : plugins.acme.directory.letsencrypt.staging, - accountKey: this.accountKey, - }); - - // Create a new account - await this.acmeClient.createAccount({ - termsOfServiceAgreed: true, - contact: [`mailto:${this.options.contactEmail}`], - }); - - return this.acmeClient; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client'; - throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`); - } - } /** * Handles incoming HTTP requests @@ -803,209 +763,73 @@ export class Port80Handler extends plugins.EventEmitter { } } - /** - * Serves the ACME HTTP-01 challenge response - * @param req The HTTP request - * @param res The HTTP response - * @param domain The domain for the challenge - */ - private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void { - const domainInfo = this.domainCertificates.get(domain); - if (!domainInfo) { - res.statusCode = 404; - res.end('Domain not configured'); - return; - } - - // The token is the last part of the URL - const urlParts = req.url?.split('/'); - const token = urlParts ? urlParts[urlParts.length - 1] : ''; - - if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end(domainInfo.challengeKeyAuthorization); - console.log(`Served ACME challenge response for ${domain}`); - } else { - res.statusCode = 404; - res.end('Challenge token not found'); - } - } /** * Obtains a certificate for a domain using ACME HTTP-01 challenge * @param domain The domain to obtain a certificate for * @param isRenewal Whether this is a renewal attempt */ + /** + * Obtains a certificate for a domain using SmartAcme HTTP-01 challenges + * @param domain The domain to obtain a certificate for + * @param isRenewal Whether this is a renewal attempt + */ private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise { - // Don't allow certificate issuance for glob patterns if (this.isGlobPattern(domain)) { throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); } - - // Get the domain info - const domainInfo = this.domainCertificates.get(domain); - if (!domainInfo) { - throw new CertificateError('Domain not found', domain, isRenewal); - } - - // Verify that acmeMaintenance is enabled + const domainInfo = this.domainCertificates.get(domain)!; if (!domainInfo.options.acmeMaintenance) { console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); return; } - - // Prevent concurrent certificate issuance if (domainInfo.obtainingInProgress) { console.log(`Certificate issuance already in progress for ${domain}`); return; } - + if (!this.smartAcme) { + throw new Port80HandlerError('SmartAcme is not initialized'); + } domainInfo.obtainingInProgress = true; domainInfo.lastRenewalAttempt = new Date(); - try { - const client = await this.getAcmeClient(); - - // Create a new order for the domain - const order = await client.createOrder({ - identifiers: [{ type: 'dns', value: domain }], - }); - - // Get the authorizations for the order - const authorizations = await client.getAuthorizations(order); - - // Process each authorization - await this.processAuthorizations(client, domain, authorizations); - - // Generate a CSR and private key - const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ - commonName: domain, - }); - - const csr = csrBuffer.toString(); - const privateKey = privateKeyBuffer.toString(); - - // Finalize the order with our CSR - await client.finalizeOrder(order, csr); - - // Get the certificate with the full chain - const certificate = await client.getCertificate(order); - - // Store the certificate and key + // Request certificate via SmartAcme + const certObj = await this.smartAcme.getCertificateForDomain(domain); + const certificate = certObj.publicKey; + const privateKey = certObj.privateKey; + const expiryDate = new Date(certObj.validUntil); domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; - - // Clear challenge data - delete domainInfo.challengeToken; - delete domainInfo.challengeKeyAuthorization; - - // Extract expiry date from certificate - domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); + domainInfo.expiryDate = expiryDate; console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); - - // Save the certificate to the store if enabled if (this.options.certificateStore) { this.saveCertificateToStore(domain, certificate, privateKey); } - - // Emit the appropriate event - const eventType = isRenewal - ? Port80HandlerEvents.CERTIFICATE_RENEWED + const eventType = isRenewal + ? Port80HandlerEvents.CERTIFICATE_RENEWED : Port80HandlerEvents.CERTIFICATE_ISSUED; - this.emitCertificateEvent(eventType, { domain, certificate, privateKey, - expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() + expiryDate: expiryDate || this.getDefaultExpiryDate() }); - } catch (error: any) { - // Check for rate limit errors - if (error.message && ( - error.message.includes('rateLimited') || - error.message.includes('too many certificates') || - error.message.includes('rate limit') - )) { - console.error(`Rate limit reached for ${domain}. Waiting before retry.`); - } else { - console.error(`Error during certificate issuance for ${domain}:`, error); - } - - // Emit failure event + const errorMsg = error?.message || 'Unknown error'; + console.error(`Error during certificate issuance for ${domain}:`, error); this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { domain, - error: error.message || 'Unknown error', + error: errorMsg, isRenewal } as ICertificateFailure); - - throw new CertificateError( - error.message || 'Certificate issuance failed', - domain, - isRenewal - ); + throw new CertificateError(errorMsg, domain, isRenewal); } finally { - // Reset flag whether successful or not domainInfo.obtainingInProgress = false; } } - /** - * Process ACME authorizations by verifying and completing challenges - * @param client ACME client - * @param domain Domain name - * @param authorizations Authorizations to process - */ - private async processAuthorizations( - client: plugins.acme.Client, - domain: string, - authorizations: plugins.acme.Authorization[] - ): Promise { - const domainInfo = this.domainCertificates.get(domain); - if (!domainInfo) { - throw new CertificateError('Domain not found during authorization', domain); - } - - for (const authz of authorizations) { - const challenge = authz.challenges.find(ch => ch.type === 'http-01'); - if (!challenge) { - throw new CertificateError('HTTP-01 challenge not found', domain); - } - - // Get the key authorization for the challenge - const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); - - // Store the challenge data - domainInfo.challengeToken = challenge.token; - domainInfo.challengeKeyAuthorization = keyAuthorization; - - // ACME client type definition workaround - use compatible approach - // First check if challenge verification is needed - const authzUrl = authz.url; - - try { - // Check if authzUrl exists and perform verification - if (authzUrl) { - await client.verifyChallenge(authz, challenge); - } - - // Complete the challenge - await client.completeChallenge(challenge); - - // Wait for validation - await client.waitForValidStatus(challenge); - console.log(`HTTP-01 challenge completed for ${domain}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error'; - console.error(`Challenge error for ${domain}:`, error); - throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain); - } - } - } - /** * Starts the certificate renewal timer */ diff --git a/ts/smartproxy/classes.pp.interfaces.ts b/ts/smartproxy/classes.pp.interfaces.ts index 1979f3d..4cee85a 100644 --- a/ts/smartproxy/classes.pp.interfaces.ts +++ b/ts/smartproxy/classes.pp.interfaces.ts @@ -1,5 +1,10 @@ import * as plugins from '../plugins.js'; +/** + * Provision object for static or HTTP-01 certificate + */ +export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; + /** Domain configuration with per-domain allowed port ranges */ export interface IDomainConfig { domains: string[]; // Glob patterns for domain(s) @@ -115,6 +120,11 @@ export interface IPortProxySettings { certificateStore?: string; skipConfiguredCerts?: boolean; }; + /** + * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, + * or a static certificate object for immediate provisioning. + */ + certProvider?: (domain: string) => Promise; } /** diff --git a/ts/smartproxy/classes.pp.networkproxybridge.ts b/ts/smartproxy/classes.pp.networkproxybridge.ts index 29e5d71..8a9e5f2 100644 --- a/ts/smartproxy/classes.pp.networkproxybridge.ts +++ b/ts/smartproxy/classes.pp.networkproxybridge.ts @@ -95,6 +95,17 @@ export class NetworkProxyBridge { } } + /** + * Apply an external (static) certificate into NetworkProxy + */ + public applyExternalCertificate(data: ICertificateData): void { + if (!this.networkProxy) { + console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`); + return; + } + this.handleCertificateEvent(data); + } + /** * Get the NetworkProxy instance */ diff --git a/ts/smartproxy/classes.smartproxy.ts b/ts/smartproxy/classes.smartproxy.ts index f1fc7b3..9d5703a 100644 --- a/ts/smartproxy/classes.smartproxy.ts +++ b/ts/smartproxy/classes.smartproxy.ts @@ -8,14 +8,14 @@ import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; import { TimeoutManager } from './classes.pp.timeoutmanager.js'; import { PortRangeManager } from './classes.pp.portrangemanager.js'; import { ConnectionHandler } from './classes.pp.connectionhandler.js'; -import { Port80Handler, Port80HandlerEvents } from '../port80handler/classes.port80handler.js'; +import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js'; import * as path from 'path'; import * as fs from 'fs'; /** * SmartProxy - Main class that coordinates all components */ -export class SmartProxy { +export class SmartProxy extends plugins.EventEmitter { private netServers: plugins.net.Server[] = []; private connectionLogger: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; @@ -34,6 +34,7 @@ export class SmartProxy { private port80Handler: Port80Handler | null = null; constructor(settingsArg: IPortProxySettings) { + super(); // Set reasonable defaults for all settings this.settings = { ...settingsArg, @@ -180,29 +181,67 @@ export class SmartProxy { } } - // Register all non-wildcard domains from domain configs + // Provision certificates per domain via certProvider or HTTP-01 for (const domainConfig of this.settings.domainConfigs) { for (const domain of domainConfig.domains) { - // Skip wildcards + // Skip wildcard domains if (domain.includes('*')) continue; - - this.port80Handler.addDomain({ - domainName: domain, - sslRedirect: true, - acmeMaintenance: true - }); - - console.log(`Registered domain ${domain} with Port80Handler`); + // Determine provisioning method + let provision = 'http01' as string | plugins.tsclass.network.ICert; + if (this.settings.certProvider) { + try { + provision = await this.settings.certProvider(domain); + } catch (err) { + console.log(`certProvider error for ${domain}: ${err}`); + } + } + if (provision === 'http01') { + this.port80Handler.addDomain({ + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }); + console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`); + } else { + // Static certificate provided + const certObj = provision as plugins.tsclass.network.ICert; + const certData: ICertificateData = { + domain: certObj.domainName, + certificate: certObj.publicKey, + privateKey: certObj.privateKey, + expiryDate: new Date(certObj.validUntil) + }; + this.networkProxyBridge.applyExternalCertificate(certData); + console.log(`Applied static certificate for ${domain} from certProvider`); + } } } // Set up event listeners this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => { console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); + // Re-emit on SmartProxy + this.emit('certificate', { + domain: certData.domain, + publicKey: certData.certificate, + privateKey: certData.privateKey, + expiryDate: certData.expiryDate, + source: 'http01', + isRenewal: false + }); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => { console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); + // Re-emit on SmartProxy + this.emit('certificate', { + domain: certData.domain, + publicKey: certData.certificate, + privateKey: certData.privateKey, + expiryDate: certData.expiryDate, + source: 'http01', + isRenewal: true + }); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (failureData) => { @@ -429,22 +468,40 @@ export class SmartProxy { await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); } - // If Port80Handler is running, register non-wildcard domains + // If Port80Handler is running, provision certificates per new domain if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) { for (const domainConfig of newDomainConfigs) { for (const domain of domainConfig.domains) { - // Skip wildcards if (domain.includes('*')) continue; - - this.port80Handler.addDomain({ - domainName: domain, - sslRedirect: true, - acmeMaintenance: true - }); + let provision = 'http01' as string | plugins.tsclass.network.ICert; + if (this.settings.certProvider) { + try { + provision = await this.settings.certProvider(domain); + } catch (err) { + console.log(`certProvider error for ${domain}: ${err}`); + } + } + if (provision === 'http01') { + this.port80Handler.addDomain({ + domainName: domain, + sslRedirect: true, + acmeMaintenance: true + }); + console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`); + } else { + const certObj = provision as plugins.tsclass.network.ICert; + const certData: ICertificateData = { + domain: certObj.domainName, + certificate: certObj.publicKey, + privateKey: certObj.privateKey, + expiryDate: new Date(certObj.validUntil) + }; + this.networkProxyBridge.applyExternalCertificate(certData); + console.log(`Applied static certificate for ${domain} from certProvider`); + } } } - - console.log('Registered non-wildcard domains with Port80Handler'); + console.log('Provisioned certificates for new domains'); } }