@push.rocks/smartproxy
Version:
A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.
845 lines • 85.2 kB
JavaScript
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 { RoutePreprocessor } from './route-preprocessor.js';
import { SocketHandlerServer } from './socket-handler-server.js';
import { DatagramHandlerServer } from './datagram-handler-server.js';
import { ChallengeProviderRelayServer } from './challenge-provider-relay-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 { buildRustProxyOptions } from './utils/rust-config.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js';
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
/**
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
*
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security)
* 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 {
settings;
routeManager;
bridge;
preprocessor;
socketHandlerServer = null;
datagramHandlerServer = null;
challengeProviderRelayServer = null;
challengeProviders = new Map();
challengeRuntimeOptions;
metricsAdapter;
nftablesManager = null;
routeUpdateLock;
stopping = false;
certProvisionPromise = null;
constructor(settingsArg) {
super();
// Apply defaults
this.settings = {
...settingsArg,
initialDataTimeout: settingsArg.initialDataTimeout || 60_000,
socketTimeout: settingsArg.socketTimeout || 60_000,
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3_600_000,
inactivityTimeout: settingsArg.inactivityTimeout || 75_000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30_000,
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
keepAliveTreatment: settingsArg.keepAliveTreatment || 'standard',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 4,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 3_600_000,
};
// 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,
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, data) => logger.log('debug', message, data),
info: (message, data) => logger.log('info', message, data),
warn: (message, data) => logger.log('warn', message, data),
error: (message, data) => 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.settings.metrics?.sampleIntervalMs ?? 1000);
this.routeUpdateLock = new Mutex();
}
/**
* Register a runtime challenge provider family. Routes reference providerId + challengeType;
* deployment wiring and provider secrets stay outside route configs.
*/
registerChallengeProvider(providerId, provider) {
if (!providerId || typeof providerId !== 'string') {
throw new Error('Challenge providerId must be a non-empty string');
}
this.challengeProviders.set(providerId, provider);
}
/**
* Start the proxy.
* Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning.
*/
async start() {
await this.validateChallengeRoutes(this.settings.routes);
let didSpawn = false;
try {
// 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: pnpm build');
}
didSpawn = true;
// Handle unexpected exit (only emits error if not intentionally stopping)
this.bridge.removeAllListeners('exit');
this.bridge.on('exit', (code, signal) => {
if (this.stopping)
return;
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
});
const hasChallengeRoutes = this.hasChallengeRoutes(this.settings.routes);
if (hasChallengeRoutes) {
await this.ensureChallengeProviderRelay();
}
// Check if any routes need TS-side handling (socket handlers, dynamic functions)
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'));
// Start socket handler relay server (but don't tell Rust yet - proxy not started)
if (hasHandlerRoutes) {
this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
await this.socketHandlerServer.start();
}
// Check if any routes need datagram handler relay (UDP socket-handler routes)
const hasDatagramHandlers = this.settings.routes.some((r) => r.action.type === 'socket-handler' && r.action.datagramHandler);
if (hasDatagramHandlers) {
const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
await this.datagramHandlerServer.start();
}
// Preprocess routes (strip JS functions, convert socket-handler routes)
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
// 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' });
}
// Build Rust config
const config = this.buildRustConfig(rustRoutes, acmeForRust);
// Start the Rust proxy
await this.bridge.startProxy(config);
// Now that Rust proxy is running, configure socket handler relay
if (this.socketHandlerServer) {
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
}
// Configure challenge provider relay. The path is also present in startup config,
// but this hot setter keeps runtime changes explicit and mirrors other relays.
if (this.challengeProviderRelayServer) {
await this.bridge.setChallengeProviderRelay(this.challengeProviderRelayServer.getSocketPath(), this.challengeRuntimeOptions);
}
// Configure datagram handler relay
if (this.datagramHandlerServer) {
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
}
// 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) {
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
}
}
// Load consumer-stored certificates
const preloadedDomains = new Set();
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) {
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
}
}
// Apply NFTables rules for routes using nftables forwarding engine
await this.applyNftablesRules(this.settings.routes);
// 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.
this.metricsAdapter.startPolling();
logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
// Fire-and-forget cert provisioning — Rust engine is already running and serving traffic.
// Events (certificate-issued / certificate-failed) fire independently per domain.
this.certProvisionPromise = this.provisionCertificatesViaCallback(preloadedDomains)
.catch((err) => logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' }));
}
catch (err) {
await this.cleanupRuntimeResourcesAfterStartFailure(didSpawn);
throw err;
}
}
/**
* Stop the proxy.
*/
async stop() {
logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' });
this.stopping = true;
// Wait for in-flight cert provisioning to bail out (it checks this.stopping)
if (this.certProvisionPromise) {
await this.certProvisionPromise;
this.certProvisionPromise = null;
}
// Clean up NFTables rules
if (this.nftablesManager) {
await this.nftablesManager.cleanup();
this.nftablesManager = null;
}
// Stop metrics polling
this.metricsAdapter.stopPolling();
// Remove exit listener before killing to avoid spurious error events
this.bridge.removeAllListeners('exit');
// 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;
}
// Stop datagram handler relay
if (this.datagramHandlerServer) {
await this.datagramHandlerServer.stop();
this.datagramHandlerServer = null;
}
// Stop challenge provider relay
if (this.challengeProviderRelayServer) {
await this.challengeProviderRelayServer.stop();
this.challengeProviderRelayServer = null;
}
this.challengeRuntimeOptions = undefined;
logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
}
/**
* Update routes atomically.
*/
async updateRoutes(newRoutes) {
await this.routeUpdateLock.runExclusive(async () => {
await this.validateChallengeRoutes(newRoutes);
// Validate before starting relays so failed route updates do not leak runtime resources.
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`);
}
const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes);
const challengeRelayWasStarted = Boolean(this.challengeProviderRelayServer);
// Preprocess for Rust
const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);
try {
if (hasChallengeRoutes) {
await this.ensureChallengeProviderRelay();
await this.bridge.setChallengeProviderRelay(this.challengeProviderRelayServer.getSocketPath(), this.challengeRuntimeOptions);
}
// Send to Rust
await this.bridge.updateRoutes(rustRoutes);
}
catch (err) {
if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) {
await this.bridge.setChallengeProviderRelay('').catch(() => undefined);
await this.challengeProviderRelayServer.stop();
this.challengeProviderRelayServer = null;
this.challengeRuntimeOptions = undefined;
}
throw err;
}
// 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 datagram handler relay if datagram handler routes changed
const hasDatagramHandlers = newRoutes.some((r) => r.action.type === 'socket-handler' && r.action.datagramHandler);
if (hasDatagramHandlers && !this.datagramHandlerServer) {
const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
await this.datagramHandlerServer.start();
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
}
else if (!hasDatagramHandlers && this.datagramHandlerServer) {
await this.datagramHandlerServer.stop();
this.datagramHandlerServer = null;
}
if (!hasChallengeRoutes && this.challengeProviderRelayServer) {
await this.bridge.setChallengeProviderRelay('').catch((err) => {
logger.log('warn', `Failed to clear challenge provider relay in Rust: ${err.message}`, { component: 'smart-proxy' });
});
await this.challengeProviderRelayServer.stop();
this.challengeProviderRelayServer = null;
this.challengeRuntimeOptions = undefined;
}
// Update NFTables rules
if (this.nftablesManager) {
await this.nftablesManager.cleanup();
this.nftablesManager = null;
}
await this.applyNftablesRules(newRoutes);
// Update stored routes
this.settings.routes = newRoutes;
logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' });
});
// Fire-and-forget cert provisioning outside the mutex — routes are already updated,
// cert provisioning doesn't need the route update lock and may be slow.
this.certProvisionPromise = this.provisionCertificatesViaCallback()
.catch((err) => logger.log('error', `Unexpected error in cert provisioning after route update: ${err.message}`, { component: 'smart-proxy' }));
}
/**
* Update the global ingress security policy without changing routes.
* The Rust engine applies this before route selection and backend connection.
*/
async updateSecurityPolicy(policy) {
this.settings.securityPolicy = policy;
await this.bridge.setSecurityPolicy(policy);
}
/**
* Provision a certificate for a named route.
*/
async provisionCertificate(routeName) {
await this.bridge.provisionCertificate(routeName);
}
/**
* Force renewal of a certificate.
*/
async renewCertificate(routeName) {
await this.bridge.renewCertificate(routeName);
}
/**
* Get certificate status for a route (async - calls Rust).
*/
async getCertificateStatus(routeName) {
return this.bridge.getCertificateStatus(routeName);
}
/**
* Get the metrics interface.
*/
getMetrics() {
return this.metricsAdapter;
}
/**
* Get sanitized active connection snapshots from the Rust engine.
*/
async getActiveConnectionSnapshots(options = {}) {
return this.bridge.getActiveConnectionSnapshots(options);
}
/**
* Get statistics (async - calls Rust).
*/
async getStatistics() {
return this.bridge.getStatistics();
}
/**
* Add a listening port at runtime.
*/
async addListeningPort(port) {
await this.bridge.addListeningPort(port);
}
/**
* Remove a listening port at runtime.
*/
async removeListeningPort(port) {
await this.bridge.removeListeningPort(port);
}
/**
* Get all currently listening ports (async - calls Rust).
*/
async getListeningPorts() {
if (!this.bridge.running)
return [];
return this.bridge.getListeningPorts();
}
/**
* Get eligible domains for ACME certificates (sync - reads local routes).
*/
getEligibleDomainsForCertificates() {
const domains = [];
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.
*/
getNfTablesStatus() {
return this.nftablesManager?.status() ?? null;
}
// --- Private helpers ---
async cleanupRuntimeResourcesAfterStartFailure(didSpawn) {
this.metricsAdapter.stopPolling();
if (this.nftablesManager) {
await this.nftablesManager.cleanup().catch((err) => {
logger.log('warn', `Failed to clean NFTables after start failure: ${err.message}`, { component: 'smart-proxy' });
});
this.nftablesManager = null;
}
if (this.socketHandlerServer) {
await this.socketHandlerServer.stop().catch((err) => {
logger.log('warn', `Failed to stop socket handler relay after start failure: ${err.message}`, { component: 'smart-proxy' });
});
this.socketHandlerServer = null;
}
if (this.datagramHandlerServer) {
await this.datagramHandlerServer.stop().catch((err) => {
logger.log('warn', `Failed to stop datagram handler relay after start failure: ${err.message}`, { component: 'smart-proxy' });
});
this.datagramHandlerServer = null;
}
if (this.challengeProviderRelayServer) {
await this.challengeProviderRelayServer.stop().catch((err) => {
logger.log('warn', `Failed to stop challenge provider relay after start failure: ${err.message}`, { component: 'smart-proxy' });
});
this.challengeProviderRelayServer = null;
this.challengeRuntimeOptions = undefined;
}
if (didSpawn) {
this.bridge.removeAllListeners('exit');
await this.bridge.stopProxy().catch(() => undefined);
this.bridge.kill();
}
}
/**
* Apply NFTables rules for routes using the nftables forwarding engine.
*/
async applyNftablesRules(routes) {
const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
if (nftRoutes.length === 0)
return;
const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
const nft = new plugins.smartnftables.SmartNftables({ tableName });
await nft.initialize();
for (const route of nftRoutes) {
const routeId = route.name || 'unnamed';
const targets = route.action.targets;
if (!targets)
continue;
const nftOpts = route.action.nftables;
const protocol = nftOpts?.protocol ?? 'tcp';
const preserveSourceIP = nftOpts?.preserveSourceIP ?? false;
const ports = Array.isArray(route.match.ports)
? route.match.ports.flatMap(p => typeof p === 'number' ? [p] : [])
: typeof route.match.ports === 'number' ? [route.match.ports] : [];
for (const target of targets) {
const targetHost = Array.isArray(target.host) ? target.host[0] : target.host;
if (typeof targetHost !== 'string')
continue;
for (const sourcePort of ports) {
const targetPort = typeof target.port === 'number' ? target.port : sourcePort;
await nft.nat.addPortForwarding(`${routeId}-${sourcePort}-${targetPort}`, {
sourcePort,
targetHost,
targetPort,
protocol,
preserveSourceIP,
});
}
}
}
this.nftablesManager = nft;
logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
}
/**
* Build the Rust configuration object from TS settings.
*/
buildRustConfig(routes, acmeOverride) {
return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions);
}
hasChallengeRoutes(routes) {
return routes.some((route) => Boolean(route.security?.challenge));
}
async ensureChallengeProviderRelay() {
const runtimeOptions = this.settings.challenge || {};
if (!this.challengeProviderRelayServer) {
this.challengeProviderRelayServer = new ChallengeProviderRelayServer(this.challengeProviders, {
providerTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
});
await this.challengeProviderRelayServer.start();
}
if (!this.challengeRuntimeOptions) {
this.challengeRuntimeOptions = {
cookieSigningKey: runtimeOptions.cookieSigningKey || plugins.crypto.randomBytes(32).toString('base64url'),
pendingCookieName: runtimeOptions.pendingCookieName || '__smartproxy_challenge_pending',
clearanceCookieName: runtimeOptions.clearanceCookieName || '__smartproxy_clearance',
reservedPathPrefix: runtimeOptions.reservedPathPrefix || '/.well-known/smartproxy-challenge',
relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
relayTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
pendingTtlSeconds: runtimeOptions.pendingTtlSeconds ?? 300,
};
}
else {
this.challengeRuntimeOptions = {
...this.challengeRuntimeOptions,
relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
};
}
}
async validateChallengeRoutes(routes) {
const manifestCache = new Map();
const errors = [];
for (const route of routes) {
const challenge = route.security?.challenge;
if (!challenge)
continue;
const routeName = route.name || route.id || 'unnamed route';
if (!challenge.providerId || typeof challenge.providerId !== 'string') {
errors.push(`${routeName}: challenge.providerId must be a non-empty string`);
}
if (!challenge.challengeType || typeof challenge.challengeType !== 'string') {
errors.push(`${routeName}: challenge.challengeType must be a non-empty string`);
}
if (!route.name && !route.id) {
errors.push(`${routeName}: challenge routes must set a stable route name or id`);
}
this.validateChallengeIntentShape(challenge, errors, routeName);
if (route.action.type !== 'forward') {
errors.push(`${routeName}: challenge routes must use forward actions`);
}
if (route.action.forwardingEngine === 'nftables') {
errors.push(`${routeName}: challenge routes cannot use nftables forwarding`);
}
if (route.action.tls?.mode === 'passthrough') {
errors.push(`${routeName}: challenge routes cannot use TLS passthrough`);
}
for (const target of route.action.targets || []) {
if (typeof target.host === 'function' || typeof target.port === 'function') {
errors.push(`${routeName}: challenge routes cannot use dynamic target host or port functions`);
}
if (target.tls?.mode === 'passthrough') {
errors.push(`${routeName}: challenge routes cannot use target TLS passthrough`);
}
}
if (route.match.transport === 'udp' || route.match.transport === 'all') {
errors.push(`${routeName}: challenge routes currently require HTTP-visible TCP handling`);
}
if (route.match.protocol !== 'http') {
errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`);
}
this.collectForbiddenChallengeKeys(challenge, `security.challenge`, errors, routeName);
const provider = this.challengeProviders.get(challenge.providerId);
if (!provider) {
errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
continue;
}
let manifest = manifestCache.get(challenge.providerId);
if (!manifest) {
manifest = await provider.getManifest();
manifestCache.set(challenge.providerId, manifest);
}
if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
}
}
if (errors.length > 0) {
throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
}
validateChallengeIntentShape(challengeArg, errors, routeName) {
if (!this.isPlainRecord(challengeArg)) {
errors.push(`${routeName}: security.challenge must be an object`);
return;
}
this.validateAllowedChallengeKeys(challengeArg, 'security.challenge', new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']), errors, routeName);
const settings = challengeArg.settings;
if (settings !== undefined && !this.isPlainRecord(settings)) {
errors.push(`${routeName}: security.challenge.settings must be an object when set`);
}
const applyTo = challengeArg.applyTo;
if (applyTo !== undefined) {
if (!this.isPlainRecord(applyTo)) {
errors.push(`${routeName}: security.challenge.applyTo must be an object when set`);
}
else {
this.validateAllowedChallengeKeys(applyTo, 'security.challenge.applyTo', new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']), errors, routeName);
}
}
const clearance = challengeArg.clearance;
if (clearance !== undefined) {
if (!this.isPlainRecord(clearance)) {
errors.push(`${routeName}: security.challenge.clearance must be an object when set`);
}
else {
this.validateAllowedChallengeKeys(clearance, 'security.challenge.clearance', new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']), errors, routeName);
}
}
}
validateAllowedChallengeKeys(valueArg, pathArg, allowedKeysArg, errors, routeName) {
for (const key of Object.keys(valueArg)) {
if (!allowedKeysArg.has(key)) {
errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
}
}
}
isPlainRecord(valueArg) {
return Boolean(valueArg)
&& typeof valueArg === 'object'
&& !Array.isArray(valueArg);
}
collectForbiddenChallengeKeys(value, path, errors, routeName) {
if (!value || typeof value !== 'object')
return;
const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
for (const [key, nestedValue] of Object.entries(value)) {
const childPath = `${path}.${key}`;
if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
}
if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
}
if (nestedValue && typeof nestedValue === 'object') {
this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName);
}
}
}
looksLikeRuntimeChallengeValue(valueArg) {
return /^https?:\/\//i.test(valueArg)
|| /^wss?:\/\//i.test(valueArg)
|| /(?:^|\b)(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\b|$)/.test(valueArg)
|| /^[a-z0-9.-]+:\d+$/i.test(valueArg);
}
/**
* 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.
*/
async provisionCertificatesViaCallback(skipDomains = new Set()) {
const provisionFn = this.settings.certProvisionFunction;
if (!provisionFn)
return;
// Phase 1: Collect all unique (domain, route) pairs that need provisioning
const seen = new Set(skipDomains);
const tasks = [];
for (const route of this.settings.routes) {
if (route.action.tls?.certificate !== 'auto')
continue;
if (!route.match.domains)
continue;
const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);
for (const domain of certDomains) {
if (seen.has(domain))
continue;
seen.add(domain);
tasks.push({ domain, route });
}
}
if (tasks.length === 0)
return;
// Phase 2: Process all domains in parallel with concurrency limit
const concurrency = this.settings.certProvisionConcurrency ?? 4;
const semaphore = new ConcurrencySemaphore(concurrency);
const promises = tasks.map(async ({ domain, route }) => {
await semaphore.acquire();
try {
await this.provisionSingleDomain(domain, route, provisionFn);
}
finally {
semaphore.release();
}
});
await Promise.allSettled(promises);
}
/**
* Provision a single domain's certificate via the callback.
* Includes per-domain timeout and shutdown checks.
*/
async provisionSingleDomain(domain, route, provisionFn) {
if (this.stopping)
return;
let expiryDate;
let source = 'certProvisionFunction';
const eventComms = {
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; },
};
const timeoutMs = this.settings.certProvisionTimeout ?? 300_000; // 5 min default
try {
const result = await this.withTimeout(provisionFn(domain, eventComms), timeoutMs, `Certificate provisioning timed out for ${domain} after ${timeoutMs}ms`);
if (this.stopping)
return;
if (result === 'http01') {
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) {
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' });
}
}
return;
}
if (result && typeof result === 'object') {
if (this.stopping)
return;
const certObj = result;
await this.bridge.loadCertificate(domain, certObj.publicKey, certObj.privateKey, certObj.ca);
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
// Persist to consumer store
if (this.settings.certStore?.save) {
try {
await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey, certObj.ca);
}
catch (storeErr) {
logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
}
}
this.emit('certificate-issued', {
domain,
expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
source,
});
}
}
catch (err) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
this.emit('certificate-failed', {
domain,
error: err.message,
source,
});
// 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) {
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' });
}
}
}
}
/**
* Race a promise against a timeout. Rejects with the given message if the timeout fires first.
*/
withTimeout(promise, ms, message) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); });
});
}
/**
* 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)
*/
normalizeDomainsForCertProvisioning(rawDomains) {
const result = [];
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;
}
isValidDomain(domain) {
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);
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnQtcHJveHkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9wcm94aWVzL3NtYXJ0LXByb3h5L3NtYXJ0LXByb3h5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLDRCQUE0QixDQUFDO0FBRXBELDBCQUEwQjtBQUMxQixPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFDekQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFDakUsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDckUsT0FBTyxFQUFFLDRCQUE0QixFQUFFLE1BQU0sc0NBQXNDLENBQUM7QUFDcEYsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFFL0QsbUJBQW1CO0FBQ25CLE9BQU8sRUFBRSxrQkFBa0IsSUFBSSxZQUFZLEVBQUUsTUFBTSxxQ0FBcUMsQ0FBQztBQUN6RixPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFDNUQsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFDL0QsT0FBTyxFQUFFLDBCQUEwQixFQUFFLE1BQU0sbUNBQW1DLENBQUM7QUFDL0UsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sRUFBRSxvQkFBb0IsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBVXhFOzs7Ozs7Ozs7R0FTRztBQUNILE1BQU0sT0FBTyxVQUFXLFNBQVEsT0FBTyxDQUFDLFlBQVk7SUFDM0MsUUFBUSxDQUFxQjtJQUM3QixZQUFZLENBQWU7SUFFMUIsTUFBTSxDQUFrQjtJQUN4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