This commit is contained in:
Philipp Kunz 2025-05-01 15:39:20 +00:00
parent a59ebd6202
commit 09aadc702e
7 changed files with 145 additions and 130 deletions

View File

@ -30,6 +30,7 @@
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0", "@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^9.1.0", "@tsclass/tsclass": "^9.1.0",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",

6
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
'@push.rocks/smartstring': '@push.rocks/smartstring':
specifier: ^4.0.15 specifier: ^4.0.15
version: 4.0.15 version: 4.0.15
'@push.rocks/taskbuffer':
specifier: ^3.1.7
version: 3.1.7
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.0 version: 9.1.0
@ -6301,7 +6304,6 @@ snapshots:
- '@aws-sdk/credential-providers' - '@aws-sdk/credential-providers'
- '@mongodb-js/zstd' - '@mongodb-js/zstd'
- '@nuxt/kit' - '@nuxt/kit'
- aws-crt
- bufferutil - bufferutil
- encoding - encoding
- gcp-metadata - gcp-metadata
@ -6920,7 +6922,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.0.7 '@push.rocks/smartlog': 3.0.7
'@push.rocks/smartpromise': 4.2.3 '@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/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9

View File

@ -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. - [ ] Remove renewal logic from Port80Handler
- [x] Add imports to ts/plugins.ts: - Delete `startRenewalTimer()` and `checkForRenewals()` methods
- import * as smartacme from '@push.rocks/smartacme'; - Remove `renewThresholdDays` and `renewCheckIntervalHours` options from `IPort80HandlerOptions`
- export { smartacme }; - [ ] Expose certificate status from Port80Handler
- [x] In Port80Handler.start(): - Ensure `getDomainCertificateStatus()` returns `{certObtained, expiryDate}` for each domain
- Instantiate SmartAcme and use the in memory certmanager. - [ ] Add renewal settings to SmartProxy
- use the DisklessHttp01Handler implemented in classes.port80handler.ts - Extend `port80HandlerConfig` to include `renewThresholdDays` and `renewCheckIntervalHours`
- Call `await smartAcme.start()` before binding HTTP server. - [ ] Implement renewal scheduler in SmartProxy using taskbuffer
- [x] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code. - Add dependency on `@push.rocks/taskbuffer` and import `{ Task, TaskManager }` in `SmartProxy`
- [x] Update `handleRequest()` to let DisklessHttp01Handler serve challenges. - Add `performRenewals()` to iterate domains and trigger renewals where `daysRemaining <= renewThresholdDays`
- [x] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo. - 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} * * *`)
## Plan: Certificate Provider Hook & Observable Emission - Call `taskManager.start()` in `SmartProxy.start()`
- [ ] Clean shutdown handling
- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts): - Call `taskManager.stop()` in `SmartProxy.stop()` alongside other cleanup
- Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`. - [ ] Throttling and safety
- Add optional `certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>`. - Skip domains already in `obtainingInProgress`
- [x] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts): - Optionally batch or stagger renewal calls for large domain sets
- Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`. - [ ] Tests
- Call `super()` in constructor. - Unit test `performRenewals()`, mocking `getDomainCertificateStatus()` to simulate expiring certificates
- In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain: - Integration test using an in-memory `Port80Handler` to verify that scheduled renewals invoke `obtainCertificate()` correctly
- Invoke `certProvider(domain)` if provided, defaulting to `'http01'`. - [ ] Documentation
- If result is `'http01'`, register domain with `Port80Handler` for ACME challenges. - Update `readme.plan.md` (this section)
- If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge` - Update `README.md` and code comments to document new renewal settings and workflow
- 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.

View File

@ -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<string, any>();
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();

View File

@ -7,7 +7,6 @@ import * as tls from 'tls';
import * as url from 'url'; import * as url from 'url';
import * as http2 from 'http2'; import * as http2 from 'http2';
export { EventEmitter, http, https, net, tls, url, http2 }; export { EventEmitter, http, https, net, tls, url, http2 };
// tsclass scope // tsclass scope
@ -25,7 +24,19 @@ import * as smartstring from '@push.rocks/smartstring';
import * as smartacme from '@push.rocks/smartacme'; import * as smartacme from '@push.rocks/smartacme';
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js'; import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.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 // third party scope
import prettyMs from 'pretty-ms'; import prettyMs from 'pretty-ms';

View File

@ -85,9 +85,7 @@ interface IPort80HandlerOptions {
port?: number; port?: number;
contactEmail?: string; contactEmail?: string;
useProduction?: boolean; useProduction?: boolean;
renewThresholdDays?: number;
httpsRedirectPort?: number; httpsRedirectPort?: number;
renewCheckIntervalHours?: number;
enabled?: boolean; // Whether ACME is enabled at all enabled?: boolean; // Whether ACME is enabled at all
autoRenew?: boolean; // Whether to automatically renew certificates autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates certificateStore?: string; // Directory to store certificates
@ -146,7 +144,8 @@ export class Port80Handler extends plugins.EventEmitter {
// SmartAcme instance for certificate management // SmartAcme instance for certificate management
private smartAcme: plugins.smartacme.SmartAcme | null = null; private smartAcme: plugins.smartacme.SmartAcme | null = null;
private server: plugins.http.Server | 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 isShuttingDown: boolean = false;
private options: Required<IPort80HandlerOptions>; private options: Required<IPort80HandlerOptions>;
@ -163,9 +162,7 @@ export class Port80Handler extends plugins.EventEmitter {
port: options.port ?? 80, port: options.port ?? 80,
contactEmail: options.contactEmail ?? 'admin@example.com', contactEmail: options.contactEmail ?? 'admin@example.com',
useProduction: options.useProduction ?? false, // Safer default: staging useProduction: options.useProduction ?? false, // Safer default: staging
renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements
httpsRedirectPort: options.httpsRedirectPort ?? 443, httpsRedirectPort: options.httpsRedirectPort ?? 443,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
enabled: options.enabled ?? true, // Enable by default enabled: options.enabled ?? true, // Enable by default
autoRenew: options.autoRenew ?? true, // Auto-renew by default autoRenew: options.autoRenew ?? true, // Auto-renew by default
certificateStore: options.certificateStore ?? './certs', // Default store location certificateStore: options.certificateStore ?? './certs', // Default store location
@ -223,7 +220,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.server.listen(this.options.port, () => { this.server.listen(this.options.port, () => {
console.log(`Port80Handler is listening on port ${this.options.port}`); console.log(`Port80Handler is listening on port ${this.options.port}`);
this.startRenewalTimer();
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
// Start certificate process for domains with acmeMaintenance enabled // Start certificate process for domains with acmeMaintenance enabled
@ -260,11 +256,6 @@ export class Port80Handler extends plugins.EventEmitter {
this.isShuttingDown = true; this.isShuttingDown = true;
// Stop the renewal timer
if (this.renewalTimer) {
clearInterval(this.renewalTimer);
this.renewalTimer = null;
}
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (this.server) { 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 * Extract expiry date from certificate using a more robust approach
@ -1041,4 +949,16 @@ export class Port80Handler extends plugins.EventEmitter {
public getConfig(): Required<IPort80HandlerOptions> { public getConfig(): Required<IPort80HandlerOptions> {
return { ...this.options }; return { ...this.options };
} }
/**
* Request a certificate renewal for a specific domain.
* @param domain The domain to renew.
*/
public async renewCertificate(domain: string): Promise<void> {
if (!this.domainCertificates.has(domain)) {
throw new Port80HandlerError(`Domain not managed: ${domain}`);
}
// Trigger renewal via ACME
await this.obtainCertificate(domain, true);
}
} }

View File

@ -32,6 +32,8 @@ export class SmartProxy extends plugins.EventEmitter {
// Port80Handler for ACME certificate management // Port80Handler for ACME certificate management
private port80Handler: Port80Handler | null = null; private port80Handler: Port80Handler | null = null;
// Renewal scheduler for certificates
private renewManager?: plugins.taskbuffer.TaskManager;
constructor(settingsArg: IPortProxySettings) { constructor(settingsArg: IPortProxySettings) {
super(); super();
@ -157,9 +159,7 @@ export class SmartProxy extends plugins.EventEmitter {
port: config.port, port: config.port,
contactEmail: config.contactEmail, contactEmail: config.contactEmail,
useProduction: config.useProduction, useProduction: config.useProduction,
renewThresholdDays: config.renewThresholdDays,
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort, httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort,
renewCheckIntervalHours: config.renewCheckIntervalHours,
enabled: config.enabled, enabled: config.enabled,
autoRenew: config.autoRenew, autoRenew: config.autoRenew,
certificateStore: config.certificateStore, certificateStore: config.certificateStore,
@ -258,6 +258,21 @@ export class SmartProxy extends plugins.EventEmitter {
// Start Port80Handler // Start Port80Handler
await this.port80Handler.start(); await this.port80Handler.start();
console.log(`Port80Handler started on port ${config.port}`); 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) { } catch (err) {
console.log(`Error initializing Port80Handler: ${err}`); console.log(`Error initializing Port80Handler: ${err}`);
} }
@ -403,6 +418,11 @@ export class SmartProxy extends plugins.EventEmitter {
public async stop() { public async stop() {
console.log('PortProxy shutting down...'); console.log('PortProxy shutting down...');
this.isShuttingDown = true; 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 // Stop the Port80Handler if running
if (this.port80Handler) { if (this.port80Handler) {
@ -572,6 +592,27 @@ export class SmartProxy extends plugins.EventEmitter {
} }
} }
/**
* Perform scheduled renewals for managed domains
*/
private async performRenewals(): Promise<void> {
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 * Request a certificate for a specific domain
*/ */