feat(smartproxy/certificate): Integrate HTTP-01 challenge handler into ACME certificate provisioning workflow

This commit is contained in:
Philipp Kunz 2025-05-18 15:56:52 +00:00
parent 538d22f81b
commit e224f34a81
4 changed files with 156 additions and 70 deletions

View File

@ -1,5 +1,12 @@
# Changelog
## 2025-05-18 - 18.2.0 - feat(smartproxy/certificate)
Integrate HTTP-01 challenge handler into ACME certificate provisioning workflow
- Added integration of SmartAcme HTTP01 handler to dynamically add and remove a challenge route for ACME certificate requests
- Updated certificate-manager to use the challenge handler for both initial provisioning and renewal
- Improved error handling and logging during certificate issuance, with clear status updates and cleanup of challenge routes
## 2025-05-15 - 18.1.1 - fix(network-proxy/websocket)
Improve WebSocket connection closure and update router integration

View File

@ -0,0 +1,50 @@
import * as plugins from '../ts/plugins.js';
import { tap } from '@push.rocks/tapbundle';
import { SmartCertManager } from '../ts/proxies/smart-proxy/certificate-manager.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
let certManager: SmartCertManager;
tap.test('should create a SmartCertManager instance', async () => {
const routes: IRouteConfig[] = [
{
name: 'test-acme-route',
match: {
domains: ['test.example.com']
},
action: {
type: 'proxy',
target: 'http://localhost:3000',
tls: {
mode: 'terminate',
certificate: 'auto'
},
acme: {
email: 'test@example.com'
}
}
}
];
certManager = new SmartCertManager(routes, './test-certs', {
email: 'test@example.com',
useProduction: false
});
// Just verify it creates without error
expect(certManager).toBeInstanceOf(SmartCertManager);
});
tap.test('should verify SmartAcme handlers are accessible', async () => {
// Test that we can access SmartAcme handlers
const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
expect(http01Handler).toBeDefined();
});
tap.test('should verify SmartAcme cert managers are accessible', async () => {
// Test that we can access SmartAcme cert managers
const memoryCertManager = new plugins.smartacme.certmanagers.MemoryCertManager();
expect(memoryCertManager).toBeDefined();
});
tap.start();

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '18.1.1',
version: '18.2.0',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
}

View File

@ -26,6 +26,7 @@ export class SmartCertManager {
private networkProxy: NetworkProxy | null = null;
private renewalTimer: NodeJS.Timeout | null = null;
private pendingChallenges: Map<string, string> = new Map();
private challengeRoute: IRouteConfig | null = null;
// Track certificate status by route name
private certStatus: Map<string, ICertStatus> = new Map();
@ -69,11 +70,18 @@ export class SmartCertManager {
);
if (hasAcmeRoutes && this.acmeOptions?.email) {
// Create SmartAcme instance with built-in MemoryCertManager
// Create HTTP-01 challenge handler
const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
// Set up challenge handler integration with our routing
this.setupChallengeHandler(http01Handler);
// Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.acmeOptions.email,
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
certManager: new plugins.smartacme.certmanagers.MemoryCertManager()
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
challengeHandlers: [http01Handler]
});
await this.smartAcme.start();
@ -157,30 +165,43 @@ export class SmartCertManager {
this.updateCertStatus(routeName, 'pending', 'acme');
try {
// Use smartacme to get certificate
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
// Add challenge route before requesting certificate
await this.addChallengeRoute();
try {
// Use smartacme to get certificate
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
// SmartAcme's Cert object has these properties:
// - certPem: The certificate PEM string
// - privateKeyPem: The private key PEM string
// - publicKey: The certificate PEM string
// - privateKey: The private key PEM string
// - csr: Certificate signing request
// - validUntil: Expiry date as Date object
// - validUntil: Timestamp in milliseconds
// - domainName: The domain name
const certData: ICertificateData = {
cert: cert.certPem,
key: cert.privateKeyPem,
ca: cert.certPem, // Use same as cert for now
expiryDate: cert.validUntil,
issueDate: new Date() // SmartAcme doesn't provide issue date
cert: cert.publicKey,
key: cert.privateKey,
ca: cert.publicKey, // Use same as cert for now
expiryDate: new Date(cert.validUntil),
issueDate: new Date(cert.created)
};
await this.certStore.saveCertificate(routeName, certData);
await this.applyCertificate(primaryDomain, certData);
this.updateCertStatus(routeName, 'valid', 'acme', certData);
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
} catch (error) {
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
throw error;
} finally {
// Always remove challenge route after provisioning
await this.removeChallengeRoute();
}
} catch (error) {
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
// Handle outer try-catch from adding challenge route
console.error(`Failed to setup ACME challenge for ${primaryDomain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
throw error;
}
@ -287,40 +308,6 @@ export class SmartCertManager {
return cert.expiryDate > expiryThreshold;
}
/**
* Create ACME challenge route
* NOTE: SmartProxy already handles path-based routing and priority
*/
private createChallengeRoute(): IRouteConfig {
return {
name: 'acme-challenge',
priority: 1000, // High priority to ensure it's checked first
match: {
ports: 80,
path: '/.well-known/acme-challenge/*'
},
action: {
type: 'static',
handler: async (context) => {
const token = context.path?.split('/').pop();
const keyAuth = token ? this.pendingChallenges.get(token) : undefined;
if (keyAuth) {
return {
status: 200,
headers: { 'Content-Type': 'text/plain' },
body: keyAuth
};
} else {
return {
status: 404,
body: 'Not found'
};
}
}
}
};
}
/**
* Add challenge route to SmartProxy
@ -330,9 +317,12 @@ export class SmartCertManager {
throw new Error('No route update callback set');
}
const challengeRoute = this.createChallengeRoute();
const updatedRoutes = [...this.routes, challengeRoute];
if (!this.challengeRoute) {
throw new Error('Challenge route not initialized');
}
const challengeRoute = this.challengeRoute;
const updatedRoutes = [...this.routes, challengeRoute];
await this.updateRoutesCallback(updatedRoutes);
}
@ -424,27 +414,66 @@ export class SmartCertManager {
}
/**
* Handle ACME challenge
* Setup challenge handler integration with SmartProxy routing
*/
private async handleChallenge(token: string, keyAuth: string): Promise<void> {
this.pendingChallenges.set(token, keyAuth);
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
// Create a challenge route that delegates to SmartAcme's HTTP-01 handler
const challengeRoute: IRouteConfig = {
name: 'acme-challenge',
priority: 1000, // High priority
match: {
ports: 80,
path: '/.well-known/acme-challenge/*'
},
action: {
type: 'static',
handler: async (context) => {
// Extract the token from the path
const token = context.path?.split('/').pop();
if (!token) {
return { status: 404, body: 'Not found' };
}
// Create mock request/response objects for SmartAcme
const mockReq = {
url: context.path,
method: 'GET',
headers: context.headers || {}
};
let responseData: any = null;
const mockRes = {
statusCode: 200,
setHeader: (name: string, value: string) => {},
end: (data: any) => {
responseData = data;
}
};
// Use SmartAcme's handler
const handled = await new Promise<boolean>((resolve) => {
http01Handler.handleRequest(mockReq as any, mockRes as any, () => {
resolve(false);
});
// Give it a moment to process
setTimeout(() => resolve(true), 100);
});
if (handled && responseData) {
return {
status: mockRes.statusCode,
headers: { 'Content-Type': 'text/plain' },
body: responseData
};
} else {
return { status: 404, body: 'Not found' };
}
}
}
};
// Add challenge route if it's the first challenge
if (this.pendingChallenges.size === 1) {
await this.addChallengeRoute();
}
}
/**
* Cleanup ACME challenge
*/
private async cleanupChallenge(token: string): Promise<void> {
this.pendingChallenges.delete(token);
// Remove challenge route if no more challenges
if (this.pendingChallenges.size === 0) {
await this.removeChallengeRoute();
}
// Store the challenge route to add it when needed
this.challengeRoute = challengeRoute;
}
/**