1442 lines
41 KiB
Markdown
1442 lines
41 KiB
Markdown
# 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
|
|
1. **No backward compatibility** - Clean break from legacy implementations
|
|
2. **No migration helpers** - Users must update to new configuration format
|
|
3. **Remove all legacy code** - Delete deprecated methods and interfaces
|
|
4. **Forward-only approach** - Focus on simplicity over compatibility
|
|
5. **No complexity for edge cases** - Only support the clean, new way
|
|
|
|
## Key Discoveries from Implementation Analysis
|
|
|
|
1. **SmartProxy already supports static routes** - The 'static' type exists in TRouteActionType
|
|
2. **Path-based routing works perfectly** - The route matching system handles paths with glob patterns
|
|
3. **Dynamic route updates are safe** - SmartProxy's updateRoutes() method handles changes gracefully
|
|
4. **Priority-based routing exists** - Routes are sorted by priority, ensuring ACME routes match first
|
|
5. **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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```markdown
|
|
## 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
```bash
|
|
#!/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
|
|
|
|
1. **No custom ACME wrapper** - Direct use of @push.rocks/smartacme
|
|
2. **No separate HTTP server** - ACME challenges are regular routes
|
|
3. **Built-in path routing** - SmartProxy already handles path-based matching
|
|
4. **Built-in priorities** - Routes are already sorted by priority
|
|
5. **Safe updates** - Route updates are already thread-safe
|
|
6. **Minimal new code** - Mostly configuration and integration
|
|
|
|
The simplification leverages SmartProxy's existing capabilities rather than reinventing them.
|
|
|
|
#### 6.2 Update Package.json
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
// Remove if no longer needed elsewhere:
|
|
// "@push.rocks/smartexpress": "x.x.x"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Implementation Sequence
|
|
|
|
1. **Day 1: Core Implementation**
|
|
- Create SmartCertManager class
|
|
- Create CertStore and AcmeClient
|
|
- Update route types
|
|
|
|
2. **Day 2: Integration**
|
|
- Update SmartProxy to use SmartCertManager
|
|
- Simplify NetworkProxyBridge
|
|
- Remove old certificate system
|
|
|
|
3. **Day 3: Testing**
|
|
- Create new tests using new format only
|
|
- No migration testing needed
|
|
- Test all new functionality
|
|
|
|
4. **Day 4: Documentation & Cleanup**
|
|
- Update all documentation
|
|
- Clean up old files
|
|
- Final testing and validation
|
|
|
|
## Risk Mitigation
|
|
|
|
1. **Static Route Handler**
|
|
- Already exists in the type system
|
|
- Just needs implementation in route-connection-handler.ts
|
|
- Low risk as it follows existing patterns
|
|
|
|
2. **Route Updates During Operation**
|
|
- SmartProxy's updateRoutes() is already thread-safe
|
|
- Sequential processing prevents race conditions
|
|
- Challenge routes are added/removed atomically
|
|
|
|
3. **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
|
|
|
|
4. **Error Recovery**
|
|
- SmartAcme initialization failures are handled gracefully
|
|
- Null checks prevent crashes if ACME isn't available
|
|
- Routes continue to work without certificates
|
|
|
|
5. **Testing Strategy**
|
|
- Test concurrent ACME challenges
|
|
- Test route priority conflicts
|
|
- Test certificate renewal during high traffic
|
|
- Test the new configuration format only
|
|
|
|
6. **No Migration Path**
|
|
- Breaking change is intentional
|
|
- Old configurations must be manually updated
|
|
- No compatibility shims or helpers provided |