41 KiB
41 KiB
ACME/Certificate Simplification Plan for SmartProxy
Command to reread CLAUDE.md
reread /home/philkunz/.claude/CLAUDE.md
Overview
Simplify the ACME/Certificate system by consolidating components, removing unnecessary abstraction layers, and integrating directly into SmartProxy's route-based architecture.
Core Principles
- No backward compatibility - Clean break from legacy implementations
- No migration helpers - Users must update to new configuration format
- Remove all legacy code - Delete deprecated methods and interfaces
- Forward-only approach - Focus on simplicity over compatibility
- No complexity for edge cases - Only support the clean, new way
Key Discoveries from Implementation Analysis
- SmartProxy already supports static routes - The 'static' type exists in TRouteActionType
- Path-based routing works perfectly - The route matching system handles paths with glob patterns
- Dynamic route updates are safe - SmartProxy's updateRoutes() method handles changes gracefully
- Priority-based routing exists - Routes are sorted by priority, ensuring ACME routes match first
- No separate HTTP server needed - ACME challenges can be regular SmartProxy routes
Current State Analysis
Files to be Removed/Replaced
ts/certificate/ (ENTIRE DIRECTORY TO BE REMOVED)
├── acme/
│ ├── acme-factory.ts (28 lines)
│ ├── challenge-handler.ts (227 lines)
│ └── index.ts (2 lines)
├── events/
│ └── certificate-events.ts (75 lines)
├── models/
│ └── certificate-types.ts (168 lines)
├── providers/
│ ├── cert-provisioner.ts (547 lines)
│ └── index.ts (2 lines)
├── storage/
│ ├── file-storage.ts (134 lines)
│ └── index.ts (2 lines)
├── utils/
│ └── certificate-helpers.ts (166 lines)
└── index.ts (75 lines)
ts/http/port80/ (ENTIRE SUBDIRECTORY TO BE REMOVED)
├── acme-interfaces.ts
├── challenge-responder.ts
├── port80-handler.ts
└── index.ts
ts/http/ (KEEP OTHER SUBDIRECTORIES)
├── index.ts (UPDATE to remove port80 exports)
├── models/ (KEEP)
├── redirects/ (KEEP)
├── router/ (KEEP)
└── utils/ (KEEP)
ts/proxies/smart-proxy/
└── network-proxy-bridge.ts (267 lines - to be simplified)
Current Dependencies
- @push.rocks/smartacme (ACME client)
- @push.rocks/smartfile (file operations)
- @push.rocks/smartcrypto (certificate operations)
- @push.rocks/smartexpress (HTTP server for challenges)
Detailed Implementation Plan
Phase 1: Create SmartCertManager
1.1 Create certificate-manager.ts
// ts/proxies/smart-proxy/certificate-manager.ts
import * as plugins from '../../plugins.js';
import { NetworkProxy } from '../network-proxy/index.js';
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
import { CertStore } from './cert-store.js';
import { AcmeClient } from './acme-client.js';
export interface ICertStatus {
domain: string;
status: 'valid' | 'pending' | 'expired' | 'error';
expiryDate?: Date;
issueDate?: Date;
source: 'static' | 'acme';
error?: string;
}
export interface ICertificateData {
cert: string;
key: string;
ca?: string;
expiryDate: Date;
issueDate: Date;
}
export class SmartCertManager {
private certStore: CertStore;
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private networkProxy: NetworkProxy | null = null;
private renewalTimer: NodeJS.Timer | null = null;
private pendingChallenges: Map<string, string> = new Map();
// Track certificate status by route name
private certStatus: Map<string, ICertStatus> = new Map();
// Callback to update SmartProxy routes for challenges
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
constructor(
private routes: IRouteConfig[],
private certDir: string = './certs',
private acmeOptions?: {
email?: string;
useProduction?: boolean;
port?: number;
}
) {
this.certStore = new CertStore(certDir);
}
public setNetworkProxy(networkProxy: NetworkProxy): void {
this.networkProxy = networkProxy;
}
/**
* Set callback for updating routes (used for challenge routes)
*/
public setUpdateRoutesCallback(callback: (routes: IRouteConfig[]) => Promise<void>): void {
this.updateRoutesCallback = callback;
}
/**
* Initialize certificate manager and provision certificates for all routes
*/
public async initialize(): Promise<void> {
// Create certificate directory if it doesn't exist
await this.certStore.initialize();
// Initialize SmartAcme if we have any ACME routes
const hasAcmeRoutes = this.routes.some(r =>
r.action.tls?.certificate === 'auto'
);
if (hasAcmeRoutes && this.acmeOptions?.email) {
// Create SmartAcme instance with our challenge handler
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.acmeOptions.email,
environment: this.acmeOptions.useProduction ? 'production' : 'staging',
certManager: new InMemoryCertManager(), // Simple in-memory cert manager
challengeHandlers: [{
type: 'http-01',
setChallenge: async (domain: string, token: string, keyAuth: string) => {
await this.handleChallenge(token, keyAuth);
},
removeChallenge: async (domain: string, token: string) => {
await this.cleanupChallenge(token);
}
}]
});
await this.smartAcme.start();
}
// Provision certificates for all routes
await this.provisionAllCertificates();
// Start renewal timer
this.startRenewalTimer();
}
/**
* Provision certificates for all routes that need them
*/
private async provisionAllCertificates(): Promise<void> {
const certRoutes = this.routes.filter(r =>
r.action.tls?.mode === 'terminate' ||
r.action.tls?.mode === 'terminate-and-reencrypt'
);
for (const route of certRoutes) {
try {
await this.provisionCertificate(route);
} catch (error) {
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
}
}
}
/**
* Provision certificate for a single route
*/
public async provisionCertificate(route: IRouteConfig): Promise<void> {
const tls = route.action.tls;
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
return;
}
const domains = this.extractDomainsFromRoute(route);
if (domains.length === 0) {
console.warn(`Route ${route.name} has TLS termination but no domains`);
return;
}
const primaryDomain = domains[0];
if (tls.certificate === 'auto') {
// ACME certificate
await this.provisionAcmeCertificate(route, domains);
} else if (typeof tls.certificate === 'object') {
// Static certificate
await this.provisionStaticCertificate(route, primaryDomain, tls.certificate);
}
}
/**
* Provision ACME certificate
*/
private async provisionAcmeCertificate(
route: IRouteConfig,
domains: string[]
): Promise<void> {
if (!this.smartAcme) {
throw new Error('SmartAcme not initialized');
}
const primaryDomain = domains[0];
const routeName = route.name || primaryDomain;
// Check if we already have a valid certificate
const existingCert = await this.certStore.getCertificate(routeName);
if (existingCert && this.isCertificateValid(existingCert)) {
console.log(`Using existing valid certificate for ${primaryDomain}`);
await this.applyCertificate(primaryDomain, existingCert);
this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
return;
}
console.log(`Requesting ACME certificate for ${domains.join(', ')}`);
this.updateCertStatus(routeName, 'pending', 'acme');
try {
// Use smartacme to get certificate
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain, {
altNames: domains.slice(1)
});
// smartacme returns a Cert object with these properties
const certData: ICertificateData = {
cert: cert.cert,
key: cert.privateKey,
ca: cert.fullChain || cert.cert, // Use fullChain if available
expiryDate: new Date(cert.validTo),
issueDate: new Date(cert.validFrom)
};
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}`);
} catch (error) {
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
throw error;
}
}
/**
* Provision static certificate
*/
private async provisionStaticCertificate(
route: IRouteConfig,
domain: string,
certConfig: { key: string; cert: string; keyFile?: string; certFile?: string }
): Promise<void> {
const routeName = route.name || domain;
try {
let key: string = certConfig.key;
let cert: string = certConfig.cert;
// Load from files if paths are provided
if (certConfig.keyFile) {
key = await plugins.smartfile.fs.readFileAsString(certConfig.keyFile);
}
if (certConfig.certFile) {
cert = await plugins.smartfile.fs.readFileAsString(certConfig.certFile);
}
// Parse certificate to get dates
const certInfo = await plugins.smartcrypto.cert.parseCert(cert);
const certData: ICertificateData = {
cert,
key,
expiryDate: certInfo.validTo,
issueDate: certInfo.validFrom
};
// Save to store for consistency
await this.certStore.saveCertificate(routeName, certData);
await this.applyCertificate(domain, certData);
this.updateCertStatus(routeName, 'valid', 'static', certData);
console.log(`Successfully loaded static certificate for ${domain}`);
} catch (error) {
console.error(`Failed to provision static certificate for ${domain}: ${error}`);
this.updateCertStatus(routeName, 'error', 'static', undefined, error.message);
throw error;
}
}
/**
* Apply certificate to NetworkProxy
*/
private async applyCertificate(domain: string, certData: ICertificateData): Promise<void> {
if (!this.networkProxy) {
console.warn('NetworkProxy not set, cannot apply certificate');
return;
}
// Apply certificate to NetworkProxy
this.networkProxy.updateCertificate(domain, certData.cert, certData.key);
// Also apply for wildcard if it's a subdomain
if (domain.includes('.') && !domain.startsWith('*.')) {
const parts = domain.split('.');
if (parts.length >= 2) {
const wildcardDomain = `*.${parts.slice(-2).join('.')}`;
this.networkProxy.updateCertificate(wildcardDomain, certData.cert, certData.key);
}
}
}
/**
* Extract domains from route configuration
*/
private extractDomainsFromRoute(route: IRouteConfig): string[] {
if (!route.match.domains) {
return [];
}
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Filter out wildcards and patterns
return domains.filter(d =>
!d.includes('*') &&
!d.includes('{') &&
d.includes('.')
);
}
/**
* Check if certificate is valid
*/
private isCertificateValid(cert: ICertificateData): boolean {
const now = new Date();
const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
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
*/
private async addChallengeRoute(): Promise<void> {
if (!this.updateRoutesCallback) {
throw new Error('No route update callback set');
}
const challengeRoute = this.createChallengeRoute();
const updatedRoutes = [...this.routes, challengeRoute];
await this.updateRoutesCallback(updatedRoutes);
}
/**
* Remove challenge route from SmartProxy
*/
private async removeChallengeRoute(): Promise<void> {
if (!this.updateRoutesCallback) {
return;
}
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
await this.updateRoutesCallback(filteredRoutes);
}
/**
* Start renewal timer
*/
private startRenewalTimer(): void {
// Check for renewals every 12 hours
this.renewalTimer = setInterval(() => {
this.checkAndRenewCertificates();
}, 12 * 60 * 60 * 1000);
// Also do an immediate check
this.checkAndRenewCertificates();
}
/**
* Check and renew certificates that are expiring
*/
private async checkAndRenewCertificates(): Promise<void> {
for (const route of this.routes) {
if (route.action.tls?.certificate === 'auto') {
const routeName = route.name || this.extractDomainsFromRoute(route)[0];
const cert = await this.certStore.getCertificate(routeName);
if (cert && !this.isCertificateValid(cert)) {
console.log(`Certificate for ${routeName} needs renewal`);
try {
await this.provisionCertificate(route);
} catch (error) {
console.error(`Failed to renew certificate for ${routeName}: ${error}`);
}
}
}
}
}
/**
* Update certificate status
*/
private updateCertStatus(
routeName: string,
status: ICertStatus['status'],
source: ICertStatus['source'],
certData?: ICertificateData,
error?: string
): void {
this.certStatus.set(routeName, {
domain: routeName,
status,
source,
expiryDate: certData?.expiryDate,
issueDate: certData?.issueDate,
error
});
}
/**
* Get certificate status for a route
*/
public getCertificateStatus(routeName: string): ICertStatus | undefined {
return this.certStatus.get(routeName);
}
/**
* Force renewal of a certificate
*/
public async renewCertificate(routeName: string): Promise<void> {
const route = this.routes.find(r => r.name === routeName);
if (!route) {
throw new Error(`Route ${routeName} not found`);
}
// Remove existing certificate to force renewal
await this.certStore.deleteCertificate(routeName);
await this.provisionCertificate(route);
}
/**
* Handle ACME challenge
*/
private async handleChallenge(token: string, keyAuth: string): Promise<void> {
this.pendingChallenges.set(token, keyAuth);
// 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();
}
}
/**
* Stop certificate manager
*/
public async stop(): Promise<void> {
if (this.renewalTimer) {
clearInterval(this.renewalTimer);
this.renewalTimer = null;
}
if (this.smartAcme) {
await this.smartAcme.stop();
}
// Remove any active challenge routes
if (this.pendingChallenges.size > 0) {
this.pendingChallenges.clear();
await this.removeChallengeRoute();
}
}
/**
* Get ACME options (for recreating after route updates)
*/
public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined {
return this.acmeOptions;
}
}
/**
* Simple in-memory certificate manager for SmartAcme
* We only use this to satisfy SmartAcme's interface - actual storage is handled by CertStore
*/
class InMemoryCertManager implements plugins.smartacme.CertManager {
private store = new Map<string, any>();
public async getCert(domain: string): Promise<any> {
// SmartAcme uses this to check for existing certs
// We return null to force it to always request new certs
return null;
}
public async setCert(domain: string, certificate: any): Promise<void> {
// SmartAcme calls this after getting a cert
// We ignore it since we handle storage ourselves
}
public async removeCert(domain: string): Promise<void> {
// Not needed for our use case
}
}
1.2 Create cert-store.ts
// ts/proxies/smart-proxy/cert-store.ts
import * as plugins from '../../plugins.js';
import type { ICertificateData } from './certificate-manager.js';
export class CertStore {
constructor(private certDir: string) {}
public async initialize(): Promise<void> {
await plugins.smartfile.fs.ensureDirectory(this.certDir);
}
public async getCertificate(routeName: string): Promise<ICertificateData | null> {
const certPath = this.getCertPath(routeName);
const metaPath = `${certPath}/meta.json`;
if (!await plugins.smartfile.fs.fileExists(metaPath)) {
return null;
}
try {
const meta = await plugins.smartfile.fs.readJson(metaPath);
const cert = await plugins.smartfile.fs.readFileAsString(`${certPath}/cert.pem`);
const key = await plugins.smartfile.fs.readFileAsString(`${certPath}/key.pem`);
let ca: string | undefined;
const caPath = `${certPath}/ca.pem`;
if (await plugins.smartfile.fs.fileExists(caPath)) {
ca = await plugins.smartfile.fs.readFileAsString(caPath);
}
return {
cert,
key,
ca,
expiryDate: new Date(meta.expiryDate),
issueDate: new Date(meta.issueDate)
};
} catch (error) {
console.error(`Failed to load certificate for ${routeName}: ${error}`);
return null;
}
}
public async saveCertificate(
routeName: string,
certData: ICertificateData
): Promise<void> {
const certPath = this.getCertPath(routeName);
await plugins.smartfile.fs.ensureDirectory(certPath);
// Save certificate files
await plugins.smartfile.fs.writeFileAsString(
`${certPath}/cert.pem`,
certData.cert
);
await plugins.smartfile.fs.writeFileAsString(
`${certPath}/key.pem`,
certData.key
);
if (certData.ca) {
await plugins.smartfile.fs.writeFileAsString(
`${certPath}/ca.pem`,
certData.ca
);
}
// Save metadata
const meta = {
expiryDate: certData.expiryDate.toISOString(),
issueDate: certData.issueDate.toISOString(),
savedAt: new Date().toISOString()
};
await plugins.smartfile.fs.writeJson(`${certPath}/meta.json`, meta);
}
public async deleteCertificate(routeName: string): Promise<void> {
const certPath = this.getCertPath(routeName);
if (await plugins.smartfile.fs.fileExists(certPath)) {
await plugins.smartfile.fs.removeDirectory(certPath);
}
}
private getCertPath(routeName: string): string {
// Sanitize route name for filesystem
const safeName = routeName.replace(/[^a-zA-Z0-9-_]/g, '_');
return `${this.certDir}/${safeName}`;
}
}
Phase 2: Update Route Types and Handler
2.1 Update route-types.ts
// Add to ts/proxies/smart-proxy/models/route-types.ts
/**
* ACME configuration for automatic certificate provisioning
*/
export interface IRouteAcme {
email: string; // Contact email for ACME account
useProduction?: boolean; // Use production ACME servers (default: false)
challengePort?: number; // Port for HTTP-01 challenges (default: 80)
renewBeforeDays?: number; // Days before expiry to renew (default: 30)
}
/**
* Static route handler response
*/
export interface IStaticResponse {
status: number;
headers?: Record<string, string>;
body: string | Buffer;
}
/**
* Update IRouteAction to support static handlers
* NOTE: The 'static' type already exists in TRouteActionType
*/
export interface IRouteAction {
type: TRouteActionType;
target?: IRouteTarget;
security?: IRouteSecurity;
options?: IRouteOptions;
tls?: IRouteTls;
redirect?: IRouteRedirect;
handler?: (context: IRouteContext) => Promise<IStaticResponse>; // For static routes
}
/**
* Extend IRouteConfig to ensure challenge routes have higher priority
*/
export interface IRouteConfig {
name?: string;
match: IRouteMatch;
action: IRouteAction;
priority?: number; // Already exists - ACME routes should use high priority
}
/**
* Extended TLS configuration for route actions
*/
export interface IRouteTls {
mode: TTlsMode;
certificate?: 'auto' | { // Auto = use ACME
key: string; // PEM-encoded private key
cert: string; // PEM-encoded certificate
ca?: string; // PEM-encoded CA chain
keyFile?: string; // Path to key file (overrides key)
certFile?: string; // Path to cert file (overrides cert)
};
acme?: IRouteAcme; // ACME options when certificate is 'auto'
versions?: string[]; // Allowed TLS versions (e.g., ['TLSv1.2', 'TLSv1.3'])
ciphers?: string; // OpenSSL cipher string
honorCipherOrder?: boolean; // Use server's cipher preferences
sessionTimeout?: number; // TLS session timeout in seconds
}
2.2 Add Static Route Handler
// Add to ts/proxies/smart-proxy/route-connection-handler.ts
/**
* Handle the route based on its action type
*/
switch (route.action.type) {
case 'forward':
return this.handleForwardAction(socket, record, route, initialChunk);
case 'redirect':
return this.handleRedirectAction(socket, record, route);
case 'block':
return this.handleBlockAction(socket, record, route);
case 'static':
return this.handleStaticAction(socket, record, route);
default:
console.log(`[${connectionId}] Unknown action type: ${(route.action as any).type}`);
socket.end();
this.connectionManager.cleanupConnection(record, 'unknown_action');
}
/**
* Handle a static action for a route
*/
private async handleStaticAction(
socket: plugins.net.Socket,
record: IConnectionRecord,
route: IRouteConfig
): Promise<void> {
const connectionId = record.id;
if (!route.action.handler) {
console.error(`[${connectionId}] Static route '${route.name}' has no handler`);
socket.end();
this.connectionManager.cleanupConnection(record, 'no_handler');
return;
}
try {
// Build route context
const context: IRouteContext = {
port: record.localPort,
domain: record.lockedDomain,
clientIp: record.remoteIP,
serverIp: socket.localAddress!,
path: record.path, // Will need to be extracted from HTTP request
isTls: record.isTLS,
tlsVersion: record.tlsVersion,
routeName: route.name,
routeId: route.name,
timestamp: Date.now(),
connectionId
};
// Call the handler
const response = await route.action.handler(context);
// Send HTTP response
const headers = response.headers || {};
headers['Content-Length'] = Buffer.byteLength(response.body).toString();
let httpResponse = `HTTP/1.1 ${response.status} ${getStatusText(response.status)}\r\n`;
for (const [key, value] of Object.entries(headers)) {
httpResponse += `${key}: ${value}\r\n`;
}
httpResponse += '\r\n';
socket.write(httpResponse);
socket.write(response.body);
socket.end();
this.connectionManager.cleanupConnection(record, 'completed');
} catch (error) {
console.error(`[${connectionId}] Error in static handler: ${error}`);
socket.end();
this.connectionManager.cleanupConnection(record, 'handler_error');
}
}
// Helper function for status text
function getStatusText(status: number): string {
const statusTexts: Record<number, string> = {
200: 'OK',
404: 'Not Found',
500: 'Internal Server Error'
};
return statusTexts[status] || 'Unknown';
}
Phase 3: SmartProxy Integration
3.1 Update SmartProxy class
// Changes to ts/proxies/smart-proxy/smart-proxy.ts
import { SmartCertManager } from './certificate-manager.js';
// Remove ALL certificate/ACME related imports:
// - CertProvisioner
// - Port80Handler
// - buildPort80Handler
// - createPort80HandlerOptions
export class SmartProxy extends plugins.EventEmitter {
// Replace certProvisioner and port80Handler with just:
private certManager: SmartCertManager | null = null;
constructor(settingsArg: ISmartProxyOptions) {
super();
// ... existing initialization ...
// No need for ACME settings in ISmartProxyOptions anymore
// Certificate configuration is now in route definitions
}
/**
* Initialize certificate manager
*/
private async initializeCertificateManager(): Promise<void> {
// Extract global ACME options if any routes use auto certificates
const autoRoutes = this.settings.routes.filter(r =>
r.action.tls?.certificate === 'auto'
);
if (autoRoutes.length === 0 && !this.hasStaticCertRoutes()) {
console.log('No routes require certificate management');
return;
}
// Use the first auto route's ACME config as defaults
const defaultAcme = autoRoutes[0]?.action.tls?.acme;
this.certManager = new SmartCertManager(
this.settings.routes,
'./certs', // Certificate directory
defaultAcme ? {
email: defaultAcme.email,
useProduction: defaultAcme.useProduction,
port: defaultAcme.challengePort || 80
} : undefined
);
// Connect with NetworkProxy
if (this.networkProxyBridge.getNetworkProxy()) {
this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
}
// Set route update callback for ACME challenges
this.certManager.setUpdateRoutesCallback(async (routes) => {
await this.updateRoutes(routes);
});
await this.certManager.initialize();
}
/**
* Check if we have routes with static certificates
*/
private hasStaticCertRoutes(): boolean {
return this.settings.routes.some(r =>
r.action.tls?.certificate &&
r.action.tls.certificate !== 'auto'
);
}
public async start() {
if (this.isShuttingDown) {
console.log("Cannot start SmartProxy while it's shutting down");
return;
}
// Initialize certificate manager before starting servers
await this.initializeCertificateManager();
// Initialize and start NetworkProxy if needed
if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
await this.networkProxyBridge.initialize();
// Connect NetworkProxy with certificate manager
if (this.certManager) {
this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
}
await this.networkProxyBridge.start();
}
// ... rest of start method ...
}
public async stop() {
console.log('SmartProxy shutting down...');
this.isShuttingDown = true;
this.portManager.setShuttingDown(true);
// Stop certificate manager
if (this.certManager) {
await this.certManager.stop();
console.log('Certificate manager stopped');
}
// ... rest of stop method ...
}
/**
* Update routes with new configuration
*/
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
console.log(`Updating routes (${newRoutes.length} routes)`);
// Update certificate manager with new routes
if (this.certManager) {
await this.certManager.stop();
this.certManager = new SmartCertManager(
newRoutes,
'./certs',
this.certManager.getAcmeOptions()
);
if (this.networkProxyBridge.getNetworkProxy()) {
this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
}
await this.certManager.initialize();
}
// ... rest of updateRoutes method ...
}
/**
* Manually provision a certificate for a route
*/
public async provisionCertificate(routeName: string): Promise<void> {
if (!this.certManager) {
throw new Error('Certificate manager not initialized');
}
const route = this.settings.routes.find(r => r.name === routeName);
if (!route) {
throw new Error(`Route ${routeName} not found`);
}
await this.certManager.provisionCertificate(route);
}
/**
* Force renewal of a certificate
*/
public async renewCertificate(routeName: string): Promise<void> {
if (!this.certManager) {
throw new Error('Certificate manager not initialized');
}
await this.certManager.renewCertificate(routeName);
}
/**
* Get certificate status for a route
*/
public getCertificateStatus(routeName: string): ICertStatus | undefined {
if (!this.certManager) {
return undefined;
}
return this.certManager.getCertificateStatus(routeName);
}
}
3.2 Simplify NetworkProxyBridge
// Simplified ts/proxies/smart-proxy/network-proxy-bridge.ts
import * as plugins from '../../plugins.js';
import { NetworkProxy } from '../network-proxy/index.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
export class NetworkProxyBridge {
private networkProxy: NetworkProxy | null = null;
constructor(private settings: ISmartProxyOptions) {}
/**
* Get the NetworkProxy instance
*/
public getNetworkProxy(): NetworkProxy | null {
return this.networkProxy;
}
/**
* Initialize NetworkProxy instance
*/
public async initialize(): Promise<void> {
if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
const networkProxyOptions: any = {
port: this.settings.networkProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info'
};
this.networkProxy = new NetworkProxy(networkProxyOptions);
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
// Apply route configurations to NetworkProxy
await this.syncRoutesToNetworkProxy(this.settings.routes || []);
}
}
/**
* Sync routes to NetworkProxy
*/
private async syncRoutesToNetworkProxy(routes: IRouteConfig[]): Promise<void> {
if (!this.networkProxy) return;
// Convert routes to NetworkProxy format
const networkProxyConfigs = routes
.filter(route =>
this.settings.useNetworkProxy?.includes(route.match.domains?.[0]) ||
this.settings.useNetworkProxy?.includes('*')
)
.map(route => this.routeToNetworkProxyConfig(route));
// Apply configurations to NetworkProxy
await this.networkProxy.updateProxyConfigs(networkProxyConfigs);
}
/**
* Convert route to NetworkProxy configuration
*/
private routeToNetworkProxyConfig(route: IRouteConfig): any {
// Convert route to NetworkProxy domain config format
return {
domain: route.match.domains?.[0] || '*',
target: route.action.target,
tls: route.action.tls,
security: route.action.security
};
}
/**
* Check if connection should use NetworkProxy
*/
public shouldUseNetworkProxy(connection: IConnectionRecord, routeMatch: any): boolean {
// Only use NetworkProxy for TLS termination
return (
routeMatch.route.action.tls?.mode === 'terminate' ||
routeMatch.route.action.tls?.mode === 'terminate-and-reencrypt'
) && this.networkProxy !== null;
}
/**
* Pipe connection to NetworkProxy
*/
public async pipeToNetworkProxy(socket: plugins.net.Socket): Promise<void> {
if (!this.networkProxy) {
throw new Error('NetworkProxy not initialized');
}
const proxySocket = new plugins.net.Socket();
await new Promise<void>((resolve, reject) => {
proxySocket.connect(this.settings.networkProxyPort!, 'localhost', () => {
console.log(`Connected to NetworkProxy for termination`);
resolve();
});
proxySocket.on('error', reject);
});
// Pipe the sockets together
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Handle cleanup
const cleanup = () => {
socket.unpipe(proxySocket);
proxySocket.unpipe(socket);
proxySocket.destroy();
};
socket.on('end', cleanup);
socket.on('error', cleanup);
proxySocket.on('end', cleanup);
proxySocket.on('error', cleanup);
}
/**
* Start NetworkProxy
*/
public async start(): Promise<void> {
if (this.networkProxy) {
await this.networkProxy.start();
}
}
/**
* Stop NetworkProxy
*/
public async stop(): Promise<void> {
if (this.networkProxy) {
await this.networkProxy.stop();
this.networkProxy = null;
}
}
}
Phase 4: Configuration Examples (No Migration)
4.1 New Configuration Format ONLY
// Update test files to use new structure
// test/test.certificate-provisioning.ts
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { expect, tap } from '@push.rocks/tapbundle';
const testProxy = new SmartProxy({
routes: [{
name: 'test-route',
match: { ports: 443, domains: 'test.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: {
mode: 'terminate',
certificate: 'auto',
acme: {
email: 'test@example.com',
useProduction: false
}
}
}
}]
});
tap.test('should provision certificate automatically', async () => {
await testProxy.start();
// Wait for certificate provisioning
await new Promise(resolve => setTimeout(resolve, 5000));
const status = testProxy.getCertificateStatus('test-route');
expect(status).toBeDefined();
expect(status.status).toEqual('valid');
expect(status.source).toEqual('acme');
await testProxy.stop();
});
tap.test('should handle static certificates', async () => {
const proxy = new SmartProxy({
routes: [{
name: 'static-route',
match: { ports: 443, domains: 'static.example.com' },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: {
mode: 'terminate',
certificate: {
certFile: './test/fixtures/cert.pem',
keyFile: './test/fixtures/key.pem'
}
}
}
}]
});
await proxy.start();
const status = proxy.getCertificateStatus('static-route');
expect(status).toBeDefined();
expect(status.status).toEqual('valid');
expect(status.source).toEqual('static');
await proxy.stop();
});
Phase 5: Documentation Update
5.1 Update README.md sections
## Certificate Management
SmartProxy includes built-in certificate management with automatic ACME (Let's Encrypt) support.
### Automatic Certificates (ACME)
```typescript
const proxy = new SmartProxy({
routes: [{
name: 'secure-site',
match: {
ports: 443,
domains: ['example.com', 'www.example.com']
},
action: {
type: 'forward',
target: { host: 'backend', port: 8080 },
tls: {
mode: 'terminate',
certificate: 'auto',
acme: {
email: 'admin@example.com',
useProduction: true,
renewBeforeDays: 30
}
}
}
}]
});
Static Certificates
const proxy = new SmartProxy({
routes: [{
name: 'static-cert',
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
target: { host: 'backend', port: 8080 },
tls: {
mode: 'terminate',
certificate: {
certFile: './certs/secure.pem',
keyFile: './certs/secure.key'
}
}
}
}]
});
Certificate Management API
// Get certificate status
const status = proxy.getCertificateStatus('route-name');
console.log(status);
// {
// domain: 'example.com',
// status: 'valid',
// source: 'acme',
// expiryDate: Date,
// issueDate: Date
// }
// Manually provision certificate
await proxy.provisionCertificate('route-name');
// Force certificate renewal
await proxy.renewCertificate('route-name');
Certificate Storage
Certificates are stored in the ./certs
directory by default:
./certs/
├── route-name/
│ ├── cert.pem
│ ├── key.pem
│ ├── ca.pem (if available)
│ └── meta.json
### Phase 5: Update HTTP Module
#### 5.1 Update http/index.ts
```typescript
// ts/http/index.ts
/**
* HTTP functionality module
*/
// Export types and models
export * from './models/http-types.js';
// Export submodules (remove port80 export)
export * from './router/index.js';
export * from './redirects/index.js';
// REMOVED: export * from './port80/index.js';
// Convenience namespace exports (no more Port80)
export const Http = {
// Only router and redirect functionality remain
};
Phase 6: Cleanup Tasks
6.1 File Deletion Script
#!/bin/bash
# cleanup-certificates.sh
# Remove old certificate module
rm -rf ts/certificate/
# Remove entire port80 subdirectory
rm -rf ts/http/port80/
# Remove old imports from index files
sed -i '/certificate\//d' ts/index.ts
sed -i '/port80\//d' ts/http/index.ts
# Update plugins.ts to remove unused dependencies (if not used elsewhere)
# sed -i '/smartexpress/d' ts/plugins.ts
6.2 Key Simplifications Achieved
- No custom ACME wrapper - Direct use of @push.rocks/smartacme
- No separate HTTP server - ACME challenges are regular routes
- Built-in path routing - SmartProxy already handles path-based matching
- Built-in priorities - Routes are already sorted by priority
- Safe updates - Route updates are already thread-safe
- Minimal new code - Mostly configuration and integration
The simplification leverages SmartProxy's existing capabilities rather than reinventing them.
6.2 Update Package.json
{
"dependencies": {
// Remove if no longer needed elsewhere:
// "@push.rocks/smartexpress": "x.x.x"
}
}
Implementation Sequence
-
Day 1: Core Implementation
- Create SmartCertManager class
- Create CertStore and AcmeClient
- Update route types
-
Day 2: Integration
- Update SmartProxy to use SmartCertManager
- Simplify NetworkProxyBridge
- Remove old certificate system
-
Day 3: Testing
- Create new tests using new format only
- No migration testing needed
- Test all new functionality
-
Day 4: Documentation & Cleanup
- Update all documentation
- Clean up old files
- Final testing and validation
Risk Mitigation
-
Static Route Handler
- Already exists in the type system
- Just needs implementation in route-connection-handler.ts
- Low risk as it follows existing patterns
-
Route Updates During Operation
- SmartProxy's updateRoutes() is already thread-safe
- Sequential processing prevents race conditions
- Challenge routes are added/removed atomically
-
Port 80 Conflicts
- Priority-based routing ensures ACME routes match first
- Path-based matching (
/.well-known/acme-challenge/*
) is specific - Other routes on port 80 won't interfere
-
Error Recovery
- SmartAcme initialization failures are handled gracefully
- Null checks prevent crashes if ACME isn't available
- Routes continue to work without certificates
-
Testing Strategy
- Test concurrent ACME challenges
- Test route priority conflicts
- Test certificate renewal during high traffic
- Test the new configuration format only
-
No Migration Path
- Breaking change is intentional
- Old configurations must be manually updated
- No compatibility shims or helpers provided