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
|
# 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)
|
## 2025-05-15 - 18.1.1 - fix(network-proxy/websocket)
|
||||||
Improve WebSocket connection closure and update router integration
|
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 = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
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.'
|
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 networkProxy: NetworkProxy | null = null;
|
||||||
private renewalTimer: NodeJS.Timeout | null = null;
|
private renewalTimer: NodeJS.Timeout | null = null;
|
||||||
private pendingChallenges: Map<string, string> = new Map();
|
private pendingChallenges: Map<string, string> = new Map();
|
||||||
|
private challengeRoute: IRouteConfig | null = null;
|
||||||
|
|
||||||
// Track certificate status by route name
|
// Track certificate status by route name
|
||||||
private certStatus: Map<string, ICertStatus> = new Map();
|
private certStatus: Map<string, ICertStatus> = new Map();
|
||||||
@ -69,11 +70,18 @@ export class SmartCertManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasAcmeRoutes && this.acmeOptions?.email) {
|
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({
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
accountEmail: this.acmeOptions.email,
|
accountEmail: this.acmeOptions.email,
|
||||||
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
||||||
certManager: new plugins.smartacme.certmanagers.MemoryCertManager()
|
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
||||||
|
challengeHandlers: [http01Handler]
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.smartAcme.start();
|
await this.smartAcme.start();
|
||||||
@ -157,30 +165,43 @@ export class SmartCertManager {
|
|||||||
this.updateCertStatus(routeName, 'pending', 'acme');
|
this.updateCertStatus(routeName, 'pending', 'acme');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use smartacme to get certificate
|
// Add challenge route before requesting certificate
|
||||||
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
await this.addChallengeRoute();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use smartacme to get certificate
|
||||||
|
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
||||||
|
|
||||||
// SmartAcme's Cert object has these properties:
|
// SmartAcme's Cert object has these properties:
|
||||||
// - certPem: The certificate PEM string
|
// - publicKey: The certificate PEM string
|
||||||
// - privateKeyPem: The private key PEM string
|
// - privateKey: The private key PEM string
|
||||||
// - csr: Certificate signing request
|
// - csr: Certificate signing request
|
||||||
// - validUntil: Expiry date as Date object
|
// - validUntil: Timestamp in milliseconds
|
||||||
// - domainName: The domain name
|
// - domainName: The domain name
|
||||||
const certData: ICertificateData = {
|
const certData: ICertificateData = {
|
||||||
cert: cert.certPem,
|
cert: cert.publicKey,
|
||||||
key: cert.privateKeyPem,
|
key: cert.privateKey,
|
||||||
ca: cert.certPem, // Use same as cert for now
|
ca: cert.publicKey, // Use same as cert for now
|
||||||
expiryDate: cert.validUntil,
|
expiryDate: new Date(cert.validUntil),
|
||||||
issueDate: new Date() // SmartAcme doesn't provide issue date
|
issueDate: new Date(cert.created)
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.certStore.saveCertificate(routeName, certData);
|
await this.certStore.saveCertificate(routeName, certData);
|
||||||
await this.applyCertificate(primaryDomain, certData);
|
await this.applyCertificate(primaryDomain, certData);
|
||||||
this.updateCertStatus(routeName, 'valid', 'acme', 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) {
|
} 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);
|
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -287,40 +308,6 @@ export class SmartCertManager {
|
|||||||
return cert.expiryDate > expiryThreshold;
|
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
|
* Add challenge route to SmartProxy
|
||||||
@ -330,9 +317,12 @@ export class SmartCertManager {
|
|||||||
throw new Error('No route update callback set');
|
throw new Error('No route update callback set');
|
||||||
}
|
}
|
||||||
|
|
||||||
const challengeRoute = this.createChallengeRoute();
|
if (!this.challengeRoute) {
|
||||||
const updatedRoutes = [...this.routes, challengeRoute];
|
throw new Error('Challenge route not initialized');
|
||||||
|
}
|
||||||
|
const challengeRoute = this.challengeRoute;
|
||||||
|
|
||||||
|
const updatedRoutes = [...this.routes, challengeRoute];
|
||||||
await this.updateRoutesCallback(updatedRoutes);
|
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> {
|
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
|
||||||
this.pendingChallenges.set(token, keyAuth);
|
// 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' };
|
||||||
|
}
|
||||||
|
|
||||||
// Add challenge route if it's the first challenge
|
// Create mock request/response objects for SmartAcme
|
||||||
if (this.pendingChallenges.size === 1) {
|
const mockReq = {
|
||||||
await this.addChallengeRoute();
|
url: context.path,
|
||||||
}
|
method: 'GET',
|
||||||
}
|
headers: context.headers || {}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
let responseData: any = null;
|
||||||
* Cleanup ACME challenge
|
const mockRes = {
|
||||||
*/
|
statusCode: 200,
|
||||||
private async cleanupChallenge(token: string): Promise<void> {
|
setHeader: (name: string, value: string) => {},
|
||||||
this.pendingChallenges.delete(token);
|
end: (data: any) => {
|
||||||
|
responseData = data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Remove challenge route if no more challenges
|
// Use SmartAcme's handler
|
||||||
if (this.pendingChallenges.size === 0) {
|
const handled = await new Promise<boolean>((resolve) => {
|
||||||
await this.removeChallengeRoute();
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the challenge route to add it when needed
|
||||||
|
this.challengeRoute = challengeRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
x
Reference in New Issue
Block a user