feat(smartproxy/certificate): Integrate HTTP-01 challenge handler into ACME certificate provisioning workflow
This commit is contained in:
parent
538d22f81b
commit
e224f34a81
@ -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
|
||||
|
||||
|
50
test/test.smartacme-integration.ts
Normal file
50
test/test.smartacme-integration.ts
Normal 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();
|
@ -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.'
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user