diff --git a/package.json b/package.json index 4f3b292..b6900e5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartrequest": "^2.1.0", "@push.rocks/smartstring": "^4.0.15", + "@push.rocks/taskbuffer": "^3.1.7", "@tsclass/tsclass": "^9.1.0", "@types/minimatch": "^5.1.2", "@types/ws": "^8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4f935..6b5b661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@push.rocks/smartstring': specifier: ^4.0.15 version: 4.0.15 + '@push.rocks/taskbuffer': + specifier: ^3.1.7 + version: 3.1.7 '@tsclass/tsclass': specifier: ^9.1.0 version: 9.1.0 @@ -6301,7 +6304,6 @@ snapshots: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' - '@nuxt/kit' - - aws-crt - bufferutil - encoding - gcp-metadata @@ -6920,7 +6922,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartlog': 3.0.7 '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.7 + '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.1.1 '@push.rocks/smartunique': 3.0.9 diff --git a/readme.plan.md b/readme.plan.md index f48bbec..8ee2888 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,31 +1,26 @@ -## Plan: Integrate @push.rocks/smartacme into Port80Handler +## Plan: Centralize Certificate Renewal for all certificates -- [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 }; -- [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. -- [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 - -- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts): - - Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`. - - Add optional `certProvider?: (domain: string) => Promise`. -- [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: - - Invoke `certProvider(domain)` if provided, defaulting to `'http01'`. - - 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). -- [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. +- [ ] 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 diff --git a/test/test.smartproxy.renewals.node.ts b/test/test.smartproxy.renewals.node.ts new file mode 100644 index 0000000..83cfbf0 --- /dev/null +++ b/test/test.smartproxy.renewals.node.ts @@ -0,0 +1,45 @@ +import { tap, expect } from '@push.rocks/tapbundle'; +import { SmartProxy } from '../ts/smartproxy/classes.smartproxy.js'; + +tap.test('performRenewals only renews domains below threshold', async () => { + // Set up SmartProxy instance without real servers + const proxy = new SmartProxy({ + fromPort: 0, + toPort: 0, + domainConfigs: [], + sniEnabled: false, + defaultAllowedIPs: [], + globalPortRanges: [] + }); + // Stub port80Handler status and renewal + const statuses = new Map(); + const now = new Date(); + statuses.set('expiring.com', { + certObtained: true, + expiryDate: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000), + obtainingInProgress: false + }); + statuses.set('ok.com', { + certObtained: true, + expiryDate: new Date(now.getTime() + 100 * 24 * 60 * 60 * 1000), + obtainingInProgress: false + }); + const renewed: string[] = []; + // Inject fake handler + (proxy as any).port80Handler = { + getDomainCertificateStatus: () => statuses, + renewCertificate: async (domain: string) => { renewed.push(domain); } + }; + // Configure threshold + proxy.settings.port80HandlerConfig.enabled = true; + proxy.settings.port80HandlerConfig.autoRenew = true; + proxy.settings.port80HandlerConfig.renewThresholdDays = 10; + + // Execute renewals + await (proxy as any).performRenewals(); + + // Only the expiring.com domain should be renewed + expect(renewed).toEqual(['expiring.com']); +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/plugins.ts b/ts/plugins.ts index 42e0aeb..d9179d4 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -7,7 +7,6 @@ import * as tls from 'tls'; import * as url from 'url'; import * as http2 from 'http2'; - export { EventEmitter, http, https, net, tls, url, http2 }; // tsclass scope @@ -25,7 +24,19 @@ import * as smartstring from '@push.rocks/smartstring'; import * as smartacme from '@push.rocks/smartacme'; import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js'; import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js'; -export { lik, smartdelay, smartrequest, smartpromise, smartstring, smartacme, smartacmePlugins, smartacmeHandlers }; +import * as taskbuffer from '@push.rocks/taskbuffer'; + +export { + lik, + smartdelay, + smartrequest, + smartpromise, + smartstring, + smartacme, + smartacmePlugins, + smartacmeHandlers, + taskbuffer, +}; // third party scope import prettyMs from 'pretty-ms'; diff --git a/ts/port80handler/classes.port80handler.ts b/ts/port80handler/classes.port80handler.ts index 3fee023..aac09f2 100644 --- a/ts/port80handler/classes.port80handler.ts +++ b/ts/port80handler/classes.port80handler.ts @@ -85,9 +85,7 @@ interface IPort80HandlerOptions { port?: number; contactEmail?: string; useProduction?: boolean; - renewThresholdDays?: number; httpsRedirectPort?: number; - renewCheckIntervalHours?: number; enabled?: boolean; // Whether ACME is enabled at all autoRenew?: boolean; // Whether to automatically renew certificates certificateStore?: string; // Directory to store certificates @@ -146,7 +144,8 @@ 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 renewalTimer: NodeJS.Timeout | null = null; + // Renewal scheduling is handled externally by SmartProxy + // (Removed internal renewal timer) private isShuttingDown: boolean = false; private options: Required; @@ -163,9 +162,7 @@ export class Port80Handler extends plugins.EventEmitter { port: options.port ?? 80, contactEmail: options.contactEmail ?? 'admin@example.com', useProduction: options.useProduction ?? false, // Safer default: staging - renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements httpsRedirectPort: options.httpsRedirectPort ?? 443, - renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, enabled: options.enabled ?? true, // Enable by default autoRenew: options.autoRenew ?? true, // Auto-renew by default certificateStore: options.certificateStore ?? './certs', // Default store location @@ -223,7 +220,6 @@ export class Port80Handler extends plugins.EventEmitter { this.server.listen(this.options.port, () => { console.log(`Port80Handler is listening on port ${this.options.port}`); - this.startRenewalTimer(); this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); // Start certificate process for domains with acmeMaintenance enabled @@ -260,11 +256,6 @@ export class Port80Handler extends plugins.EventEmitter { this.isShuttingDown = true; - // Stop the renewal timer - if (this.renewalTimer) { - clearInterval(this.renewalTimer); - this.renewalTimer = null; - } return new Promise((resolve) => { if (this.server) { @@ -830,89 +821,6 @@ export class Port80Handler extends plugins.EventEmitter { } } - /** - * Starts the certificate renewal timer - */ - private startRenewalTimer(): void { - if (this.renewalTimer) { - clearInterval(this.renewalTimer); - } - - // Convert hours to milliseconds - const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000; - - this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval); - - // Prevent the timer from keeping the process alive - if (this.renewalTimer.unref) { - this.renewalTimer.unref(); - } - - console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`); - } - - /** - * Checks for certificates that need renewal - */ - private checkForRenewals(): void { - if (this.isShuttingDown) { - return; - } - - // Skip renewal if auto-renewal is disabled - if (this.options.autoRenew === false) { - console.log('Auto-renewal is disabled, skipping certificate renewal check'); - return; - } - - console.log('Checking for certificates that need renewal...'); - - const now = new Date(); - const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; - - for (const [domain, domainInfo] of this.domainCertificates.entries()) { - // Skip glob patterns - if (this.isGlobPattern(domain)) { - continue; - } - - // Skip domains with acmeMaintenance disabled - if (!domainInfo.options.acmeMaintenance) { - continue; - } - - // Skip domains without certificates or already in renewal - if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { - continue; - } - - // Skip domains without expiry dates - if (!domainInfo.expiryDate) { - continue; - } - - const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime(); - - // Check if certificate is near expiry - if (timeUntilExpiry <= renewThresholdMs) { - console.log(`Certificate for ${domain} expires soon, renewing...`); - - const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)); - - this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, { - domain, - expiryDate: domainInfo.expiryDate, - daysRemaining - } as ICertificateExpiring); - - // Start renewal process - this.obtainCertificate(domain, true).catch(err => { - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - console.error(`Error renewing certificate for ${domain}:`, errorMessage); - }); - } - } - } /** * Extract expiry date from certificate using a more robust approach @@ -1041,4 +949,16 @@ export class Port80Handler extends plugins.EventEmitter { public getConfig(): Required { return { ...this.options }; } + + /** + * Request a certificate renewal for a specific domain. + * @param domain The domain to renew. + */ + public async renewCertificate(domain: string): Promise { + if (!this.domainCertificates.has(domain)) { + throw new Port80HandlerError(`Domain not managed: ${domain}`); + } + // Trigger renewal via ACME + await this.obtainCertificate(domain, true); + } } \ No newline at end of file diff --git a/ts/smartproxy/classes.smartproxy.ts b/ts/smartproxy/classes.smartproxy.ts index 9d5703a..475518a 100644 --- a/ts/smartproxy/classes.smartproxy.ts +++ b/ts/smartproxy/classes.smartproxy.ts @@ -32,6 +32,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; constructor(settingsArg: IPortProxySettings) { super(); @@ -157,9 +159,7 @@ export class SmartProxy extends plugins.EventEmitter { port: config.port, contactEmail: config.contactEmail, useProduction: config.useProduction, - renewThresholdDays: config.renewThresholdDays, httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort, - renewCheckIntervalHours: config.renewCheckIntervalHours, enabled: config.enabled, autoRenew: config.autoRenew, certificateStore: config.certificateStore, @@ -258,6 +258,21 @@ 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}`); } @@ -403,6 +418,11 @@ 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 the Port80Handler if running if (this.port80Handler) { @@ -572,6 +592,27 @@ export class SmartProxy extends plugins.EventEmitter { } } + /** + * Perform scheduled renewals for managed domains + */ + private async performRenewals(): Promise { + if (!this.port80Handler) return; + const statuses = this.port80Handler.getDomainCertificateStatus(); + const threshold = this.settings.port80HandlerConfig.renewThresholdDays ?? 30; + const now = new Date(); + for (const [domain, status] of statuses.entries()) { + if (!status.certObtained || status.obtainingInProgress || !status.expiryDate) continue; + const msRemaining = status.expiryDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(msRemaining / (24 * 60 * 60 * 1000)); + if (daysRemaining <= threshold) { + try { + await this.port80Handler.renewCertificate(domain); + } catch (err) { + console.error(`Error renewing certificate for ${domain}:`, err); + } + } + } + } /** * Request a certificate for a specific domain */