Compare commits

..

5 Commits

Author SHA1 Message Date
a59ebd6202 7.2.0
Some checks failed
Default (tags) / security (push) Successful in 46s
Default (tags) / test (push) Failing after 1m12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-01 12:13:18 +00:00
0d8740d812 feat(ACME/Certificate): Introduce certificate provider hook and observable certificate events; remove legacy ACME flow 2025-05-01 12:13:18 +00:00
e6a138279d before refactor 2025-05-01 11:48:04 +00:00
a30571dae2 7.1.4
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 1m11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-30 13:39:42 +00:00
24d6d6982d fix(dependencies): Update dependency versions in package.json 2025-04-30 13:39:42 +00:00
12 changed files with 1821 additions and 555 deletions

View File

@ -1,5 +1,28 @@
# 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)
Update dependency versions in package.json
- Bump @git.zone/tsbuild from ^2.2.6 to ^2.3.2
- Bump @push.rocks/tapbundle from ^5.5.10 to ^6.0.0
- Bump @types/node from ^22.13.10 to ^22.15.3
- Bump typescript from ^5.8.2 to ^5.8.3
- Bump @push.rocks/lik from ^6.1.0 to ^6.2.2
- Add @push.rocks/smartnetwork at ^4.0.0
- Bump @push.rocks/smartrequest from ^2.0.23 to ^2.1.0
- Bump @tsclass/tsclass from ^5.0.0 to ^9.1.0
- Bump @types/ws from ^8.18.0 to ^8.18.1
- Update ws to ^8.18.1
## 2025-04-28 - 7.1.3 - fix(docs) ## 2025-04-28 - 7.1.3 - fix(docs)
Update project hints documentation in readme.hints.md Update project hints documentation in readme.hints.md

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "7.1.3", "version": "7.2.0",
"private": false, "private": false,
"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.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@ -15,23 +15,24 @@
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.2.6", "@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^1.0.77", "@git.zone/tstest": "^1.0.77",
"@push.rocks/tapbundle": "^5.5.10", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.13.10", "@types/node": "^22.15.3",
"typescript": "^5.8.2" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.1.0", "@push.rocks/lik": "^6.2.2",
"@push.rocks/smartacme": "^7.2.3",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartnetwork": "^4.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.0.23", "@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@tsclass/tsclass": "^5.0.0", "@tsclass/tsclass": "^9.1.0",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/ws": "^8.18.0", "@types/ws": "^8.18.1",
"acme-client": "^5.4.0",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"pretty-ms": "^9.2.0", "pretty-ms": "^9.2.0",
"ws": "^8.18.1" "ws": "^8.18.1"

1852
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -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

31
readme.plan.md Normal file
View File

@ -0,0 +1,31 @@
## Plan: Integrate @push.rocks/smartacme into Port80Handler
- [x] read the complete README of @push.rocks/smartacme and understand the API.
- [x] Add imports to ts/plugins.ts:
- import * as smartacme from '@push.rocks/smartacme';
- export { smartacme };
- [x] In Port80Handler.start():
- Instantiate SmartAcme and use the in memory certmanager.
- use the DisklessHttp01Handler implemented in classes.port80handler.ts
- Call `await smartAcme.start()` before binding HTTP server.
- [x] Replace old ACME flow in `obtainCertificate()` to use `await smartAcme.getCertificateForDomain(domain)` and process returned cert object. Remove old code.
- [x] Update `handleRequest()` to let DisklessHttp01Handler serve challenges.
- [x] Remove legacy methods: `getAcmeClient()`, `handleAcmeChallenge()`, `processAuthorizations()`, and related token bookkeeping in domainInfo.
## Plan: Certificate Provider Hook & Observable Emission
- [x] Extend IPortProxySettings (ts/smartproxy/classes.pp.interfaces.ts):
- Define type ISmartProxyCertProvisionObject = tsclass.network.ICert | 'http01'`.
- Add optional `certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>`.
- [x] Enhance SmartProxy (ts/smartproxy/classes.smartproxy.ts):
- Import `EventEmitter` and change class signature to `export class SmartProxy extends EventEmitter`.
- Call `super()` in constructor.
- In `initializePort80Handler` and `updateDomainConfigs`, for each non-wildcard domain:
- Invoke `certProvider(domain)` if provided, defaulting to `'http01'`.
- If result is `'http01'`, register domain with `Port80Handler` for ACME challenges.
- If static cert returned, bypass `Port80Handler`, apply via `NetworkProxyBridge`
- Subscribe to `Port80HandlerEvents.CERTIFICATE_ISSUED` and `CERTIFICATE_RENEWED` and re-emit on `SmartProxy` as `'certificate'` events (include `domain`, `publicKey`, `privateKey`, `expiryDate`, `source: 'http01'`, `isRenewal` flag).
- [x] Extend NetworkProxyBridge (ts/smartproxy/classes.pp.networkproxybridge.ts):
- Add public method `applyExternalCertificate(data: ICertificateData): void` to forward static certs into `NetworkProxy`.
- [ ] Define `SmartProxy` `'certificate'` event interface in TypeScript and update documentation.
- [ ] Update README with usage examples showing `certProvider` callback and listening for `'certificate'` events.

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '7.1.3', 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.'
} }

View File

@ -12,6 +12,10 @@ import { Port80Handler } from '../port80handler/classes.port80handler.js';
* automatic certificate management, and high-performance connection pooling. * automatic certificate management, and high-performance connection pooling.
*/ */
export class NetworkProxy implements IMetricsTracker { export class NetworkProxy implements IMetricsTracker {
// Provide a minimal JSON representation to avoid circular references during deep equality checks
public toJSON(): any {
return {};
}
// Configuration // Configuration
public options: INetworkProxyOptions; public options: INetworkProxyOptions;
public proxyConfigs: IReverseProxyConfig[] = []; public proxyConfigs: IReverseProxyConfig[] = [];

View File

@ -22,13 +22,15 @@ import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrequest from '@push.rocks/smartrequest';
import * as smartstring from '@push.rocks/smartstring'; import * as smartstring from '@push.rocks/smartstring';
export { lik, smartdelay, smartrequest, smartpromise, smartstring }; import * as smartacme from '@push.rocks/smartacme';
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js';
export { lik, smartdelay, smartrequest, smartpromise, smartstring, smartacme, smartacmePlugins, smartacmeHandlers };
// third party scope // third party scope
import * as acme from 'acme-client';
import prettyMs from 'pretty-ms'; import prettyMs from 'pretty-ms';
import * as ws from 'ws'; import * as ws from 'ws';
import wsDefault from 'ws'; import wsDefault from 'ws';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
export { acme, prettyMs, ws, wsDefault, minimatch }; export { prettyMs, ws, wsDefault, minimatch };

View File

@ -2,6 +2,21 @@ import * as plugins from '../plugins.js';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
class DisklessHttp01Handler {
private storage: Map<string, string>;
constructor(storage: Map<string, string>) { this.storage = storage; }
public getSupportedTypes(): string[] { return ['http-01']; }
public async prepare(ch: any): Promise<void> {
this.storage.set(ch.token, ch.keyAuthorization);
}
public async verify(ch: any): Promise<void> {
return;
}
public async cleanup(ch: any): Promise<void> {
this.storage.delete(ch.token);
}
}
/** /**
* Custom error classes for better error handling * Custom error classes for better error handling
@ -59,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;
} }
@ -128,9 +141,11 @@ export interface ICertificateExpiring {
*/ */
export class Port80Handler extends plugins.EventEmitter { export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>; private domainCertificates: Map<string, IDomainCertificate>;
// In-memory storage for ACME HTTP-01 challenge tokens
private acmeHttp01Storage: Map<string, string> = new Map();
// SmartAcme instance for certificate management
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>;
@ -175,6 +190,17 @@ export class Port80Handler extends plugins.EventEmitter {
console.log('Port80Handler is disabled, skipping start'); console.log('Port80Handler is disabled, skipping start');
return; return;
} }
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
if (this.options.enabled) {
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.options.contactEmail,
certManager: new plugins.smartacme.MemoryCertManager(),
environment: this.options.useProduction ? 'production' : 'integration',
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
challengePriority: ['http-01'],
});
await this.smartAcme.start();
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
@ -579,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
@ -640,19 +634,26 @@ export class Port80Handler extends plugins.EventEmitter {
const { domainInfo, pattern } = domainMatch; const { domainInfo, pattern } = domainMatch;
const options = domainInfo.options; const options = domainInfo.options;
// If the request is for an ACME HTTP-01 challenge, handle it // Serve or forward ACME HTTP-01 challenge requests
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && (options.acmeMaintenance || options.acmeForward)) { if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) {
// Check if we should forward ACME requests // 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;
} }
// Serve challenge response from in-memory storage
// Only handle ACME challenges for non-glob patterns const token = req.url.split('/').pop() || '';
if (!this.isGlobPattern(pattern)) { const keyAuth = this.acmeHttp01Storage.get(token);
this.handleAcmeChallenge(req, res, domain); if (keyAuth) {
return; res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(keyAuth);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
} }
return;
} }
// Check if we should forward non-ACME requests // Check if we should forward non-ACME requests
@ -762,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);
} }
const eventType = isRenewal
// Emit the appropriate event ? Port80HandlerEvents.CERTIFICATE_RENEWED
const eventType = isRenewal
? 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
*/ */

View File

@ -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>;
} }
/** /**

View File

@ -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
*/ */

View File

@ -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');
} }
} }