2025-05-09 21:21:28 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
2025-05-19 23:37:11 +00:00
|
|
|
import { logger } from '../../core/utils/logger.js';
|
2025-05-04 12:44:35 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Rust bridge and helpers
|
|
|
|
|
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
|
|
|
|
import { RoutePreprocessor } from './route-preprocessor.js';
|
|
|
|
|
import { SocketHandlerServer } from './socket-handler-server.js';
|
|
|
|
|
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
2025-05-19 03:40:58 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Route management
|
|
|
|
|
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
2025-08-14 14:30:54 +00:00
|
|
|
import { RouteValidator } from './utils/route-validator.js';
|
2026-02-13 16:32:02 +00:00
|
|
|
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
2026-02-09 10:55:46 +00:00
|
|
|
import { Mutex } from './utils/mutex.js';
|
2025-08-14 14:30:54 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Types
|
2026-02-13 21:24:16 +00:00
|
|
|
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
|
2026-02-09 10:55:46 +00:00
|
|
|
import type { IRouteConfig } from './models/route-types.js';
|
2025-06-22 22:28:37 +00:00
|
|
|
import type { IMetrics } from './models/metrics-types.js';
|
2025-06-09 15:02:36 +00:00
|
|
|
|
2025-03-25 22:30:57 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
2025-05-10 00:49:39 +00:00
|
|
|
*
|
2026-02-09 10:55:46 +00:00
|
|
|
* 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)
|
2025-03-25 22:30:57 +00:00
|
|
|
*/
|
2025-05-01 12:13:18 +00:00
|
|
|
export class SmartProxy extends plugins.EventEmitter {
|
2026-02-09 10:55:46 +00:00
|
|
|
public settings: ISmartProxyOptions;
|
2025-06-22 22:28:37 +00:00
|
|
|
public routeManager: RouteManager;
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
private bridge: RustProxyBridge;
|
|
|
|
|
private preprocessor: RoutePreprocessor;
|
|
|
|
|
private socketHandlerServer: SocketHandlerServer | null = null;
|
|
|
|
|
private metricsAdapter: RustMetricsAdapter;
|
2025-08-14 14:30:54 +00:00
|
|
|
private routeUpdateLock: Mutex;
|
2026-02-09 16:25:33 +00:00
|
|
|
private stopping = false;
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-05-09 22:46:53 +00:00
|
|
|
constructor(settingsArg: ISmartProxyOptions) {
|
2025-05-01 12:13:18 +00:00
|
|
|
super();
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// Apply defaults
|
2025-03-25 22:30:57 +00:00
|
|
|
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,
|
|
|
|
|
};
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// Normalize ACME options
|
2025-05-18 18:29:59 +00:00
|
|
|
if (this.settings.acme) {
|
|
|
|
|
if (this.settings.acme.accountEmail && !this.settings.acme.email) {
|
|
|
|
|
this.settings.acme.email = this.settings.acme.accountEmail;
|
|
|
|
|
}
|
2025-05-02 14:58:33 +00:00
|
|
|
this.settings.acme = {
|
2026-02-09 10:55:46 +00:00
|
|
|
enabled: this.settings.acme.enabled !== false,
|
2025-05-18 18:29:59 +00:00
|
|
|
port: this.settings.acme.port || 80,
|
|
|
|
|
email: this.settings.acme.email,
|
|
|
|
|
useProduction: this.settings.acme.useProduction || false,
|
|
|
|
|
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
|
2026-02-09 10:55:46 +00:00
|
|
|
autoRenew: this.settings.acme.autoRenew !== false,
|
2025-05-18 18:29:59 +00:00
|
|
|
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
|
|
|
|
|
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
|
|
|
|
|
routeForwards: this.settings.acme.routeForwards || [],
|
2026-02-09 10:55:46 +00:00
|
|
|
...this.settings.acme,
|
2025-05-02 11:19:14 +00:00
|
|
|
};
|
2025-03-25 22:30:57 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// Validate routes
|
|
|
|
|
if (this.settings.routes?.length) {
|
2025-08-14 14:30:54 +00:00
|
|
|
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`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// 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
|
2025-06-02 03:57:52 +00:00
|
|
|
this.routeManager = new RouteManager({
|
|
|
|
|
logger: loggerAdapter,
|
|
|
|
|
enableDetailedLogging: this.settings.enableDetailedLogging,
|
2026-02-09 10:55:46 +00:00
|
|
|
routes: this.settings.routes,
|
2025-06-02 03:57:52 +00:00
|
|
|
});
|
2025-05-10 00:26:03 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
this.bridge = new RustProxyBridge();
|
|
|
|
|
this.preprocessor = new RoutePreprocessor();
|
2026-02-09 16:25:33 +00:00
|
|
|
this.metricsAdapter = new RustMetricsAdapter(
|
|
|
|
|
this.bridge,
|
|
|
|
|
this.settings.metrics?.sampleIntervalMs ?? 1000
|
|
|
|
|
);
|
2025-05-19 03:40:58 +00:00
|
|
|
this.routeUpdateLock = new Mutex();
|
2025-05-18 23:07:31 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-25 22:30:57 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Start the proxy.
|
|
|
|
|
* Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning.
|
2025-03-25 22:30:57 +00:00
|
|
|
*/
|
2026-02-09 10:55:46 +00:00
|
|
|
public async start(): Promise<void> {
|
|
|
|
|
// Spawn Rust binary
|
|
|
|
|
const spawned = await this.bridge.spawn();
|
|
|
|
|
if (!spawned) {
|
2025-05-18 18:29:59 +00:00
|
|
|
throw new Error(
|
2026-02-09 10:55:46 +00:00
|
|
|
'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
|
2026-02-10 09:43:40 +00:00
|
|
|
'or build locally with: pnpm build'
|
2025-05-18 18:29:59 +00:00
|
|
|
);
|
|
|
|
|
}
|
2025-03-25 22:30:57 +00:00
|
|
|
|
2026-02-09 16:25:33 +00:00
|
|
|
// Handle unexpected exit (only emits error if not intentionally stopping)
|
2026-02-09 10:55:46 +00:00
|
|
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
2026-02-09 16:25:33 +00:00
|
|
|
if (this.stopping) return;
|
2026-02-09 10:55:46 +00:00
|
|
|
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
|
|
|
|
|
this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
|
2025-05-20 15:32:19 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-09 16:25:33 +00:00
|
|
|
// Check if any routes need TS-side handling (socket handlers, dynamic functions)
|
2026-02-09 10:55:46 +00:00
|
|
|
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')
|
|
|
|
|
);
|
2025-05-15 14:35:01 +00:00
|
|
|
|
2026-02-09 16:25:33 +00:00
|
|
|
// Start socket handler relay server (but don't tell Rust yet - proxy not started)
|
2026-02-09 10:55:46 +00:00
|
|
|
if (hasHandlerRoutes) {
|
|
|
|
|
this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
|
|
|
|
|
await this.socketHandlerServer.start();
|
2025-05-20 16:01:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Preprocess routes (strip JS functions, convert socket-handler routes)
|
|
|
|
|
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
|
2025-05-20 16:01:32 +00:00
|
|
|
|
2026-02-13 13:08:30 +00:00
|
|
|
// When certProvisionFunction handles cert provisioning,
|
|
|
|
|
// disable Rust's built-in ACME to prevent race condition.
|
|
|
|
|
let acmeForRust = this.settings.acme;
|
|
|
|
|
if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
|
|
|
|
|
acmeForRust = { ...acmeForRust, enabled: false };
|
|
|
|
|
logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Build Rust config
|
2026-02-13 13:08:30 +00:00
|
|
|
const config = this.buildRustConfig(rustRoutes, acmeForRust);
|
2025-03-25 22:30:57 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Start the Rust proxy
|
|
|
|
|
await this.bridge.startProxy(config);
|
2025-03-25 22:30:57 +00:00
|
|
|
|
2026-02-09 16:25:33 +00:00
|
|
|
// Now that Rust proxy is running, configure socket handler relay
|
|
|
|
|
if (this.socketHandlerServer) {
|
|
|
|
|
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 16:32:02 +00:00
|
|
|
// Load default self-signed fallback certificate (domain: '*')
|
|
|
|
|
if (!this.settings.disableDefaultCert) {
|
|
|
|
|
try {
|
|
|
|
|
const defaultCert = generateDefaultCertificate();
|
|
|
|
|
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
|
|
|
|
|
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load consumer-stored certificates
|
|
|
|
|
const preloadedDomains = new Set<string>();
|
|
|
|
|
if (this.settings.certStore) {
|
|
|
|
|
try {
|
|
|
|
|
const stored = await this.settings.certStore.loadAll();
|
|
|
|
|
for (const entry of stored) {
|
|
|
|
|
await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
|
|
|
|
|
preloadedDomains.add(entry.domain);
|
|
|
|
|
}
|
|
|
|
|
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 12:42:20 +00:00
|
|
|
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
|
|
|
|
|
// running and accepting connections, so metrics should be available immediately.
|
|
|
|
|
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
|
|
|
|
|
// not block metrics collection.
|
2026-02-09 10:55:46 +00:00
|
|
|
this.metricsAdapter.startPolling();
|
2025-03-25 22:30:57 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
|
2026-02-14 12:42:20 +00:00
|
|
|
|
|
|
|
|
// Handle certProvisionFunction (may be slow — runs after startup is complete)
|
|
|
|
|
await this.provisionCertificatesViaCallback(preloadedDomains);
|
2025-03-25 22:30:57 +00:00
|
|
|
}
|
|
|
|
|
|
2025-05-19 03:40:58 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Stop the proxy.
|
2025-05-19 03:40:58 +00:00
|
|
|
*/
|
2026-02-09 10:55:46 +00:00
|
|
|
public async stop(): Promise<void> {
|
|
|
|
|
logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' });
|
2026-02-09 16:25:33 +00:00
|
|
|
this.stopping = true;
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// Stop metrics polling
|
|
|
|
|
this.metricsAdapter.stopPolling();
|
|
|
|
|
|
2026-02-09 16:25:33 +00:00
|
|
|
// Remove exit listener before killing to avoid spurious error events
|
|
|
|
|
this.bridge.removeAllListeners('exit');
|
|
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// Stop Rust proxy
|
2025-05-20 15:44:48 +00:00
|
|
|
try {
|
2026-02-09 10:55:46 +00:00
|
|
|
await this.bridge.stopProxy();
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore if already stopped
|
2025-05-20 15:44:48 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
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' });
|
2025-05-19 03:40:58 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-05-10 00:01:02 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Update routes atomically.
|
2025-05-10 00:01:02 +00:00
|
|
|
*/
|
|
|
|
|
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
|
2025-05-19 03:40:58 +00:00
|
|
|
return this.routeUpdateLock.runExclusive(async () => {
|
2026-02-09 10:55:46 +00:00
|
|
|
// 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`);
|
2025-05-20 15:44:48 +00:00
|
|
|
}
|
2025-05-10 00:49:39 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
// 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')
|
2025-05-19 03:40:58 +00:00
|
|
|
);
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
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;
|
2025-05-19 03:40:58 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// 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' });
|
2025-05-19 03:40:58 +00:00
|
|
|
});
|
2025-05-10 00:01:02 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-03-25 22:30:57 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Provision a certificate for a named route.
|
2025-03-25 22:30:57 +00:00
|
|
|
*/
|
2025-05-18 15:38:07 +00:00
|
|
|
public async provisionCertificate(routeName: string): Promise<void> {
|
2026-02-09 10:55:46 +00:00
|
|
|
await this.bridge.provisionCertificate(routeName);
|
2025-05-18 15:38:07 +00:00
|
|
|
}
|
2025-05-20 15:32:19 +00:00
|
|
|
|
2025-05-18 15:38:07 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Force renewal of a certificate.
|
2025-05-18 15:38:07 +00:00
|
|
|
*/
|
|
|
|
|
public async renewCertificate(routeName: string): Promise<void> {
|
2026-02-09 10:55:46 +00:00
|
|
|
await this.bridge.renewCertificate(routeName);
|
2025-05-18 15:38:07 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-05-18 15:38:07 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get certificate status for a route (async - calls Rust).
|
2025-05-18 15:38:07 +00:00
|
|
|
*/
|
2026-02-09 10:55:46 +00:00
|
|
|
public async getCertificateStatus(routeName: string): Promise<any> {
|
|
|
|
|
return this.bridge.getCertificateStatus(routeName);
|
2025-03-25 22:30:57 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-06-09 15:02:36 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get the metrics interface.
|
2025-06-09 15:02:36 +00:00
|
|
|
*/
|
2025-06-22 22:28:37 +00:00
|
|
|
public getMetrics(): IMetrics {
|
2026-02-09 10:55:46 +00:00
|
|
|
return this.metricsAdapter;
|
2025-06-09 15:02:36 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-03-25 22:30:57 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get statistics (async - calls Rust).
|
2025-03-25 22:30:57 +00:00
|
|
|
*/
|
2026-02-09 10:55:46 +00:00
|
|
|
public async getStatistics(): Promise<any> {
|
|
|
|
|
return this.bridge.getStatistics();
|
2025-03-25 22:30:57 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-05-13 12:48:41 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Add a listening port at runtime.
|
2025-05-13 12:48:41 +00:00
|
|
|
*/
|
|
|
|
|
public async addListeningPort(port: number): Promise<void> {
|
2026-02-09 10:55:46 +00:00
|
|
|
await this.bridge.addListeningPort(port);
|
2025-05-13 12:48:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Remove a listening port at runtime.
|
2025-05-13 12:48:41 +00:00
|
|
|
*/
|
|
|
|
|
public async removeListeningPort(port: number): Promise<void> {
|
2026-02-09 10:55:46 +00:00
|
|
|
await this.bridge.removeListeningPort(port);
|
2025-05-13 12:48:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get all currently listening ports (async - calls Rust).
|
2025-05-13 12:48:41 +00:00
|
|
|
*/
|
2026-02-09 10:55:46 +00:00
|
|
|
public async getListeningPorts(): Promise<number[]> {
|
2026-02-09 16:25:33 +00:00
|
|
|
if (!this.bridge.running) return [];
|
2026-02-09 10:55:46 +00:00
|
|
|
return this.bridge.getListeningPorts();
|
2025-05-13 12:48:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-25 22:30:57 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get eligible domains for ACME certificates (sync - reads local routes).
|
2025-03-25 22:30:57 +00:00
|
|
|
*/
|
|
|
|
|
public getEligibleDomainsForCertificates(): string[] {
|
|
|
|
|
const domains: string[] = [];
|
2026-02-09 10:55:46 +00:00
|
|
|
for (const route of this.settings.routes || []) {
|
2025-05-10 00:01:02 +00:00
|
|
|
if (!route.match.domains) continue;
|
2026-02-09 10:55:46 +00:00
|
|
|
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);
|
2025-03-25 22:30:57 +00:00
|
|
|
}
|
|
|
|
|
return domains;
|
|
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2025-05-15 14:35:01 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Get NFTables status (async - calls Rust).
|
2025-05-15 14:35:01 +00:00
|
|
|
*/
|
|
|
|
|
public async getNfTablesStatus(): Promise<Record<string, any>> {
|
2026-02-09 10:55:46 +00:00
|
|
|
return this.bridge.getNftablesStatus();
|
2025-05-15 14:35:01 +00:00
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
// --- Private helpers ---
|
|
|
|
|
|
2025-05-18 18:29:59 +00:00
|
|
|
/**
|
2026-02-09 10:55:46 +00:00
|
|
|
* Build the Rust configuration object from TS settings.
|
2025-05-18 18:29:59 +00:00
|
|
|
*/
|
2026-02-13 13:08:30 +00:00
|
|
|
private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
|
|
|
|
|
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
|
2026-02-09 10:55:46 +00:00
|
|
|
return {
|
|
|
|
|
routes,
|
|
|
|
|
defaults: this.settings.defaults,
|
2026-02-13 13:08:30 +00:00
|
|
|
acme: acme
|
2026-02-09 10:55:46 +00:00
|
|
|
? {
|
2026-02-13 13:08:30 +00:00
|
|
|
enabled: acme.enabled,
|
|
|
|
|
email: acme.email,
|
|
|
|
|
useProduction: acme.useProduction,
|
|
|
|
|
port: acme.port,
|
|
|
|
|
renewThresholdDays: acme.renewThresholdDays,
|
|
|
|
|
autoRenew: acme.autoRenew,
|
|
|
|
|
renewCheckIntervalHours: acme.renewCheckIntervalHours,
|
2026-02-09 10:55:46 +00:00
|
|
|
}
|
|
|
|
|
: 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,
|
2026-02-13 23:18:22 +00:00
|
|
|
metrics: this.settings.metrics,
|
2026-02-09 10:55:46 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-02-13 16:32:02 +00:00
|
|
|
private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
|
2026-02-09 10:55:46 +00:00
|
|
|
const provisionFn = this.settings.certProvisionFunction;
|
|
|
|
|
if (!provisionFn) return;
|
|
|
|
|
|
2026-02-13 16:32:02 +00:00
|
|
|
const provisionedDomains = new Set<string>(skipDomains);
|
2026-02-13 13:08:30 +00:00
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
for (const route of this.settings.routes) {
|
|
|
|
|
if (route.action.tls?.certificate !== 'auto') continue;
|
|
|
|
|
if (!route.match.domains) continue;
|
|
|
|
|
|
2026-02-13 13:08:30 +00:00
|
|
|
const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
|
|
|
|
const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);
|
2026-02-09 10:55:46 +00:00
|
|
|
|
2026-02-13 13:08:30 +00:00
|
|
|
for (const domain of certDomains) {
|
|
|
|
|
if (provisionedDomains.has(domain)) continue;
|
|
|
|
|
provisionedDomains.add(domain);
|
2026-02-13 21:24:16 +00:00
|
|
|
|
|
|
|
|
// Build eventComms channel for this domain
|
|
|
|
|
let expiryDate: string | undefined;
|
|
|
|
|
let source = 'certProvisionFunction';
|
|
|
|
|
|
|
|
|
|
const eventComms: ICertProvisionEventComms = {
|
|
|
|
|
log: (msg) => logger.log('info', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
|
|
|
|
warn: (msg) => logger.log('warn', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
|
|
|
|
error: (msg) => logger.log('error', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
|
|
|
|
|
setExpiryDate: (date) => { expiryDate = date.toISOString(); },
|
|
|
|
|
setSource: (s) => { source = s; },
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
try {
|
2026-02-13 21:24:16 +00:00
|
|
|
const result: TSmartProxyCertProvisionObject = await provisionFn(domain, eventComms);
|
2026-02-09 10:55:46 +00:00
|
|
|
|
|
|
|
|
if (result === 'http01') {
|
2026-02-13 13:08:30 +00:00
|
|
|
// Callback wants HTTP-01 for this domain — trigger Rust ACME explicitly
|
|
|
|
|
if (route.name) {
|
|
|
|
|
try {
|
|
|
|
|
await this.bridge.provisionCertificate(route.name);
|
|
|
|
|
logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
|
|
|
|
|
} catch (provisionErr: any) {
|
2026-02-13 16:32:02 +00:00
|
|
|
logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
|
|
|
|
|
'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
|
2026-02-13 13:08:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
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' });
|
2026-02-13 16:32:02 +00:00
|
|
|
|
|
|
|
|
// Persist to consumer store
|
|
|
|
|
if (this.settings.certStore?.save) {
|
|
|
|
|
try {
|
|
|
|
|
await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey);
|
|
|
|
|
} catch (storeErr: any) {
|
|
|
|
|
logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 21:24:16 +00:00
|
|
|
|
|
|
|
|
// Emit certificate-issued event
|
|
|
|
|
this.emit('certificate-issued', {
|
|
|
|
|
domain,
|
|
|
|
|
expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
|
|
|
|
|
source,
|
|
|
|
|
} satisfies ICertificateIssuedEvent);
|
2026-02-09 10:55:46 +00:00
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
|
|
|
|
|
|
2026-02-13 21:24:16 +00:00
|
|
|
// Emit certificate-failed event
|
|
|
|
|
this.emit('certificate-failed', {
|
|
|
|
|
domain,
|
|
|
|
|
error: err.message,
|
|
|
|
|
source,
|
|
|
|
|
} satisfies ICertificateFailedEvent);
|
|
|
|
|
|
2026-02-13 16:32:02 +00:00
|
|
|
// Fallback to ACME if enabled and route has a name
|
|
|
|
|
if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
|
|
|
|
|
try {
|
|
|
|
|
await this.bridge.provisionCertificate(route.name);
|
|
|
|
|
logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
|
|
|
|
|
} catch (acmeErr: any) {
|
|
|
|
|
logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
|
|
|
|
|
(this.settings.disableDefaultCert
|
|
|
|
|
? ' — TLS will fail for this domain (disableDefaultCert is true)'
|
|
|
|
|
: ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
|
|
|
|
|
}
|
2026-02-09 10:55:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
2025-05-18 18:29:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-15 14:35:01 +00:00
|
|
|
|
2026-02-13 13:08:30 +00:00
|
|
|
/**
|
|
|
|
|
* Normalize routing glob patterns into valid domain identifiers for cert provisioning.
|
|
|
|
|
* - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']`
|
|
|
|
|
* - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard)
|
|
|
|
|
* - `code.foss.global` → `['code.foss.global']` (plain domain)
|
|
|
|
|
* - `*mid*.example.com` → skipped with warning (unsupported glob)
|
|
|
|
|
*/
|
|
|
|
|
private normalizeDomainsForCertProvisioning(rawDomains: string[]): string[] {
|
|
|
|
|
const result: string[] = [];
|
|
|
|
|
for (const raw of rawDomains) {
|
|
|
|
|
// Plain domain — no glob characters
|
|
|
|
|
if (!raw.includes('*')) {
|
|
|
|
|
result.push(raw);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Valid wildcard: *.example.com
|
|
|
|
|
if (raw.startsWith('*.') && !raw.slice(2).includes('*')) {
|
|
|
|
|
result.push(raw);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Routing glob like *example.com (leading star, no dot after it)
|
|
|
|
|
// Convert to bare domain + wildcard pair
|
|
|
|
|
if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) {
|
|
|
|
|
const baseDomain = raw.slice(1); // Remove leading *
|
|
|
|
|
result.push(baseDomain);
|
|
|
|
|
result.push(`*.${baseDomain}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unsupported glob pattern (e.g. *mid*.example.com)
|
|
|
|
|
logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' });
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:55:46 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|