import * as plugins from './plugins.js'; import { EventEmitter } from 'events'; // Command map for the hub side of remoteingress-bin type THubCommands = { ping: { params: Record; result: { pong: boolean }; }; startHub: { params: { tunnelPort: number; targetHost?: string; tlsCertPem?: string; tlsKeyPem?: string; }; result: { started: boolean }; }; stopHub: { params: Record; result: { stopped: boolean; wasRunning?: boolean }; }; updateAllowedEdges: { params: { edges: Array<{ id: string; secret: string; listenPorts?: number[]; listenPortsUdp?: number[]; stunIntervalSecs?: number }>; }; result: { updated: boolean }; }; getHubStatus: { params: Record; result: { running: boolean; tunnelPort: number; connectedEdges: Array<{ edgeId: string; connectedAt: number; activeStreams: number; peerAddr: string; }>; }; }; }; export interface IHubConfig { tunnelPort?: number; targetHost?: string; tls?: { certPem?: string; keyPem?: string; }; } type TAllowedEdge = { id: string; secret: string; listenPorts?: number[]; listenPortsUdp?: number[]; stunIntervalSecs?: number }; const MAX_RESTART_ATTEMPTS = 10; const MAX_RESTART_BACKOFF_MS = 30_000; export class RemoteIngressHub extends EventEmitter { private bridge: InstanceType>; private started = false; private stopping = false; private savedConfig: IHubConfig | null = null; private savedEdges: TAllowedEdge[] = []; private restartBackoffMs = 1000; private restartAttempts = 0; constructor() { super(); const packageDir = plugins.path.resolve( plugins.path.dirname(new URL(import.meta.url).pathname), '..', ); this.bridge = new plugins.smartrust.RustBridge({ binaryName: 'remoteingress-bin', cliArgs: ['--management'], requestTimeoutMs: 30_000, readyTimeoutMs: 10_000, localPaths: [ // Platform-suffixed binary in dist_rust (production) plugins.path.join(packageDir, 'dist_rust', `remoteingress-bin_${process.platform === 'win32' ? 'windows' : 'linux'}_${process.arch === 'x64' ? 'amd64' : process.arch}`), // Exact binaryName fallback in dist_rust plugins.path.join(packageDir, 'dist_rust', 'remoteingress-bin'), // Development build paths (cargo output uses exact name) plugins.path.join(packageDir, 'rust', 'target', 'release', 'remoteingress-bin'), plugins.path.join(packageDir, 'rust', 'target', 'debug', 'remoteingress-bin'), ], searchSystemPath: false, logger: { log: (level: string, message: string) => { if (level === 'error') { console.error(`[RemoteIngressHub] ${message}`); } else { console.log(`[RemoteIngressHub] ${message}`); } }, }, }); // Forward events from Rust binary this.bridge.on('management:edgeConnected', (data: { edgeId: string; peerAddr: string }) => { this.emit('edgeConnected', data); }); this.bridge.on('management:edgeDisconnected', (data: { edgeId: string; reason?: string }) => { const reason = data?.reason ?? 'unknown'; console.log(`[RemoteIngressHub] Edge ${data.edgeId} disconnected: ${reason}`); this.emit('edgeDisconnected', data); }); this.bridge.on('management:streamOpened', (data: { edgeId: string; streamId: number }) => { this.emit('streamOpened', data); }); this.bridge.on('management:streamClosed', (data: { edgeId: string; streamId: number }) => { this.emit('streamClosed', data); }); } /** * Start the hub — spawns the Rust binary and starts the tunnel server. */ public async start(config: IHubConfig = {}): Promise { this.savedConfig = config; this.stopping = false; const spawned = await this.bridge.spawn(); if (!spawned) { throw new Error('Failed to spawn remoteingress-bin'); } // Register crash recovery handler this.bridge.on('exit', this.handleCrashRecovery); await this.bridge.sendCommand('startHub', { tunnelPort: config.tunnelPort ?? 8443, targetHost: config.targetHost ?? '127.0.0.1', ...(config.tls?.certPem && config.tls?.keyPem ? { tlsCertPem: config.tls.certPem, tlsKeyPem: config.tls.keyPem } : {}), }); this.started = true; this.restartAttempts = 0; this.restartBackoffMs = 1000; } /** * Stop the hub and kill the Rust process. */ public async stop(): Promise { this.stopping = true; if (this.started) { try { await this.bridge.sendCommand('stopHub', {} as Record); } catch { // Process may already be dead } this.bridge.removeListener('exit', this.handleCrashRecovery); this.bridge.kill(); this.started = false; } } /** * Update the list of allowed edges that can connect to this hub. */ public async updateAllowedEdges(edges: TAllowedEdge[]): Promise { this.savedEdges = edges; await this.bridge.sendCommand('updateAllowedEdges', { edges }); } /** * Get the current hub status. */ public async getStatus() { return this.bridge.sendCommand('getHubStatus', {} as Record); } /** * Check if the bridge is running. */ public get running(): boolean { return this.bridge.running; } /** * Handle unexpected Rust binary crash — auto-restart with backoff. */ private handleCrashRecovery = async (code: number | null, signal: string | null) => { if (this.stopping || !this.started || !this.savedConfig) { return; } console.error( `[RemoteIngressHub] Rust binary crashed (code=${code}, signal=${signal}), ` + `attempt ${this.restartAttempts + 1}/${MAX_RESTART_ATTEMPTS}` ); this.started = false; if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) { console.error('[RemoteIngressHub] Max restart attempts reached, giving up'); this.emit('crashRecoveryFailed'); return; } await new Promise(resolve => setTimeout(resolve, this.restartBackoffMs)); this.restartBackoffMs = Math.min(this.restartBackoffMs * 2, MAX_RESTART_BACKOFF_MS); this.restartAttempts++; try { const spawned = await this.bridge.spawn(); if (!spawned) { console.error('[RemoteIngressHub] Failed to respawn binary'); return; } this.bridge.on('exit', this.handleCrashRecovery); const config = this.savedConfig; await this.bridge.sendCommand('startHub', { tunnelPort: config.tunnelPort ?? 8443, targetHost: config.targetHost ?? '127.0.0.1', ...(config.tls?.certPem && config.tls?.keyPem ? { tlsCertPem: config.tls.certPem, tlsKeyPem: config.tls.keyPem } : {}), }); // Restore allowed edges if (this.savedEdges.length > 0) { await this.bridge.sendCommand('updateAllowedEdges', { edges: this.savedEdges }); } this.started = true; this.restartAttempts = 0; this.restartBackoffMs = 1000; console.log('[RemoteIngressHub] Successfully recovered from crash'); this.emit('crashRecovered'); } catch (err) { console.error(`[RemoteIngressHub] Crash recovery failed: ${err}`); } }; }