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.
This commit is contained in:
parent
09aadc702e
commit
8a396a04fa
@ -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
|
||||
|
||||
|
@ -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<ICert | 'http01'>`
|
||||
* `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.
|
||||
|
140
test/test.certprovisioner.unit.ts
Normal file
140
test/test.certprovisioner.unit.ts
Normal file
@ -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<void> {
|
||||
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<ISmartProxyCertProvisionObject> => {
|
||||
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<ISmartProxyCertProvisionObject> => '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<ISmartProxyCertProvisionObject> => '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<ISmartProxyCertProvisionObject> => ({
|
||||
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();
|
@ -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.'
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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<string, string>;
|
||||
@ -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;
|
||||
|
183
ts/smartproxy/classes.pp.certprovisioner.ts
Normal file
183
ts/smartproxy/classes.pp.certprovisioner.ts
Normal file
@ -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<ISmartProxyCertProvisionObject>;
|
||||
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<string, 'http01' | 'static'>;
|
||||
|
||||
/**
|
||||
* @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<ISmartProxyCertProvisionObject>,
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user