From 8a396a04fab308c642d6a20834a75886f740edff Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Fri, 2 May 2025 11:19:14 +0000 Subject: [PATCH] BREAKING CHANGE(certProvisioner): Refactor: Introduce unified CertProvisioner to centralize certificate provisioning and renewal; remove legacy ACME config from Port80Handler and update SmartProxy to delegate certificate lifecycle management. --- changelog.md | 9 + readme.plan.md | 71 ++++--- test/test.certprovisioner.unit.ts | 140 +++++++++++++ ts/00_commitinfo_data.ts | 2 +- .../classes.np.certificatemanager.ts | 3 - ts/port80handler/classes.port80handler.ts | 162 ++------------- ts/smartproxy/classes.pp.certprovisioner.ts | 183 +++++++++++++++++ ts/smartproxy/classes.pp.interfaces.ts | 11 - .../classes.pp.networkproxybridge.ts | 8 +- ts/smartproxy/classes.pp.portrangemanager.ts | 10 - ts/smartproxy/classes.smartproxy.ts | 190 +++++------------- 11 files changed, 447 insertions(+), 342 deletions(-) create mode 100644 test/test.certprovisioner.unit.ts create mode 100644 ts/smartproxy/classes.pp.certprovisioner.ts diff --git a/changelog.md b/changelog.md index fce9251..0bf17cb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-05-02 - 8.0.0 - BREAKING CHANGE(certProvisioner) +Refactor: Introduce unified CertProvisioner to centralize certificate provisioning and renewal; remove legacy ACME config from Port80Handler and update SmartProxy to delegate certificate lifecycle management. + +- Removed deprecated acme properties and renewal scheduler from IPort80HandlerOptions and Port80Handler. +- Created new CertProvisioner component in ts/smartproxy/classes.pp.certprovisioner.ts to handle static and HTTP-01 certificate workflows. +- Updated SmartProxy to initialize CertProvisioner and re-emit certificate events. +- Eliminated legacy renewal logic and associated autoRenew settings from Port80Handler. +- Adjusted tests to reflect changes in certificate provisioning and renewal behavior. + ## 2025-05-01 - 7.2.0 - feat(ACME/Certificate) Introduce certificate provider hook and observable certificate events; remove legacy ACME flow diff --git a/readme.plan.md b/readme.plan.md index 8ee2888..38ce935 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,26 +1,47 @@ -## Plan: Centralize Certificate Renewal for all certificates +## Refactor: Introduce a Unified CertProvisioner for Certificate Lifecycle -- [ ] Remove renewal logic from Port80Handler - - Delete `startRenewalTimer()` and `checkForRenewals()` methods - - Remove `renewThresholdDays` and `renewCheckIntervalHours` options from `IPort80HandlerOptions` -- [ ] Expose certificate status from Port80Handler - - Ensure `getDomainCertificateStatus()` returns `{certObtained, expiryDate}` for each domain -- [ ] Add renewal settings to SmartProxy - - Extend `port80HandlerConfig` to include `renewThresholdDays` and `renewCheckIntervalHours` -- [ ] Implement renewal scheduler in SmartProxy using taskbuffer - - Add dependency on `@push.rocks/taskbuffer` and import `{ Task, TaskManager }` in `SmartProxy` - - Add `performRenewals()` to iterate domains and trigger renewals where `daysRemaining <= renewThresholdDays` - - Instantiate a `TaskManager` and define a `Task` that wraps `performRenewals()` - - Use `taskManager.addAndScheduleTask(task, cronExpr)` to schedule renewals, building `cronExpr` from `renewCheckIntervalHours` (e.g. `0 0 */${renewCheckIntervalHours} * * *`) - - Call `taskManager.start()` in `SmartProxy.start()` -- [ ] Clean shutdown handling - - Call `taskManager.stop()` in `SmartProxy.stop()` alongside other cleanup -- [ ] Throttling and safety - - Skip domains already in `obtainingInProgress` - - Optionally batch or stagger renewal calls for large domain sets -- [ ] Tests - - Unit test `performRenewals()`, mocking `getDomainCertificateStatus()` to simulate expiring certificates - - Integration test using an in-memory `Port80Handler` to verify that scheduled renewals invoke `obtainCertificate()` correctly -- [ ] Documentation - - Update `readme.plan.md` (this section) - - Update `README.md` and code comments to document new renewal settings and workflow +- [x] Ensure Port80Handler is challenge-only: + - Remove any internal scheduling and deprecated ACME flows (`getAcmeClient`, `processAuthorizations`, `handleAcmeChallenge`) from Port80Handler. + - Remove legacy ACME options (`renewThresholdDays`, `renewCheckIntervalHours`, `mongoDescriptor`, etc.) from `IPort80HandlerOptions`. + - Retain only methods for HTTP-01 challenge and direct renewals (`obtainCertificate`, `renewCertificate`, `getDomainCertificateStatus`). +- [x] Clean up deprecated `acme` configuration: + - Remove the `acme` property from `IPortProxySettings` and all legacy references in code. + +- [x] Implement `CertProvisioner` component: + - [x] Create class `ts/smartproxy/classes.pp.certprovisioner.ts`. + - [x] Constructor accepts: + * `domainConfigs: IDomainConfig[]` + * `port80Handler: Port80Handler` + * `networkProxyBridge: NetworkProxyBridge` + * optional `certProvider: (domain) => Promise` + * `renewThresholdDays`, `renewCheckIntervalHours`, `autoRenew` settings. + - Responsibilities: + * Initial provisioning: static vs HTTP-01. + * Subscribe to Port80Handler events (CERTIFICATE_ISSUED/RENEWED) and to static cert updates. + * Re-emit unified `'certificate'` events to SmartProxy. + * Central scheduling of renewals via `@push.rocks/taskbuffer`. + +- [x] Refactor SmartProxy: + - [x] Remove existing scheduling / renewal logic. + - [x] Instantiate `CertProvisioner` in `start()`, delegate cert workflows entirely. + - [x] Forward CertProvisioner events to SmartProxy’s `'certificate'` listener. + +- [x] CertProvisioner lifecycle methods: + - [x] `start()`: provision all domains, start scheduler. + - [x] `stop()`: stop scheduler. + - [x] `requestCertificate(domain)`: on-demand provisioning. + +- [x] Handle static certificate auto-refresh: + - [x] In the renewal scheduler, for domains with static certs, re-call `certProvider(domain)` near expiry. + - [x] Apply returned cert via `networkProxyBridge.applyExternalCertificate()`. + +- [ ] Tests: + - Unit tests for `CertProvisioner`, mocking Port80Handler and `certProvider`: + * Validate initial provisioning and dynamic/static flows. + * Validate scheduling triggers correct renewals. + - Integration tests: + * Use actual in-memory Port80Handler with short intervals to verify renewals and event emission. + +- [ ] Documentation: + - Add code-level TS doc for `CertProvisioner` API (options, methods, events). + - Update root `README.md` and architecture diagrams to show `CertProvisioner` role. diff --git a/test/test.certprovisioner.unit.ts b/test/test.certprovisioner.unit.ts new file mode 100644 index 0000000..2b3300d --- /dev/null +++ b/test/test.certprovisioner.unit.ts @@ -0,0 +1,140 @@ +import { tap, expect } from '@push.rocks/tapbundle'; +import * as plugins from '../ts/plugins.js'; +import { CertProvisioner } from '../ts/smartproxy/classes.pp.certprovisioner.js'; +import type { IDomainConfig, ISmartProxyCertProvisionObject } from '../ts/smartproxy/classes.pp.interfaces.js'; +import type { ICertificateData } from '../ts/port80handler/classes.port80handler.js'; + +// Fake Port80Handler stub +class FakePort80Handler extends plugins.EventEmitter { + public domainsAdded: string[] = []; + public renewCalled: string[] = []; + addDomain(opts: { domainName: string; sslRedirect: boolean; acmeMaintenance: boolean }) { + this.domainsAdded.push(opts.domainName); + } + async renewCertificate(domain: string): Promise { + this.renewCalled.push(domain); + } +} + +// Fake NetworkProxyBridge stub +class FakeNetworkProxyBridge { + public appliedCerts: ICertificateData[] = []; + applyExternalCertificate(cert: ICertificateData) { + this.appliedCerts.push(cert); + } +} + +tap.test('CertProvisioner handles static provisioning', async () => { + const domain = 'static.com'; + const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }]; + const fakePort80 = new FakePort80Handler(); + const fakeBridge = new FakeNetworkProxyBridge(); + // certProvider returns static certificate + const certProvider = async (d: string): Promise => { + expect(d).toEqual(domain); + return { + domainName: domain, + publicKey: 'CERT', + privateKey: 'KEY', + validUntil: Date.now() + 3600 * 1000 + }; + }; + const prov = new CertProvisioner( + domainConfigs, + fakePort80 as any, + fakeBridge as any, + certProvider, + 1, // low renew threshold + 1, // short interval + false // disable auto renew for unit test + ); + const events: any[] = []; + prov.on('certificate', (data) => events.push(data)); + await prov.start(); + // Static flow: no addDomain, certificate applied via bridge + expect(fakePort80.domainsAdded.length).toEqual(0); + expect(fakeBridge.appliedCerts.length).toEqual(1); + expect(events.length).toEqual(1); + const evt = events[0]; + expect(evt.domain).toEqual(domain); + expect(evt.certificate).toEqual('CERT'); + expect(evt.privateKey).toEqual('KEY'); + expect(evt.isRenewal).toEqual(false); + expect(evt.source).toEqual('static'); +}); + +tap.test('CertProvisioner handles http01 provisioning', async () => { + const domain = 'http01.com'; + const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }]; + const fakePort80 = new FakePort80Handler(); + const fakeBridge = new FakeNetworkProxyBridge(); + // certProvider returns http01 directive + const certProvider = async (): Promise => 'http01'; + const prov = new CertProvisioner( + domainConfigs, + fakePort80 as any, + fakeBridge as any, + certProvider, + 1, + 1, + false + ); + const events: any[] = []; + prov.on('certificate', (data) => events.push(data)); + await prov.start(); + // HTTP-01 flow: addDomain called, no static cert applied + expect(fakePort80.domainsAdded).toEqual([domain]); + expect(fakeBridge.appliedCerts.length).toEqual(0); + expect(events.length).toEqual(0); +}); + +tap.test('CertProvisioner on-demand http01 renewal', async () => { + const domain = 'renew.com'; + const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }]; + const fakePort80 = new FakePort80Handler(); + const fakeBridge = new FakeNetworkProxyBridge(); + const certProvider = async (): Promise => 'http01'; + const prov = new CertProvisioner( + domainConfigs, + fakePort80 as any, + fakeBridge as any, + certProvider, + 1, + 1, + false + ); + // requestCertificate should call renewCertificate + await prov.requestCertificate(domain); + expect(fakePort80.renewCalled).toEqual([domain]); +}); + +tap.test('CertProvisioner on-demand static provisioning', async () => { + const domain = 'ondemand.com'; + const domainConfigs: IDomainConfig[] = [{ domains: [domain], allowedIPs: [] }]; + const fakePort80 = new FakePort80Handler(); + const fakeBridge = new FakeNetworkProxyBridge(); + const certProvider = async (): Promise => ({ + domainName: domain, + publicKey: 'PKEY', + privateKey: 'PRIV', + validUntil: Date.now() + 1000 + }); + const prov = new CertProvisioner( + domainConfigs, + fakePort80 as any, + fakeBridge as any, + certProvider, + 1, + 1, + false + ); + const events: any[] = []; + prov.on('certificate', (data) => events.push(data)); + await prov.requestCertificate(domain); + expect(fakeBridge.appliedCerts.length).toEqual(1); + expect(events.length).toEqual(1); + expect(events[0].domain).toEqual(domain); + expect(events[0].source).toEqual('static'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 61e863c..4662021 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.2.0', + version: '8.0.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/networkproxy/classes.np.certificatemanager.ts b/ts/networkproxy/classes.np.certificatemanager.ts index 0e1fca4..100fe50 100644 --- a/ts/networkproxy/classes.np.certificatemanager.ts +++ b/ts/networkproxy/classes.np.certificatemanager.ts @@ -353,11 +353,8 @@ export class CertificateManager { 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 enabled: this.options.acme.enabled, - autoRenew: this.options.acme.autoRenew, certificateStore: this.options.acme.certificateStore, skipConfiguredCerts: this.options.acme.skipConfiguredCerts }); diff --git a/ts/port80handler/classes.port80handler.ts b/ts/port80handler/classes.port80handler.ts index aac09f2..21a600c 100644 --- a/ts/port80handler/classes.port80handler.ts +++ b/ts/port80handler/classes.port80handler.ts @@ -1,7 +1,6 @@ import * as plugins from '../plugins.js'; import { IncomingMessage, ServerResponse } from 'http'; -import * as fs from 'fs'; -import * as path from 'path'; +// (fs and path I/O moved to CertProvisioner) // ACME HTTP-01 challenge handler storing tokens in memory (diskless) class DisklessHttp01Handler { private storage: Map; @@ -87,9 +86,7 @@ interface IPort80HandlerOptions { useProduction?: boolean; httpsRedirectPort?: number; enabled?: boolean; // Whether ACME is enabled at all - autoRenew?: boolean; // Whether to automatically renew certificates - certificateStore?: string; // Directory to store certificates - skipConfiguredCerts?: boolean; // Skip domains that already have certificates + // (Persistence moved to CertProvisioner) } /** @@ -163,10 +160,7 @@ export class Port80Handler extends plugins.EventEmitter { contactEmail: options.contactEmail ?? 'admin@example.com', useProduction: options.useProduction ?? false, // Safer default: staging httpsRedirectPort: options.httpsRedirectPort ?? 443, - enabled: options.enabled ?? true, // Enable by default - autoRenew: options.autoRenew ?? true, // Auto-renew by default - certificateStore: options.certificateStore ?? './certs', // Default store location - skipConfiguredCerts: options.skipConfiguredCerts ?? false + enabled: options.enabled ?? true // Enable by default }; } @@ -201,10 +195,6 @@ export class Port80Handler extends plugins.EventEmitter { return new Promise((resolve, reject) => { try { - // Load certificates from store if enabled - if (this.options.certificateStore) { - this.loadCertificatesFromStore(); - } this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); @@ -370,10 +360,7 @@ export class Port80Handler extends plugins.EventEmitter { console.log(`Certificate set for ${domain}`); - // Save certificate to store if enabled - if (this.options.certificateStore) { - this.saveCertificateToStore(domain, certificate, privateKey); - } + // (Persistence of certificates moved to CertProvisioner) // Emit certificate event this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { @@ -408,134 +395,7 @@ export class Port80Handler extends plugins.EventEmitter { }; } - /** - * Saves a certificate to the filesystem store - * @param domain The domain for the certificate - * @param certificate The certificate (PEM format) - * @param privateKey The private key (PEM format) - * @private - */ - private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void { - // Skip if certificate store is not enabled - if (!this.options.certificateStore) return; - - try { - const storePath = this.options.certificateStore; - - // Ensure the directory exists - if (!fs.existsSync(storePath)) { - fs.mkdirSync(storePath, { recursive: true }); - console.log(`Created certificate store directory: ${storePath}`); - } - - const certPath = path.join(storePath, `${domain}.cert.pem`); - const keyPath = path.join(storePath, `${domain}.key.pem`); - - // Write certificate and private key files - fs.writeFileSync(certPath, certificate); - fs.writeFileSync(keyPath, privateKey); - - // Set secure permissions for private key - try { - fs.chmodSync(keyPath, 0o600); - } catch (err) { - console.log(`Warning: Could not set secure permissions on ${keyPath}`); - } - - console.log(`Saved certificate for ${domain} to ${certPath}`); - } catch (err) { - console.error(`Error saving certificate for ${domain}:`, err); - } - } - /** - * Loads certificates from the certificate store - * @private - */ - private loadCertificatesFromStore(): void { - if (!this.options.certificateStore) return; - - try { - const storePath = this.options.certificateStore; - - // Ensure the directory exists - if (!fs.existsSync(storePath)) { - fs.mkdirSync(storePath, { recursive: true }); - console.log(`Created certificate store directory: ${storePath}`); - return; - } - - // Get list of certificate files - const files = fs.readdirSync(storePath); - const certFiles = files.filter(file => file.endsWith('.cert.pem')); - - // Load each certificate - for (const certFile of certFiles) { - const domain = certFile.replace('.cert.pem', ''); - const keyFile = `${domain}.key.pem`; - - // Skip if key file doesn't exist - if (!files.includes(keyFile)) { - console.log(`Warning: Found certificate for ${domain} but no key file`); - continue; - } - - // Skip if we should skip configured certs - if (this.options.skipConfiguredCerts) { - const domainInfo = this.domainCertificates.get(domain); - if (domainInfo && domainInfo.certObtained) { - console.log(`Skipping already configured certificate for ${domain}`); - continue; - } - } - - // Load certificate and key - try { - const certificate = fs.readFileSync(path.join(storePath, certFile), 'utf8'); - const privateKey = fs.readFileSync(path.join(storePath, keyFile), 'utf8'); - - // Extract expiry date - let expiryDate: Date | undefined; - try { - const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); - if (matches && matches[1]) { - expiryDate = new Date(matches[1]); - } - } catch (err) { - console.log(`Warning: Could not extract expiry date from certificate for ${domain}`); - } - - // Check if domain is already registered - let domainInfo = this.domainCertificates.get(domain); - if (!domainInfo) { - // Register domain if not already registered - domainInfo = { - options: { - domainName: domain, - sslRedirect: true, - acmeMaintenance: true - }, - certObtained: false, - obtainingInProgress: false - }; - this.domainCertificates.set(domain, domainInfo); - } - - // Set certificate - domainInfo.certificate = certificate; - domainInfo.privateKey = privateKey; - domainInfo.certObtained = true; - domainInfo.expiryDate = expiryDate; - - console.log(`Loaded certificate for ${domain} from store, valid until ${expiryDate?.toISOString() || 'unknown'}`); - } catch (err) { - console.error(`Error loading certificate for ${domain}:`, err); - } - } - } catch (err) { - console.error('Error loading certificates from store:', err); - } - } /** * Check if a domain is a glob pattern @@ -625,13 +485,19 @@ export class Port80Handler extends plugins.EventEmitter { const { domainInfo, pattern } = domainMatch; const options = domainInfo.options; - // Serve or forward ACME HTTP-01 challenge requests - if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) { + // Handle ACME HTTP-01 challenge requests or forwarding + if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { // Forward ACME requests if configured if (options.acmeForward) { this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); return; } + // If not managing ACME for this domain, return 404 + if (!options.acmeMaintenance) { + res.statusCode = 404; + res.end('Not found'); + return; + } // Serve challenge response from in-memory storage const token = req.url.split('/').pop() || ''; const keyAuth = this.acmeHttp01Storage.get(token); @@ -795,9 +661,7 @@ export class Port80Handler extends plugins.EventEmitter { domainInfo.expiryDate = expiryDate; console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); - if (this.options.certificateStore) { - this.saveCertificateToStore(domain, certificate, privateKey); - } + // Persistence moved to CertProvisioner const eventType = isRenewal ? Port80HandlerEvents.CERTIFICATE_RENEWED : Port80HandlerEvents.CERTIFICATE_ISSUED; diff --git a/ts/smartproxy/classes.pp.certprovisioner.ts b/ts/smartproxy/classes.pp.certprovisioner.ts new file mode 100644 index 0000000..92b12d1 --- /dev/null +++ b/ts/smartproxy/classes.pp.certprovisioner.ts @@ -0,0 +1,183 @@ +import * as plugins from '../plugins.js'; +import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js'; +import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js'; +import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; + +/** + * CertProvisioner manages certificate provisioning and renewal workflows, + * unifying static certificates and HTTP-01 challenges via Port80Handler. + */ +export class CertProvisioner extends plugins.EventEmitter { + private domainConfigs: IDomainConfig[]; + private port80Handler: Port80Handler; + private networkProxyBridge: NetworkProxyBridge; + private certProvider?: (domain: string) => Promise; + private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>; + private renewThresholdDays: number; + private renewCheckIntervalHours: number; + private autoRenew: boolean; + private renewManager?: plugins.taskbuffer.TaskManager; + // Track provisioning type per domain: 'http01' or 'static' + private provisionMap: Map; + + /** + * @param domainConfigs Array of domain configuration objects + * @param port80Handler HTTP-01 challenge handler instance + * @param networkProxyBridge Bridge for applying external certificates + * @param certProvider Optional callback returning a static cert or 'http01' + * @param renewThresholdDays Days before expiry to trigger renewals + * @param renewCheckIntervalHours Interval in hours to check for renewals + * @param autoRenew Whether to automatically schedule renewals + */ + constructor( + domainConfigs: IDomainConfig[], + port80Handler: Port80Handler, + networkProxyBridge: NetworkProxyBridge, + certProvider?: (domain: string) => Promise, + renewThresholdDays: number = 30, + renewCheckIntervalHours: number = 24, + autoRenew: boolean = true, + forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = [] + ) { + super(); + this.domainConfigs = domainConfigs; + this.port80Handler = port80Handler; + this.networkProxyBridge = networkProxyBridge; + this.certProvider = certProvider; + this.renewThresholdDays = renewThresholdDays; + this.renewCheckIntervalHours = renewCheckIntervalHours; + this.autoRenew = autoRenew; + this.provisionMap = new Map(); + this.forwardConfigs = forwardConfigs; + } + + /** + * Start initial provisioning and schedule renewals. + */ + public async start(): Promise { + // Subscribe to Port80Handler certificate events + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { + this.emit('certificate', { ...data, source: 'http01', isRenewal: false }); + }); + this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { + this.emit('certificate', { ...data, source: 'http01', isRenewal: true }); + }); + + // Apply external forwarding for ACME challenges (e.g. Synology) + for (const f of this.forwardConfigs) { + this.port80Handler.addDomain({ + domainName: f.domain, + sslRedirect: f.sslRedirect, + acmeMaintenance: false, + forward: f.forwardConfig, + acmeForward: f.acmeForwardConfig + }); + } + // Initial provisioning for all domains + const domains = this.domainConfigs.flatMap(cfg => cfg.domains); + for (const domain of domains) { + // Skip wildcard domains + if (domain.includes('*')) continue; + let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; + if (this.certProvider) { + try { + provision = await this.certProvider(domain); + } catch (err) { + console.error(`certProvider error for ${domain}:`, err); + } + } + if (provision === 'http01') { + this.provisionMap.set(domain, 'http01'); + this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); + } else { + this.provisionMap.set(domain, 'static'); + 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); + this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); + } + } + + // Schedule renewals if enabled + if (this.autoRenew) { + this.renewManager = new plugins.taskbuffer.TaskManager(); + const renewTask = new plugins.taskbuffer.Task({ + name: 'CertificateRenewals', + taskFunction: async () => { + for (const [domain, type] of this.provisionMap.entries()) { + // Skip wildcard domains + if (domain.includes('*')) continue; + try { + if (type === 'http01') { + await this.port80Handler.renewCertificate(domain); + } else if (type === 'static' && this.certProvider) { + const provision2 = await this.certProvider(domain); + if (provision2 !== 'http01') { + const certObj = provision2 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); + this.emit('certificate', { ...certData, source: 'static', isRenewal: true }); + } + } + } catch (err) { + console.error(`Renewal error for ${domain}:`, err); + } + } + } + }); + const hours = this.renewCheckIntervalHours; + const cronExpr = `0 0 */${hours} * * *`; + this.renewManager.addAndScheduleTask(renewTask, cronExpr); + this.renewManager.start(); + } + } + + /** + * Stop all scheduled renewal tasks. + */ + public async stop(): Promise { + // Stop scheduled renewals + if (this.renewManager) { + this.renewManager.stop(); + } + } + + /** + * Request a certificate on-demand for the given domain. + * @param domain Domain name to provision + */ + public async requestCertificate(domain: string): Promise { + // Skip wildcard domains + if (domain.includes('*')) { + throw new Error(`Cannot request certificate for wildcard domain: ${domain}`); + } + // Determine provisioning method + let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; + if (this.certProvider) { + provision = await this.certProvider(domain); + } + if (provision === 'http01') { + await this.port80Handler.renewCertificate(domain); + } 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); + this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); + } + } +} \ No newline at end of file diff --git a/ts/smartproxy/classes.pp.interfaces.ts b/ts/smartproxy/classes.pp.interfaces.ts index 4cee85a..176c483 100644 --- a/ts/smartproxy/classes.pp.interfaces.ts +++ b/ts/smartproxy/classes.pp.interfaces.ts @@ -109,17 +109,6 @@ export interface IPortProxySettings { }>; }; - // Legacy ACME configuration (deprecated, use port80HandlerConfig instead) - acme?: { - enabled?: boolean; - port?: number; - contactEmail?: string; - useProduction?: boolean; - renewThresholdDays?: number; - autoRenew?: boolean; - certificateStore?: string; - skipConfiguredCerts?: boolean; - }; /** * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, * or a static certificate object for immediate provisioning. diff --git a/ts/smartproxy/classes.pp.networkproxybridge.ts b/ts/smartproxy/classes.pp.networkproxybridge.ts index 8a9e5f2..2c8b9d7 100644 --- a/ts/smartproxy/classes.pp.networkproxybridge.ts +++ b/ts/smartproxy/classes.pp.networkproxybridge.ts @@ -43,10 +43,6 @@ export class NetworkProxyBridge { useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available }; - // Copy ACME settings for backward compatibility (if port80HandlerConfig not set) - if (!this.settings.port80HandlerConfig && this.settings.acme) { - networkProxyOptions.acme = { ...this.settings.acme }; - } this.networkProxy = new NetworkProxy(networkProxyOptions); @@ -288,7 +284,7 @@ export class NetworkProxyBridge { ); // Log ACME-eligible domains - const acmeEnabled = this.settings.port80HandlerConfig?.enabled || this.settings.acme?.enabled; + const acmeEnabled = !!this.settings.port80HandlerConfig?.enabled; if (acmeEnabled) { const acmeEligibleDomains = proxyConfigs .filter((config) => !config.hostName.includes('*')) // Exclude wildcards @@ -349,7 +345,7 @@ export class NetworkProxyBridge { return false; } - if (!this.settings.port80HandlerConfig?.enabled && !this.settings.acme?.enabled) { + if (!this.settings.port80HandlerConfig?.enabled) { console.log('Cannot request certificate - ACME is not enabled'); return false; } diff --git a/ts/smartproxy/classes.pp.portrangemanager.ts b/ts/smartproxy/classes.pp.portrangemanager.ts index 5872a7d..4cc8a88 100644 --- a/ts/smartproxy/classes.pp.portrangemanager.ts +++ b/ts/smartproxy/classes.pp.portrangemanager.ts @@ -117,10 +117,6 @@ export class PortRangeManager { } } - // Add ACME HTTP challenge port if enabled - if (this.settings.acme?.enabled && this.settings.acme.port) { - ports.add(this.settings.acme.port); - } // Add global port ranges if (this.settings.globalPortRanges) { @@ -202,12 +198,6 @@ export class PortRangeManager { warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`); } - // Check ACME port - if (this.settings.acme?.enabled && this.settings.acme.port) { - if (portMappings.has(this.settings.acme.port)) { - warnings.push(`ACME HTTP challenge port ${this.settings.acme.port} is also used in port ranges`); - } - } return warnings; } diff --git a/ts/smartproxy/classes.smartproxy.ts b/ts/smartproxy/classes.smartproxy.ts index 475518a..4bfb914 100644 --- a/ts/smartproxy/classes.smartproxy.ts +++ b/ts/smartproxy/classes.smartproxy.ts @@ -8,7 +8,9 @@ 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, type ICertificateData } from '../port80handler/classes.port80handler.js'; +import { Port80Handler } from '../port80handler/classes.port80handler.js'; +import { CertProvisioner } from './classes.pp.certprovisioner.js'; +import type { ICertificateData } from '../port80handler/classes.port80handler.js'; import * as path from 'path'; import * as fs from 'fs'; @@ -32,8 +34,8 @@ export class SmartProxy extends plugins.EventEmitter { // Port80Handler for ACME certificate management private port80Handler: Port80Handler | null = null; - // Renewal scheduler for certificates - private renewManager?: plugins.taskbuffer.TaskManager; + // CertProvisioner for unified certificate workflows + private certProvisioner?: CertProvisioner; constructor(settingsArg: IPortProxySettings) { super(); @@ -69,37 +71,20 @@ export class SmartProxy extends plugins.EventEmitter { globalPortRanges: settingsArg.globalPortRanges || [], }; - // Set port80HandlerConfig defaults, using legacy acme config if available + // Set default port80HandlerConfig if not provided if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) { - if (this.settings.acme) { - // Migrate from legacy acme config - this.settings.port80HandlerConfig = { - enabled: this.settings.acme.enabled, - port: this.settings.acme.port || 80, - contactEmail: this.settings.acme.contactEmail || 'admin@example.com', - useProduction: this.settings.acme.useProduction || false, - renewThresholdDays: this.settings.acme.renewThresholdDays || 30, - autoRenew: this.settings.acme.autoRenew !== false, // Default to true - certificateStore: this.settings.acme.certificateStore || './certs', - skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, - httpsRedirectPort: this.settings.fromPort, - renewCheckIntervalHours: 24 - }; - } else { - // Set defaults if no config provided - this.settings.port80HandlerConfig = { - enabled: false, - port: 80, - contactEmail: 'admin@example.com', - useProduction: false, - renewThresholdDays: 30, - autoRenew: true, - certificateStore: './certs', - skipConfiguredCerts: false, - httpsRedirectPort: this.settings.fromPort, - renewCheckIntervalHours: 24 - }; - } + this.settings.port80HandlerConfig = { + enabled: false, + port: 80, + contactEmail: 'admin@example.com', + useProduction: false, + renewThresholdDays: 30, + autoRenew: true, + certificateStore: './certs', + skipConfiguredCerts: false, + httpsRedirectPort: this.settings.fromPort, + renewCheckIntervalHours: 24 + }; } // Initialize component managers @@ -161,96 +146,11 @@ export class SmartProxy extends plugins.EventEmitter { useProduction: config.useProduction, httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort, enabled: config.enabled, - autoRenew: config.autoRenew, certificateStore: config.certificateStore, skipConfiguredCerts: config.skipConfiguredCerts }); - // Register domain forwarding configurations - if (config.domainForwards) { - for (const forward of config.domainForwards) { - this.port80Handler.addDomain({ - domainName: forward.domain, - sslRedirect: true, - acmeMaintenance: true, - forward: forward.forwardConfig, - acmeForward: forward.acmeForwardConfig - }); - - console.log(`Registered domain forwarding for ${forward.domain}`); - } - } - // Provision certificates per domain via certProvider or HTTP-01 - for (const domainConfig of this.settings.domainConfigs) { - for (const domain of domainConfig.domains) { - // Skip wildcard domains - if (domain.includes('*')) continue; - // 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) => { - console.log(`Certificate ${failureData.isRenewal ? 'renewal' : 'issuance'} failed for ${failureData.domain}: ${failureData.error}`); - }); - - this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (expiryData) => { - console.log(`Certificate for ${expiryData.domain} is expiring in ${expiryData.daysRemaining} days`); - }); // Share Port80Handler with NetworkProxyBridge this.networkProxyBridge.setPort80Handler(this.port80Handler); @@ -258,21 +158,6 @@ export class SmartProxy extends plugins.EventEmitter { // Start Port80Handler await this.port80Handler.start(); console.log(`Port80Handler started on port ${config.port}`); - // Schedule certificate renewals using taskbuffer - if (config.autoRenew) { - this.renewManager = new plugins.taskbuffer.TaskManager(); - const renewTask = new plugins.taskbuffer.Task({ - name: 'CertificateRenewals', - taskFunction: async () => { - await (this as any).performRenewals(); - } - }); - const hours = config.renewCheckIntervalHours!; - const cronExpr = `0 0 */${hours} * * *`; - this.renewManager.addAndScheduleTask(renewTask, cronExpr); - this.renewManager.start(); - console.log(`Scheduled certificate renewals every ${hours} hours`); - } } catch (err) { console.log(`Error initializing Port80Handler: ${err}`); } @@ -290,6 +175,37 @@ export class SmartProxy extends plugins.EventEmitter { // Initialize Port80Handler if enabled await this.initializePort80Handler(); + // Initialize CertProvisioner for unified certificate workflows + if (this.port80Handler) { + this.certProvisioner = new CertProvisioner( + this.settings.domainConfigs, + this.port80Handler, + this.networkProxyBridge, + this.settings.certProvider, + this.settings.port80HandlerConfig?.renewThresholdDays || 30, + this.settings.port80HandlerConfig?.renewCheckIntervalHours || 24, + this.settings.port80HandlerConfig?.autoRenew !== false, + // External ACME forwarding for specific domains + this.settings.port80HandlerConfig?.domainForwards?.map(f => ({ + domain: f.domain, + forwardConfig: f.forwardConfig, + acmeForwardConfig: f.acmeForwardConfig, + sslRedirect: false + })) || [] + ); + this.certProvisioner.on('certificate', (certData) => { + this.emit('certificate', { + domain: certData.domain, + publicKey: certData.certificate, + privateKey: certData.privateKey, + expiryDate: certData.expiryDate, + source: certData.source, + isRenewal: certData.isRenewal + }); + }); + await this.certProvisioner.start(); + console.log('CertProvisioner started'); + } // Initialize and start NetworkProxy if needed if ( @@ -418,10 +334,10 @@ export class SmartProxy extends plugins.EventEmitter { public async stop() { console.log('PortProxy shutting down...'); this.isShuttingDown = true; - // Stop the certificate renewal scheduler if active - if (this.renewManager) { - this.renewManager.stop(); - console.log('Certificate renewal scheduler stopped'); + // Stop CertProvisioner if active + if (this.certProvisioner) { + await this.certProvisioner.stop(); + console.log('CertProvisioner stopped'); } // Stop the Port80Handler if running