feat(ACME/Certificate): Introduce certificate provider hook and observable certificate events; remove legacy ACME flow
This commit is contained in:
parent
e6a138279d
commit
0d8740d812
@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-05-01 - 7.2.0 - feat(ACME/Certificate)
|
||||||
|
Introduce certificate provider hook and observable certificate events; remove legacy ACME flow
|
||||||
|
|
||||||
|
- Extended IPortProxySettings with a new certProvider callback that allows returning a static certificate or 'http01' for ACME challenges.
|
||||||
|
- Updated Port80Handler to leverage SmartAcme's getCertificateForDomain and removed outdated methods such as getAcmeClient and processAuthorizations.
|
||||||
|
- Enhanced SmartProxy to extend EventEmitter, invoking certProvider on non-wildcard domains and re-emitting certificate events (with domain, publicKey, privateKey, expiryDate, source, and isRenewal flag).
|
||||||
|
- Updated NetworkProxyBridge to support applying external certificates via a new applyExternalCertificate method.
|
||||||
|
- Revised documentation (readme.md and readme.plan.md) to include usage examples for the new certificate provider hook.
|
||||||
|
|
||||||
## 2025-04-30 - 7.1.4 - fix(dependencies)
|
## 2025-04-30 - 7.1.4 - fix(dependencies)
|
||||||
Update dependency versions in package.json
|
Update dependency versions in package.json
|
||||||
|
|
||||||
|
44
readme.md
44
readme.md
@ -199,6 +199,50 @@ sequenceDiagram
|
|||||||
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
||||||
- **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding
|
- **NfTables Integration** - Direct manipulation of nftables for advanced low-level port forwarding
|
||||||
|
|
||||||
|
## Certificate Provider Hook & Events
|
||||||
|
|
||||||
|
You can customize how certificates are provisioned per domain by using the `certProvider` callback and listen for certificate events emitted by `SmartProxy`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
// Example certProvider: static for a specific domain, HTTP-01 otherwise
|
||||||
|
const certProvider = async (domain: string) => {
|
||||||
|
if (domain === 'static.example.com') {
|
||||||
|
// Load from disk or vault
|
||||||
|
return {
|
||||||
|
id: 'static-cert',
|
||||||
|
domainName: domain,
|
||||||
|
created: Date.now(),
|
||||||
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||||
|
privateKey: fs.readFileSync('/etc/ssl/private/static.key', 'utf8'),
|
||||||
|
publicKey: fs.readFileSync('/etc/ssl/certs/static.crt', 'utf8'),
|
||||||
|
csr: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback to ACME HTTP-01 challenge
|
||||||
|
return 'http01';
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
fromPort: 80,
|
||||||
|
toPort: 8080,
|
||||||
|
domainConfigs: [{
|
||||||
|
domains: ['static.example.com', 'dynamic.example.com'],
|
||||||
|
allowedIPs: ['*']
|
||||||
|
}],
|
||||||
|
certProvider
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for certificate issuance or renewal
|
||||||
|
proxy.on('certificate', (evt) => {
|
||||||
|
console.log(`Certificate for ${evt.domain} ready, expires on ${evt.expiryDate}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await proxy.start();
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
### backendProtocol
|
### backendProtocol
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
## Plan: Integrate @push.rocks/smartacme into Port80Handler
|
## Plan: Integrate @push.rocks/smartacme into Port80Handler
|
||||||
|
|
||||||
- [ ] read the complete README of @push.rocks/smartacme and understand the API.
|
- [x] read the complete README of @push.rocks/smartacme and understand the API.
|
||||||
- [ ] Add imports to ts/plugins.ts:
|
- [x] Add imports to ts/plugins.ts:
|
||||||
- import * as smartacme from '@push.rocks/smartacme';
|
- import * as smartacme from '@push.rocks/smartacme';
|
||||||
- export { smartacme };
|
- export { smartacme };
|
||||||
- [ ] In Port80Handler.start():
|
- [x] In Port80Handler.start():
|
||||||
- Instantiate SmartAcme and use the in memory certmanager.
|
- Instantiate SmartAcme and use the in memory certmanager.
|
||||||
- use the DisklessHttp01Handler implemented in classes.port80handler.ts
|
- use the DisklessHttp01Handler implemented in classes.port80handler.ts
|
||||||
- Call `await smartAcme.start()` before binding HTTP server.
|
- Call `await smartAcme.start()` before binding HTTP server.
|
||||||
- [ ] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code.
|
- [x] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code.
|
||||||
- [ ] Update `handleRequest()` to let DisklessHttp01Handler serve challenges.
|
- [x] Update `handleRequest()` to let DisklessHttp01Handler serve challenges.
|
||||||
- [ ] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo.
|
- [x] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo.
|
||||||
|
|
||||||
## Plan: Certificate Provider Hook & Observable Emission
|
## Plan: Certificate Provider Hook & Observable Emission
|
||||||
|
|
||||||
- [ ] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts):
|
- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts):
|
||||||
- Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`.
|
- Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`.
|
||||||
- Add optional `certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>`.
|
- Add optional `certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>`.
|
||||||
- [ ] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts):
|
- [x] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts):
|
||||||
- Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`.
|
- Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`.
|
||||||
- Call `super()` in constructor.
|
- Call `super()` in constructor.
|
||||||
- In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain:
|
- In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain:
|
||||||
@ -25,7 +25,7 @@
|
|||||||
- If result is `'http01'`, register domain with `Port80Handler` for ACME challenges.
|
- If result is `'http01'`, register domain with `Port80Handler` for ACME challenges.
|
||||||
- If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge`
|
- If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge`
|
||||||
- Subscribe to `Port80HandlerEvents.CERTIFICATE_ISSUED` and `CERTIFICATE_RENEWED` and re-emit on `SmartProxy` as `'certificate'` events (include `domain`, `publicKey`, `privateKey`, `expiryDate`, `source: 'http01'`, `isRenewal` flag).
|
- Subscribe to `Port80HandlerEvents.CERTIFICATE_ISSUED` and `CERTIFICATE_RENEWED` and re-emit on `SmartProxy` as `'certificate'` events (include `domain`, `publicKey`, `privateKey`, `expiryDate`, `source: 'http01'`, `isRenewal` flag).
|
||||||
- [ ] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts):
|
- [x] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts):
|
||||||
- Add public method `applyExternalCertificate(data: ICertificateData): void` to forward static certs into `NetworkProxy`.
|
- Add public method `applyExternalCertificate(data: ICertificateData): void` to forward static certs into `NetworkProxy`.
|
||||||
- [ ] Define `SmartProxy` `'certificate'` event interface in TypeScript and update documentation.
|
- [ ] Define `SmartProxy` `'certificate'` event interface in TypeScript and update documentation.
|
||||||
- [ ] Update README with usage examples showing `certProvider` callback and listening for `'certificate'` events.
|
- [ ] Update README with usage examples showing `certProvider` callback and listening for `'certificate'` events.
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '7.1.4',
|
version: '7.2.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.'
|
||||||
}
|
}
|
||||||
|
@ -74,8 +74,6 @@ interface IDomainCertificate {
|
|||||||
obtainingInProgress: boolean;
|
obtainingInProgress: boolean;
|
||||||
certificate?: string;
|
certificate?: string;
|
||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
challengeToken?: string;
|
|
||||||
challengeKeyAuthorization?: string;
|
|
||||||
expiryDate?: Date;
|
expiryDate?: Date;
|
||||||
lastRenewalAttempt?: Date;
|
lastRenewalAttempt?: Date;
|
||||||
}
|
}
|
||||||
@ -94,7 +92,6 @@ interface IPort80HandlerOptions {
|
|||||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
autoRenew?: boolean; // Whether to automatically renew certificates
|
||||||
certificateStore?: string; // Directory to store certificates
|
certificateStore?: string; // Directory to store certificates
|
||||||
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
|
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
|
||||||
mongoDescriptor?: plugins.smartdata.IMongoDescriptor; // MongoDB config for SmartAcme
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,8 +146,6 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
// SmartAcme instance for certificate management
|
// SmartAcme instance for certificate management
|
||||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||||
private server: plugins.http.Server | null = null;
|
private server: plugins.http.Server | null = null;
|
||||||
private acmeClient: plugins.acme.Client | null = null;
|
|
||||||
private accountKey: string | null = null;
|
|
||||||
private renewalTimer: NodeJS.Timeout | null = null;
|
private renewalTimer: NodeJS.Timeout | null = null;
|
||||||
private isShuttingDown: boolean = false;
|
private isShuttingDown: boolean = false;
|
||||||
private options: Required<IPort80HandlerOptions>;
|
private options: Required<IPort80HandlerOptions>;
|
||||||
@ -197,13 +192,10 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
|
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
|
||||||
if (this.options.enabled) {
|
if (this.options.enabled) {
|
||||||
if (!this.options.mongoDescriptor) {
|
|
||||||
throw new ServerError('MongoDB descriptor is required for SmartAcme');
|
|
||||||
}
|
|
||||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
accountEmail: this.options.contactEmail,
|
accountEmail: this.options.contactEmail,
|
||||||
|
certManager: new plugins.smartacme.MemoryCertManager(),
|
||||||
environment: this.options.useProduction ? 'production' : 'integration',
|
environment: this.options.useProduction ? 'production' : 'integration',
|
||||||
mongoDescriptor: this.options.mongoDescriptor,
|
|
||||||
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
|
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
|
||||||
challengePriority: ['http-01'],
|
challengePriority: ['http-01'],
|
||||||
});
|
});
|
||||||
@ -613,38 +605,6 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy initialization of the ACME client
|
|
||||||
* @returns An ACME client instance
|
|
||||||
*/
|
|
||||||
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
|
||||||
if (this.acmeClient) {
|
|
||||||
return this.acmeClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate a new account key
|
|
||||||
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
|
||||||
|
|
||||||
this.acmeClient = new plugins.acme.Client({
|
|
||||||
directoryUrl: this.options.useProduction
|
|
||||||
? plugins.acme.directory.letsencrypt.production
|
|
||||||
: plugins.acme.directory.letsencrypt.staging,
|
|
||||||
accountKey: this.accountKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new account
|
|
||||||
await this.acmeClient.createAccount({
|
|
||||||
termsOfServiceAgreed: true,
|
|
||||||
contact: [`mailto:${this.options.contactEmail}`],
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.acmeClient;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
|
|
||||||
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles incoming HTTP requests
|
* Handles incoming HTTP requests
|
||||||
@ -803,209 +763,73 @@ export class Port80Handler extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves the ACME HTTP-01 challenge response
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
* @param domain The domain for the challenge
|
|
||||||
*/
|
|
||||||
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
|
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
|
||||||
if (!domainInfo) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Domain not configured');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The token is the last part of the URL
|
|
||||||
const urlParts = req.url?.split('/');
|
|
||||||
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
|
||||||
|
|
||||||
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
|
||||||
res.end(domainInfo.challengeKeyAuthorization);
|
|
||||||
console.log(`Served ACME challenge response for ${domain}`);
|
|
||||||
} else {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Challenge token not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||||
* @param domain The domain to obtain a certificate for
|
* @param domain The domain to obtain a certificate for
|
||||||
* @param isRenewal Whether this is a renewal attempt
|
* @param isRenewal Whether this is a renewal attempt
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
|
||||||
|
* @param domain The domain to obtain a certificate for
|
||||||
|
* @param isRenewal Whether this is a renewal attempt
|
||||||
|
*/
|
||||||
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||||
// Don't allow certificate issuance for glob patterns
|
|
||||||
if (this.isGlobPattern(domain)) {
|
if (this.isGlobPattern(domain)) {
|
||||||
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
||||||
}
|
}
|
||||||
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
// Get the domain info
|
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
|
||||||
if (!domainInfo) {
|
|
||||||
throw new CertificateError('Domain not found', domain, isRenewal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that acmeMaintenance is enabled
|
|
||||||
if (!domainInfo.options.acmeMaintenance) {
|
if (!domainInfo.options.acmeMaintenance) {
|
||||||
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent concurrent certificate issuance
|
|
||||||
if (domainInfo.obtainingInProgress) {
|
if (domainInfo.obtainingInProgress) {
|
||||||
console.log(`Certificate issuance already in progress for ${domain}`);
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.smartAcme) {
|
||||||
|
throw new Port80HandlerError('SmartAcme is not initialized');
|
||||||
|
}
|
||||||
domainInfo.obtainingInProgress = true;
|
domainInfo.obtainingInProgress = true;
|
||||||
domainInfo.lastRenewalAttempt = new Date();
|
domainInfo.lastRenewalAttempt = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await this.getAcmeClient();
|
// Request certificate via SmartAcme
|
||||||
|
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
||||||
// Create a new order for the domain
|
const certificate = certObj.publicKey;
|
||||||
const order = await client.createOrder({
|
const privateKey = certObj.privateKey;
|
||||||
identifiers: [{ type: 'dns', value: domain }],
|
const expiryDate = new Date(certObj.validUntil);
|
||||||
});
|
|
||||||
|
|
||||||
// Get the authorizations for the order
|
|
||||||
const authorizations = await client.getAuthorizations(order);
|
|
||||||
|
|
||||||
// Process each authorization
|
|
||||||
await this.processAuthorizations(client, domain, authorizations);
|
|
||||||
|
|
||||||
// Generate a CSR and private key
|
|
||||||
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
|
||||||
commonName: domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const csr = csrBuffer.toString();
|
|
||||||
const privateKey = privateKeyBuffer.toString();
|
|
||||||
|
|
||||||
// Finalize the order with our CSR
|
|
||||||
await client.finalizeOrder(order, csr);
|
|
||||||
|
|
||||||
// Get the certificate with the full chain
|
|
||||||
const certificate = await client.getCertificate(order);
|
|
||||||
|
|
||||||
// Store the certificate and key
|
|
||||||
domainInfo.certificate = certificate;
|
domainInfo.certificate = certificate;
|
||||||
domainInfo.privateKey = privateKey;
|
domainInfo.privateKey = privateKey;
|
||||||
domainInfo.certObtained = true;
|
domainInfo.certObtained = true;
|
||||||
|
domainInfo.expiryDate = expiryDate;
|
||||||
// Clear challenge data
|
|
||||||
delete domainInfo.challengeToken;
|
|
||||||
delete domainInfo.challengeKeyAuthorization;
|
|
||||||
|
|
||||||
// Extract expiry date from certificate
|
|
||||||
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
|
||||||
|
|
||||||
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||||
|
|
||||||
// Save the certificate to the store if enabled
|
|
||||||
if (this.options.certificateStore) {
|
if (this.options.certificateStore) {
|
||||||
this.saveCertificateToStore(domain, certificate, privateKey);
|
this.saveCertificateToStore(domain, certificate, privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the appropriate event
|
|
||||||
const eventType = isRenewal
|
const eventType = isRenewal
|
||||||
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
||||||
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
||||||
|
|
||||||
this.emitCertificateEvent(eventType, {
|
this.emitCertificateEvent(eventType, {
|
||||||
domain,
|
domain,
|
||||||
certificate,
|
certificate,
|
||||||
privateKey,
|
privateKey,
|
||||||
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
expiryDate: expiryDate || this.getDefaultExpiryDate()
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check for rate limit errors
|
const errorMsg = error?.message || 'Unknown error';
|
||||||
if (error.message && (
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||||
error.message.includes('rateLimited') ||
|
|
||||||
error.message.includes('too many certificates') ||
|
|
||||||
error.message.includes('rate limit')
|
|
||||||
)) {
|
|
||||||
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
|
|
||||||
} else {
|
|
||||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit failure event
|
|
||||||
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
||||||
domain,
|
domain,
|
||||||
error: error.message || 'Unknown error',
|
error: errorMsg,
|
||||||
isRenewal
|
isRenewal
|
||||||
} as ICertificateFailure);
|
} as ICertificateFailure);
|
||||||
|
throw new CertificateError(errorMsg, domain, isRenewal);
|
||||||
throw new CertificateError(
|
|
||||||
error.message || 'Certificate issuance failed',
|
|
||||||
domain,
|
|
||||||
isRenewal
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Reset flag whether successful or not
|
|
||||||
domainInfo.obtainingInProgress = false;
|
domainInfo.obtainingInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process ACME authorizations by verifying and completing challenges
|
|
||||||
* @param client ACME client
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param authorizations Authorizations to process
|
|
||||||
*/
|
|
||||||
private async processAuthorizations(
|
|
||||||
client: plugins.acme.Client,
|
|
||||||
domain: string,
|
|
||||||
authorizations: plugins.acme.Authorization[]
|
|
||||||
): Promise<void> {
|
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
|
||||||
if (!domainInfo) {
|
|
||||||
throw new CertificateError('Domain not found during authorization', domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const authz of authorizations) {
|
|
||||||
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
|
||||||
if (!challenge) {
|
|
||||||
throw new CertificateError('HTTP-01 challenge not found', domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the key authorization for the challenge
|
|
||||||
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
||||||
|
|
||||||
// Store the challenge data
|
|
||||||
domainInfo.challengeToken = challenge.token;
|
|
||||||
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
|
||||||
|
|
||||||
// ACME client type definition workaround - use compatible approach
|
|
||||||
// First check if challenge verification is needed
|
|
||||||
const authzUrl = authz.url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if authzUrl exists and perform verification
|
|
||||||
if (authzUrl) {
|
|
||||||
await client.verifyChallenge(authz, challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete the challenge
|
|
||||||
await client.completeChallenge(challenge);
|
|
||||||
|
|
||||||
// Wait for validation
|
|
||||||
await client.waitForValidStatus(challenge);
|
|
||||||
console.log(`HTTP-01 challenge completed for ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
|
|
||||||
console.error(`Challenge error for ${domain}:`, error);
|
|
||||||
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the certificate renewal timer
|
* Starts the certificate renewal timer
|
||||||
*/
|
*/
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision object for static or HTTP-01 certificate
|
||||||
|
*/
|
||||||
|
export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
|
||||||
|
|
||||||
/** Domain configuration with per-domain allowed port ranges */
|
/** Domain configuration with per-domain allowed port ranges */
|
||||||
export interface IDomainConfig {
|
export interface IDomainConfig {
|
||||||
domains: string[]; // Glob patterns for domain(s)
|
domains: string[]; // Glob patterns for domain(s)
|
||||||
@ -115,6 +120,11 @@ export interface IPortProxySettings {
|
|||||||
certificateStore?: string;
|
certificateStore?: string;
|
||||||
skipConfiguredCerts?: boolean;
|
skipConfiguredCerts?: boolean;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
|
||||||
|
* or a static certificate object for immediate provisioning.
|
||||||
|
*/
|
||||||
|
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -95,6 +95,17 @@ export class NetworkProxyBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an external (static) certificate into NetworkProxy
|
||||||
|
*/
|
||||||
|
public applyExternalCertificate(data: ICertificateData): void {
|
||||||
|
if (!this.networkProxy) {
|
||||||
|
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleCertificateEvent(data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the NetworkProxy instance
|
* Get the NetworkProxy instance
|
||||||
*/
|
*/
|
||||||
|
@ -8,14 +8,14 @@ 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 } from '../port80handler/classes.port80handler.js';
|
import { Port80Handler, Port80HandlerEvents, 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartProxy - Main class that coordinates all components
|
* SmartProxy - Main class that coordinates all components
|
||||||
*/
|
*/
|
||||||
export class SmartProxy {
|
export class SmartProxy extends plugins.EventEmitter {
|
||||||
private netServers: plugins.net.Server[] = [];
|
private netServers: plugins.net.Server[] = [];
|
||||||
private connectionLogger: NodeJS.Timeout | null = null;
|
private connectionLogger: NodeJS.Timeout | null = null;
|
||||||
private isShuttingDown: boolean = false;
|
private isShuttingDown: boolean = false;
|
||||||
@ -34,6 +34,7 @@ export class SmartProxy {
|
|||||||
private port80Handler: Port80Handler | null = null;
|
private port80Handler: Port80Handler | null = null;
|
||||||
|
|
||||||
constructor(settingsArg: IPortProxySettings) {
|
constructor(settingsArg: IPortProxySettings) {
|
||||||
|
super();
|
||||||
// Set reasonable defaults for all settings
|
// Set reasonable defaults for all settings
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...settingsArg,
|
...settingsArg,
|
||||||
@ -180,29 +181,67 @@ export class SmartProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all non-wildcard domains from domain configs
|
// Provision certificates per domain via certProvider or HTTP-01
|
||||||
for (const domainConfig of this.settings.domainConfigs) {
|
for (const domainConfig of this.settings.domainConfigs) {
|
||||||
for (const domain of domainConfig.domains) {
|
for (const domain of domainConfig.domains) {
|
||||||
// Skip wildcards
|
// Skip wildcard domains
|
||||||
if (domain.includes('*')) continue;
|
if (domain.includes('*')) continue;
|
||||||
|
// Determine provisioning method
|
||||||
this.port80Handler.addDomain({
|
let provision = 'http01' as string | plugins.tsclass.network.ICert;
|
||||||
domainName: domain,
|
if (this.settings.certProvider) {
|
||||||
sslRedirect: true,
|
try {
|
||||||
acmeMaintenance: true
|
provision = await this.settings.certProvider(domain);
|
||||||
});
|
} catch (err) {
|
||||||
|
console.log(`certProvider error for ${domain}: ${err}`);
|
||||||
console.log(`Registered domain ${domain} with Port80Handler`);
|
}
|
||||||
|
}
|
||||||
|
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
|
// Set up event listeners
|
||||||
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => {
|
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => {
|
||||||
console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
|
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) => {
|
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => {
|
||||||
console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`);
|
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) => {
|
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (failureData) => {
|
||||||
@ -429,22 +468,40 @@ export class SmartProxy {
|
|||||||
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
|
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Port80Handler is running, register non-wildcard domains
|
// If Port80Handler is running, provision certificates per new domain
|
||||||
if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) {
|
if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) {
|
||||||
for (const domainConfig of newDomainConfigs) {
|
for (const domainConfig of newDomainConfigs) {
|
||||||
for (const domain of domainConfig.domains) {
|
for (const domain of domainConfig.domains) {
|
||||||
// Skip wildcards
|
|
||||||
if (domain.includes('*')) continue;
|
if (domain.includes('*')) continue;
|
||||||
|
let provision = 'http01' as string | plugins.tsclass.network.ICert;
|
||||||
this.port80Handler.addDomain({
|
if (this.settings.certProvider) {
|
||||||
domainName: domain,
|
try {
|
||||||
sslRedirect: true,
|
provision = await this.settings.certProvider(domain);
|
||||||
acmeMaintenance: true
|
} 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 {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('Provisioned certificates for new domains');
|
||||||
console.log('Registered non-wildcard domains with Port80Handler');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user