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
|
# 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)
|
## 2025-05-01 - 7.2.0 - feat(ACME/Certificate)
|
||||||
Introduce certificate provider hook and observable certificate events; remove legacy ACME flow
|
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
|
- [x] Ensure Port80Handler is challenge-only:
|
||||||
- Delete `startRenewalTimer()` and `checkForRenewals()` methods
|
- Remove any internal scheduling and deprecated ACME flows (`getAcmeClient`, `processAuthorizations`, `handleAcmeChallenge`) from Port80Handler.
|
||||||
- Remove `renewThresholdDays` and `renewCheckIntervalHours` options from `IPort80HandlerOptions`
|
- Remove legacy ACME options (`renewThresholdDays`, `renewCheckIntervalHours`, `mongoDescriptor`, etc.) from `IPort80HandlerOptions`.
|
||||||
- [ ] Expose certificate status from Port80Handler
|
- Retain only methods for HTTP-01 challenge and direct renewals (`obtainCertificate`, `renewCertificate`, `getDomainCertificateStatus`).
|
||||||
- Ensure `getDomainCertificateStatus()` returns `{certObtained, expiryDate}` for each domain
|
- [x] Clean up deprecated `acme` configuration:
|
||||||
- [ ] Add renewal settings to SmartProxy
|
- Remove the `acme` property from `IPortProxySettings` and all legacy references in code.
|
||||||
- Extend `port80HandlerConfig` to include `renewThresholdDays` and `renewCheckIntervalHours`
|
|
||||||
- [ ] Implement renewal scheduler in SmartProxy using taskbuffer
|
- [x] Implement `CertProvisioner` component:
|
||||||
- Add dependency on `@push.rocks/taskbuffer` and import `{ Task, TaskManager }` in `SmartProxy`
|
- [x] Create class `ts/smartproxy/classes.pp.certprovisioner.ts`.
|
||||||
- Add `performRenewals()` to iterate domains and trigger renewals where `daysRemaining <= renewThresholdDays`
|
- [x] Constructor accepts:
|
||||||
- Instantiate a `TaskManager` and define a `Task` that wraps `performRenewals()`
|
* `domainConfigs: IDomainConfig[]`
|
||||||
- Use `taskManager.addAndScheduleTask(task, cronExpr)` to schedule renewals, building `cronExpr` from `renewCheckIntervalHours` (e.g. `0 0 */${renewCheckIntervalHours} * * *`)
|
* `port80Handler: Port80Handler`
|
||||||
- Call `taskManager.start()` in `SmartProxy.start()`
|
* `networkProxyBridge: NetworkProxyBridge`
|
||||||
- [ ] Clean shutdown handling
|
* optional `certProvider: (domain) => Promise<ICert | 'http01'>`
|
||||||
- Call `taskManager.stop()` in `SmartProxy.stop()` alongside other cleanup
|
* `renewThresholdDays`, `renewCheckIntervalHours`, `autoRenew` settings.
|
||||||
- [ ] Throttling and safety
|
- Responsibilities:
|
||||||
- Skip domains already in `obtainingInProgress`
|
* Initial provisioning: static vs HTTP-01.
|
||||||
- Optionally batch or stagger renewal calls for large domain sets
|
* Subscribe to Port80Handler events (CERTIFICATE_ISSUED/RENEWED) and to static cert updates.
|
||||||
- [ ] Tests
|
* Re-emit unified `'certificate'` events to SmartProxy.
|
||||||
- Unit test `performRenewals()`, mocking `getDomainCertificateStatus()` to simulate expiring certificates
|
* Central scheduling of renewals via `@push.rocks/taskbuffer`.
|
||||||
- Integration test using an in-memory `Port80Handler` to verify that scheduled renewals invoke `obtainCertificate()` correctly
|
|
||||||
- [ ] Documentation
|
- [x] Refactor SmartProxy:
|
||||||
- Update `readme.plan.md` (this section)
|
- [x] Remove existing scheduling / renewal logic.
|
||||||
- Update `README.md` and code comments to document new renewal settings and workflow
|
- [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 = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
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.'
|
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,
|
port: this.options.acme.port,
|
||||||
contactEmail: this.options.acme.contactEmail,
|
contactEmail: this.options.acme.contactEmail,
|
||||||
useProduction: this.options.acme.useProduction,
|
useProduction: this.options.acme.useProduction,
|
||||||
renewThresholdDays: this.options.acme.renewThresholdDays,
|
|
||||||
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
|
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
|
||||||
renewCheckIntervalHours: 24, // Check daily for renewals
|
|
||||||
enabled: this.options.acme.enabled,
|
enabled: this.options.acme.enabled,
|
||||||
autoRenew: this.options.acme.autoRenew,
|
|
||||||
certificateStore: this.options.acme.certificateStore,
|
certificateStore: this.options.acme.certificateStore,
|
||||||
skipConfiguredCerts: this.options.acme.skipConfiguredCerts
|
skipConfiguredCerts: this.options.acme.skipConfiguredCerts
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { IncomingMessage, ServerResponse } from 'http';
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
import * as fs from 'fs';
|
// (fs and path I/O moved to CertProvisioner)
|
||||||
import * as path from 'path';
|
|
||||||
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
|
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
|
||||||
class DisklessHttp01Handler {
|
class DisklessHttp01Handler {
|
||||||
private storage: Map<string, string>;
|
private storage: Map<string, string>;
|
||||||
@ -87,9 +86,7 @@ interface IPort80HandlerOptions {
|
|||||||
useProduction?: boolean;
|
useProduction?: boolean;
|
||||||
httpsRedirectPort?: number;
|
httpsRedirectPort?: number;
|
||||||
enabled?: boolean; // Whether ACME is enabled at all
|
enabled?: boolean; // Whether ACME is enabled at all
|
||||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
// (Persistence moved to CertProvisioner)
|
||||||
certificateStore?: string; // Directory to store certificates
|
|
||||||
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,10 +160,7 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
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
|
||||||
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||||
enabled: options.enabled ?? true, // Enable by default
|
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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,10 +195,6 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
// Load certificates from store if enabled
|
|
||||||
if (this.options.certificateStore) {
|
|
||||||
this.loadCertificatesFromStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
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}`);
|
console.log(`Certificate set for ${domain}`);
|
||||||
|
|
||||||
// Save certificate to store if enabled
|
// (Persistence of certificates moved to CertProvisioner)
|
||||||
if (this.options.certificateStore) {
|
|
||||||
this.saveCertificateToStore(domain, certificate, privateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit certificate event
|
// Emit certificate event
|
||||||
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
|
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
|
* Check if a domain is a glob pattern
|
||||||
@ -625,13 +485,19 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
const { domainInfo, pattern } = domainMatch;
|
const { domainInfo, pattern } = domainMatch;
|
||||||
const options = domainInfo.options;
|
const options = domainInfo.options;
|
||||||
|
|
||||||
// Serve or forward ACME HTTP-01 challenge requests
|
// Handle ACME HTTP-01 challenge requests or forwarding
|
||||||
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) {
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
||||||
// Forward ACME requests if configured
|
// Forward ACME requests if configured
|
||||||
if (options.acmeForward) {
|
if (options.acmeForward) {
|
||||||
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
||||||
return;
|
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
|
// Serve challenge response from in-memory storage
|
||||||
const token = req.url.split('/').pop() || '';
|
const token = req.url.split('/').pop() || '';
|
||||||
const keyAuth = this.acmeHttp01Storage.get(token);
|
const keyAuth = this.acmeHttp01Storage.get(token);
|
||||||
@ -795,9 +661,7 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
domainInfo.expiryDate = expiryDate;
|
domainInfo.expiryDate = expiryDate;
|
||||||
|
|
||||||
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||||
if (this.options.certificateStore) {
|
// Persistence moved to CertProvisioner
|
||||||
this.saveCertificateToStore(domain, certificate, privateKey);
|
|
||||||
}
|
|
||||||
const eventType = isRenewal
|
const eventType = isRenewal
|
||||||
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
||||||
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
: 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,
|
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
|
||||||
* or a static certificate object for immediate provisioning.
|
* or a static certificate object for immediate provisioning.
|
||||||
|
@ -43,10 +43,6 @@ export class NetworkProxyBridge {
|
|||||||
useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available
|
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);
|
this.networkProxy = new NetworkProxy(networkProxyOptions);
|
||||||
|
|
||||||
@ -288,7 +284,7 @@ export class NetworkProxyBridge {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Log ACME-eligible domains
|
// Log ACME-eligible domains
|
||||||
const acmeEnabled = this.settings.port80HandlerConfig?.enabled || this.settings.acme?.enabled;
|
const acmeEnabled = !!this.settings.port80HandlerConfig?.enabled;
|
||||||
if (acmeEnabled) {
|
if (acmeEnabled) {
|
||||||
const acmeEligibleDomains = proxyConfigs
|
const acmeEligibleDomains = proxyConfigs
|
||||||
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
|
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
|
||||||
@ -349,7 +345,7 @@ export class NetworkProxyBridge {
|
|||||||
return false;
|
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');
|
console.log('Cannot request certificate - ACME is not enabled');
|
||||||
return false;
|
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
|
// Add global port ranges
|
||||||
if (this.settings.globalPortRanges) {
|
if (this.settings.globalPortRanges) {
|
||||||
@ -202,12 +198,6 @@ export class PortRangeManager {
|
|||||||
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
|
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;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
|||||||
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
||||||
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
||||||
import { ConnectionHandler } from './classes.pp.connectionhandler.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 path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
@ -32,8 +34,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
|
// CertProvisioner for unified certificate workflows
|
||||||
private renewManager?: plugins.taskbuffer.TaskManager;
|
private certProvisioner?: CertProvisioner;
|
||||||
|
|
||||||
constructor(settingsArg: IPortProxySettings) {
|
constructor(settingsArg: IPortProxySettings) {
|
||||||
super();
|
super();
|
||||||
@ -69,37 +71,20 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
globalPortRanges: settingsArg.globalPortRanges || [],
|
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.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) {
|
||||||
if (this.settings.acme) {
|
this.settings.port80HandlerConfig = {
|
||||||
// Migrate from legacy acme config
|
enabled: false,
|
||||||
this.settings.port80HandlerConfig = {
|
port: 80,
|
||||||
enabled: this.settings.acme.enabled,
|
contactEmail: 'admin@example.com',
|
||||||
port: this.settings.acme.port || 80,
|
useProduction: false,
|
||||||
contactEmail: this.settings.acme.contactEmail || 'admin@example.com',
|
renewThresholdDays: 30,
|
||||||
useProduction: this.settings.acme.useProduction || false,
|
autoRenew: true,
|
||||||
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
|
certificateStore: './certs',
|
||||||
autoRenew: this.settings.acme.autoRenew !== false, // Default to true
|
skipConfiguredCerts: false,
|
||||||
certificateStore: this.settings.acme.certificateStore || './certs',
|
httpsRedirectPort: this.settings.fromPort,
|
||||||
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
|
renewCheckIntervalHours: 24
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize component managers
|
// Initialize component managers
|
||||||
@ -161,96 +146,11 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
useProduction: config.useProduction,
|
useProduction: config.useProduction,
|
||||||
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort,
|
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort,
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
autoRenew: config.autoRenew,
|
|
||||||
certificateStore: config.certificateStore,
|
certificateStore: config.certificateStore,
|
||||||
skipConfiguredCerts: config.skipConfiguredCerts
|
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
|
// Share Port80Handler with NetworkProxyBridge
|
||||||
this.networkProxyBridge.setPort80Handler(this.port80Handler);
|
this.networkProxyBridge.setPort80Handler(this.port80Handler);
|
||||||
@ -258,21 +158,6 @@ 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}`);
|
||||||
}
|
}
|
||||||
@ -290,6 +175,37 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
|
|
||||||
// Initialize Port80Handler if enabled
|
// Initialize Port80Handler if enabled
|
||||||
await this.initializePort80Handler();
|
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
|
// Initialize and start NetworkProxy if needed
|
||||||
if (
|
if (
|
||||||
@ -418,10 +334,10 @@ 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
|
// Stop CertProvisioner if active
|
||||||
if (this.renewManager) {
|
if (this.certProvisioner) {
|
||||||
this.renewManager.stop();
|
await this.certProvisioner.stop();
|
||||||
console.log('Certificate renewal scheduler stopped');
|
console.log('CertProvisioner stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the Port80Handler if running
|
// Stop the Port80Handler if running
|
||||||
|
Loading…
x
Reference in New Issue
Block a user