Files
smartproxy/ts/proxies/smart-proxy/smart-proxy.ts

410 lines
15 KiB
TypeScript

import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';
// Rust bridge and helpers
import { RustProxyBridge } from './rust-proxy-bridge.js';
import { RustBinaryLocator } from './rust-binary-locator.js';
import { RoutePreprocessor } from './route-preprocessor.js';
import { SocketHandlerServer } from './socket-handler-server.js';
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js';
import { Mutex } from './utils/mutex.js';
// Types
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js';
/**
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
*
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security,
* NFTables) is handled by the Rust binary. TypeScript is only:
* - The npm module interface (types, route helpers)
* - The thin IPC wrapper (this class)
* - Socket-handler callback relay (for JS-defined handlers)
* - Certificate provisioning callbacks (certProvisionFunction)
*/
export class SmartProxy extends plugins.EventEmitter {
public settings: ISmartProxyOptions;
public routeManager: RouteManager;
private bridge: RustProxyBridge;
private preprocessor: RoutePreprocessor;
private socketHandlerServer: SocketHandlerServer | null = null;
private metricsAdapter: RustMetricsAdapter;
private routeUpdateLock: Mutex;
constructor(settingsArg: ISmartProxyOptions) {
super();
// Apply defaults
this.settings = {
...settingsArg,
initialDataTimeout: settingsArg.initialDataTimeout || 120000,
socketTimeout: settingsArg.socketTimeout || 3600000,
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000,
inactivityTimeout: settingsArg.inactivityTimeout || 14400000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
};
// Normalize ACME options
if (this.settings.acme) {
if (this.settings.acme.accountEmail && !this.settings.acme.email) {
this.settings.acme.email = this.settings.acme.accountEmail;
}
this.settings.acme = {
enabled: this.settings.acme.enabled !== false,
port: this.settings.acme.port || 80,
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false,
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [],
...this.settings.acme,
};
}
// Validate routes
if (this.settings.routes?.length) {
const validation = RouteValidator.validateRoutes(this.settings.routes);
if (!validation.valid) {
RouteValidator.logValidationErrors(validation.errors);
throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
}
}
// Create logger adapter
const loggerAdapter = {
debug: (message: string, data?: any) => logger.log('debug', message, data),
info: (message: string, data?: any) => logger.log('info', message, data),
warn: (message: string, data?: any) => logger.log('warn', message, data),
error: (message: string, data?: any) => logger.log('error', message, data),
};
// Initialize components
this.routeManager = new RouteManager({
logger: loggerAdapter,
enableDetailedLogging: this.settings.enableDetailedLogging,
routes: this.settings.routes,
});
this.bridge = new RustProxyBridge();
this.preprocessor = new RoutePreprocessor();
this.metricsAdapter = new RustMetricsAdapter(this.bridge);
this.routeUpdateLock = new Mutex();
}
/**
* Start the proxy.
* Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning.
*/
public async start(): Promise<void> {
// Spawn Rust binary
const spawned = await this.bridge.spawn();
if (!spawned) {
throw new Error(
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
'or build locally with: cd rust && cargo build --release'
);
}
// Handle unexpected exit
this.bridge.on('exit', (code: number | null, signal: string | null) => {
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
});
// Start socket handler relay if any routes need TS-side handling
const hasHandlerRoutes = this.settings.routes.some(
(r) =>
(r.action.type === 'socket-handler' && r.action.socketHandler) ||
r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
);
if (hasHandlerRoutes) {
this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
await this.socketHandlerServer.start();
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
}
// Preprocess routes (strip JS functions, convert socket-handler routes)
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
// Build Rust config
const config = this.buildRustConfig(rustRoutes);
// Start the Rust proxy
await this.bridge.startProxy(config);
// Handle certProvisionFunction
await this.provisionCertificatesViaCallback();
// Start metrics polling
this.metricsAdapter.startPolling();
logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
}
/**
* Stop the proxy.
*/
public async stop(): Promise<void> {
logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' });
// Stop metrics polling
this.metricsAdapter.stopPolling();
// Stop Rust proxy
try {
await this.bridge.stopProxy();
} catch {
// Ignore if already stopped
}
this.bridge.kill();
// Stop socket handler relay
if (this.socketHandlerServer) {
await this.socketHandlerServer.stop();
this.socketHandlerServer = null;
}
logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
}
/**
* Update routes atomically.
*/
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
return this.routeUpdateLock.runExclusive(async () => {
// Validate
const validation = RouteValidator.validateRoutes(newRoutes);
if (!validation.valid) {
RouteValidator.logValidationErrors(validation.errors);
throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
}
// Preprocess for Rust
const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);
// Send to Rust
await this.bridge.updateRoutes(rustRoutes);
// Update local route manager
this.routeManager.updateRoutes(newRoutes);
// Update socket handler relay if handler routes changed
const hasHandlerRoutes = newRoutes.some(
(r) =>
(r.action.type === 'socket-handler' && r.action.socketHandler) ||
r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
);
if (hasHandlerRoutes && !this.socketHandlerServer) {
this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
await this.socketHandlerServer.start();
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
} else if (!hasHandlerRoutes && this.socketHandlerServer) {
await this.socketHandlerServer.stop();
this.socketHandlerServer = null;
}
// Update stored routes
this.settings.routes = newRoutes;
// Handle cert provisioning for new routes
await this.provisionCertificatesViaCallback();
logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' });
});
}
/**
* Provision a certificate for a named route.
*/
public async provisionCertificate(routeName: string): Promise<void> {
await this.bridge.provisionCertificate(routeName);
}
/**
* Force renewal of a certificate.
*/
public async renewCertificate(routeName: string): Promise<void> {
await this.bridge.renewCertificate(routeName);
}
/**
* Get certificate status for a route (async - calls Rust).
*/
public async getCertificateStatus(routeName: string): Promise<any> {
return this.bridge.getCertificateStatus(routeName);
}
/**
* Get the metrics interface.
*/
public getMetrics(): IMetrics {
return this.metricsAdapter;
}
/**
* Get statistics (async - calls Rust).
*/
public async getStatistics(): Promise<any> {
return this.bridge.getStatistics();
}
/**
* Add a listening port at runtime.
*/
public async addListeningPort(port: number): Promise<void> {
await this.bridge.addListeningPort(port);
}
/**
* Remove a listening port at runtime.
*/
public async removeListeningPort(port: number): Promise<void> {
await this.bridge.removeListeningPort(port);
}
/**
* Get all currently listening ports (async - calls Rust).
*/
public async getListeningPorts(): Promise<number[]> {
return this.bridge.getListeningPorts();
}
/**
* Get eligible domains for ACME certificates (sync - reads local routes).
*/
public getEligibleDomainsForCertificates(): string[] {
const domains: string[] = [];
for (const route of this.settings.routes || []) {
if (!route.match.domains) continue;
if (
route.action.type !== 'forward' ||
!route.action.tls ||
route.action.tls.mode === 'passthrough' ||
route.action.tls.certificate !== 'auto'
)
continue;
const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
const eligible = routeDomains.filter((d) => !d.includes('*') && this.isValidDomain(d));
domains.push(...eligible);
}
return domains;
}
/**
* Get NFTables status (async - calls Rust).
*/
public async getNfTablesStatus(): Promise<Record<string, any>> {
return this.bridge.getNftablesStatus();
}
// --- Private helpers ---
/**
* Build the Rust configuration object from TS settings.
*/
private buildRustConfig(routes: IRouteConfig[]): any {
return {
routes,
defaults: this.settings.defaults,
acme: this.settings.acme
? {
enabled: this.settings.acme.enabled,
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction,
port: this.settings.acme.port,
renewThresholdDays: this.settings.acme.renewThresholdDays,
autoRenew: this.settings.acme.autoRenew,
certificateStore: this.settings.acme.certificateStore,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours,
}
: undefined,
connectionTimeout: this.settings.connectionTimeout,
initialDataTimeout: this.settings.initialDataTimeout,
socketTimeout: this.settings.socketTimeout,
maxConnectionLifetime: this.settings.maxConnectionLifetime,
gracefulShutdownTimeout: this.settings.gracefulShutdownTimeout,
maxConnectionsPerIp: this.settings.maxConnectionsPerIP,
connectionRateLimitPerMinute: this.settings.connectionRateLimitPerMinute,
keepAliveTreatment: this.settings.keepAliveTreatment,
keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier,
extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime,
acceptProxyProtocol: this.settings.acceptProxyProtocol,
sendProxyProtocol: this.settings.sendProxyProtocol,
};
}
/**
* For routes with certificate: 'auto', call certProvisionFunction if set.
* If the callback returns a cert object, load it into Rust.
* If it returns 'http01', let Rust handle ACME.
*/
private async provisionCertificatesViaCallback(): Promise<void> {
const provisionFn = this.settings.certProvisionFunction;
if (!provisionFn) return;
for (const route of this.settings.routes) {
if (route.action.tls?.certificate !== 'auto') continue;
if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
for (const domain of domains) {
if (domain.includes('*')) continue;
try {
const result: TSmartProxyCertProvisionObject = await provisionFn(domain);
if (result === 'http01') {
// Rust handles ACME for this domain
continue;
}
// Got a static cert object - load it into Rust
if (result && typeof result === 'object') {
const certObj = result as plugins.tsclass.network.ICert;
await this.bridge.loadCertificate(
domain,
certObj.publicKey,
certObj.privateKey,
);
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
}
} catch (err: any) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
// Fallback to ACME if enabled
if (this.settings.certProvisionFallbackToAcme !== false) {
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' });
}
}
}
}
}
private isValidDomain(domain: string): boolean {
if (!domain || domain.length === 0) return false;
if (domain.includes('*')) return false;
const validDomainRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return validDomainRegex.test(domain);
}
}