/** * Port Forwarding Service * Handles communication between the frontend and the Electron backend * for establishing and managing SSH port forwarding tunnels. */ import { Host, Identity, KnownHost, PortForwardingRule, SSHKey, TerminalSettings } from '../../domain/models'; import { isPortForwardingRuleStartable, isPortForwardingRuntimeBusy, selectStartablePortForwardingRules, selectStoppablePortForwardingRules, } from '../../domain/portForwardingBulkActions'; import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials'; import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth'; import { resolveHostKeepalive } from '../../domain/host'; import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts'; import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, formatIncompleteProxyIdentityMessage, formatMissingProxyIdentityMessage, hasUnreadableProxyCredential, hasUsableProxyConfig, resolveProxyConfigAuth, } from '../../domain/proxyProfiles'; // Fallback matching DEFAULT_TERMINAL_SETTINGS so older call sites that don't // thread terminalSettings still get the cloud-friendly defaults. const FALLBACK_TERMINAL_SETTINGS = { verifyHostKeys: true, keepaliveInterval: 30, keepaliveCountMax: 10, }; import { logger } from '../../lib/logger'; import { localStorageAdapter } from '../persistence/localStorageAdapter'; import { STORAGE_KEY_PF_RECONNECT_CANCEL } from '../config/storageKeys'; import { netcattyBridge } from './netcattyBridge'; export interface PortForwardingConnection { ruleId: string; tunnelId: string; status: 'inactive' | 'connecting' | 'active' | 'error'; error?: string; unsubscribe?: () => void; locallyInitiated?: boolean; // Reconnect state reconnectAttempts?: number; reconnectTimeoutId?: ReturnType; reconnectDueAt?: number; reconnectTimerCallback?: () => void; reconnectStartAuthorized?: boolean; reconnectSuppressed?: boolean; syncedShouldReconnect?: () => boolean; syncedOnStatusChange?: ( status: PortForwardingRule['status'], error?: string, ) => void; } // Map to track active connections const activeConnections = new Map(); // Rules are added only when their bounded automatic reconnect cycle is // exhausted. This distinguishes a local-outage failure from a tunnel that the // user intentionally stopped while leaving auto-start enabled for next launch. const exhaustedReconnectRules = new Set(); const rulesPendingCleanup = new Set(); const manualStopsInProgress = new Set(); const stopAllInProgress = new Set(); const ruleCleanupPromises = new Map>(); const deferredReconnects = new Map void; }>(); // Reconnect configuration const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAY_MS = 3000; // 3 seconds between reconnection attempts // Callbacks for auto-reconnect - will be set by the state hook let reconnectCallback: (( ruleId: string, onStatusChange: (status: PortForwardingRule['status'], error?: string) => void ) => Promise<{ success: boolean; error?: string }>) | null = null; let shouldReconnectRule: ((ruleId: string) => boolean) | undefined; /** * Set the reconnect callback (called by state hook to enable auto-reconnect) */ export const setReconnectCallback = ( callback: typeof reconnectCallback, shouldReconnect?: (ruleId: string) => boolean, ): void => { reconnectCallback = callback; shouldReconnectRule = callback ? shouldReconnect : undefined; }; /** * Clear any pending reconnect for a rule */ export const clearReconnectTimer = (ruleId: string): void => { const conn = activeConnections.get(ruleId); if (conn?.reconnectTimeoutId) { clearTimeout(conn.reconnectTimeoutId); } if (conn) { conn.reconnectTimeoutId = undefined; conn.reconnectDueAt = undefined; conn.reconnectTimerCallback = undefined; } }; /** * Give a failed/retrying auto-start rule a fresh bounded retry cycle after * local network recovery. Returns false for inactive rules that were stopped * manually and therefore must stay stopped until the next app launch. */ export const resetReconnectAttempts = (ruleId: string): boolean => { const connection = activeConnections.get(ruleId); const shouldRecover = exhaustedReconnectRules.has(ruleId) || (connection?.reconnectAttempts ?? 0) > 0; if (connection) connection.reconnectAttempts = 0; return shouldRecover; }; /** Re-check recovery eligibility after asynchronous backend reconciliation. */ export const isReconnectRecoveryEligible = (ruleId: string): boolean => { return exhaustedReconnectRules.has(ruleId); }; interface PausedReconnectTimer { connection: PortForwardingConnection; callback: () => void; remainingMs: number; } const pauseReconnectTimer = (ruleId: string): PausedReconnectTimer | undefined => { const connection = activeConnections.get(ruleId); if (!connection?.reconnectTimeoutId || !connection.reconnectTimerCallback) return undefined; clearTimeout(connection.reconnectTimeoutId); const paused = { connection, callback: connection.reconnectTimerCallback, remainingMs: Math.max(0, (connection.reconnectDueAt ?? Date.now()) - Date.now()), }; connection.reconnectTimeoutId = undefined; connection.reconnectDueAt = undefined; connection.reconnectTimerCallback = undefined; return paused; }; const restoreReconnectTimer = (ruleId: string, paused?: PausedReconnectTimer): void => { if (!paused) return; const connection = activeConnections.get(ruleId); if (connection !== paused.connection || connection.reconnectTimeoutId) return; connection.reconnectDueAt = Date.now() + paused.remainingMs; connection.reconnectTimerCallback = paused.callback; connection.reconnectTimeoutId = setTimeout(paused.callback, paused.remainingMs); }; // Cross-window reconnect cancellation via localStorage broadcast. // When one window deletes/replaces a rule, it writes to this key so // other windows (with pending reconnect timers) can cancel them. const broadcastReconnectCancel = (ruleId: string): void => { try { // Write then immediately remove so the storage event fires on // other windows without leaving stale data. localStorageAdapter.writeString(STORAGE_KEY_PF_RECONNECT_CANCEL, ruleId); localStorageAdapter.remove(STORAGE_KEY_PF_RECONNECT_CANCEL); } catch { // localStorage may be unavailable in some contexts } }; /** * Start listening for cross-window reconnect cancellation events. * Should be called once at app init (e.g. in the port-forwarding state hook). * Returns a cleanup function. */ export const initReconnectCancelListener = (): (() => void) => { const handler = (e: StorageEvent) => { if (e.key !== STORAGE_KEY_PF_RECONNECT_CANCEL || !e.newValue) return; const ruleId = e.newValue; exhaustedReconnectRules.delete(ruleId); clearReconnectTimer(ruleId); const conn = activeConnections.get(ruleId); if (conn) { conn.unsubscribe?.(); activeConnections.delete(ruleId); } // Also ask the backend to stop any tunnel for this rule. // This catches tunnels still in SSH handshake that aren't yet // in the renderer's activeConnections or the backend's list output. const bridge = netcattyBridge.get(); if (bridge?.stopPortForwardByRuleId) { bridge.stopPortForwardByRuleId(ruleId).catch((err: unknown) => { logger.warn(`[PortForwardingService] Cross-window stopByRuleId failed for ${ruleId}:`, err); }); } }; window.addEventListener('storage', handler); return () => window.removeEventListener('storage', handler); }; /** * Helper function to schedule a reconnection attempt * Returns true if a reconnect was scheduled, false otherwise */ const scheduleReconnectIfNeeded = ( ruleId: string, enableReconnect: boolean, onStatusChange: (status: PortForwardingRule['status'], error?: string) => void, ): boolean => { if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) { return false; } if (rulesPendingCleanup.has(ruleId)) { deferredReconnects.set(ruleId, { enableReconnect, onStatusChange }); return true; } const currentConn = activeConnections.get(ruleId); if (currentConn?.reconnectSuppressed) return false; if (currentConn?.reconnectTimerCallback) { currentConn.status = 'connecting'; currentConn.error = `Reconnecting (${currentConn.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`; return true; } const attempts = (currentConn?.reconnectAttempts ?? 0) + 1; if (attempts <= MAX_RECONNECT_ATTEMPTS) { // If the activeConnections entry was already deleted (e.g. by // stopAndCleanupRule while the handshake was in-flight), we // can't actually schedule a reconnect. Return false so the // caller transitions to 'inactive' instead of stuck 'connecting'. if (!currentConn) { return false; } logger.info(`[PortForwardingService] Scheduling reconnect ${attempts}/${MAX_RECONNECT_ATTEMPTS}`); currentConn.reconnectAttempts = attempts; exhaustedReconnectRules.delete(ruleId); const runReconnect = () => { if (currentConn.reconnectTimerCallback !== runReconnect) return; currentConn.reconnectTimeoutId = undefined; currentConn.reconnectDueAt = undefined; currentConn.reconnectTimerCallback = undefined; if (activeConnections.get(ruleId) !== currentConn) return; if (shouldReconnectRule?.(ruleId) === false) { currentConn.unsubscribe?.(); activeConnections.delete(ruleId); onStatusChange('inactive'); return; } if (reconnectCallback) { currentConn.reconnectStartAuthorized = true; reconnectCallback(ruleId, onStatusChange); } }; currentConn.reconnectDueAt = Date.now() + RECONNECT_DELAY_MS; currentConn.reconnectTimerCallback = runReconnect; currentConn.reconnectTimeoutId = setTimeout(runReconnect, RECONNECT_DELAY_MS); const reconnectMessage = `Reconnecting (${attempts}/${MAX_RECONNECT_ATTEMPTS})...`; currentConn.status = 'connecting'; currentConn.error = reconnectMessage; onStatusChange('connecting', reconnectMessage); return true; } logger.warn(`[PortForwardingService] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached for rule ${ruleId}`); exhaustedReconnectRules.add(ruleId); // Reset reconnect attempts if (currentConn) { currentConn.reconnectAttempts = 0; } return false; }; /** * Get active connection info for a rule */ export const getActiveConnection = (ruleId: string): PortForwardingConnection | undefined => { return activeConnections.get(ruleId); }; /** * Get all active connection rule IDs */ export const getActiveRuleIds = (): string[] => { return Array.from(activeConnections.entries()) .filter(([_, conn]) => conn.status === 'active' || conn.status === 'connecting') .map(([ruleId]) => ruleId); }; /** Return whether any tracked runtime still needs to be stopped. */ export const hasActivePortForwardRuntime = (): boolean => Array.from(activeConnections.values()).some((connection) => isPortForwardingRuntimeBusy(connection), ); const finishRuleCleanup = (ruleId: string): void => { exhaustedReconnectRules.delete(ruleId); rulesPendingCleanup.delete(ruleId); deferredReconnects.delete(ruleId); clearReconnectTimer(ruleId); const conn = activeConnections.get(ruleId); conn?.unsubscribe?.(); activeConnections.delete(ruleId); broadcastReconnectCancel(ruleId); }; const preserveFailedStopConnection = ( ruleId: string, connection: PortForwardingConnection | undefined, error: string, ): void => { const failedConnection = connection ?? { ruleId, tunnelId: `untracked-${ruleId}`, status: 'error' as const, }; failedConnection.reconnectStartAuthorized = false; failedConnection.reconnectSuppressed = true; failedConnection.status = 'error'; failedConnection.error = error; activeConnections.set(ruleId, failedConnection); }; const resumeReconnectAfterFailedCleanup = ( ruleId: string, pausedReconnect?: PausedReconnectTimer, ): void => { rulesPendingCleanup.delete(ruleId); const deferredReconnect = deferredReconnects.get(ruleId); deferredReconnects.delete(ruleId); if (pausedReconnect) { restoreReconnectTimer(ruleId, pausedReconnect); return; } if (deferredReconnect) { scheduleReconnectIfNeeded( ruleId, deferredReconnect.enableReconnect, deferredReconnect.onStatusChange, ); } }; /** Stop every tunnel for a rule and cancel reconnects in every window. */ export const stopAndCleanupRuleAndWait = ( ruleId: string, ): Promise<{ success: boolean; error?: string }> => { const existingCleanup = ruleCleanupPromises.get(ruleId); if (existingCleanup) return existingCleanup; const cleanupPromise = (async () => { const conn = activeConnections.get(ruleId); rulesPendingCleanup.add(ruleId); const pausedReconnect = pauseReconnectTimer(ruleId); // Use stopPortForwardByRuleId so every tunnel for this rule is marked // cancelled before its sockets are closed. const bridge = netcattyBridge.get(); if (bridge?.stopPortForwardByRuleId) { try { const result = await bridge.stopPortForwardByRuleId(ruleId); if ((result.failed ?? 0) > 0) { const error = result.errors?.filter(Boolean).join('; ') || `Failed to stop ${result.failed} port forwarding tunnel(s)`; logger.warn(`[PortForwardingService] Backend stopByRuleId failed for ${ruleId}: ${error}`); resumeReconnectAfterFailedCleanup(ruleId, pausedReconnect); return { success: false, error }; } finishRuleCleanup(ruleId); return { success: true }; } catch (err) { const error = err instanceof Error ? err.message : String(err); logger.warn(`[PortForwardingService] Backend stopByRuleId failed for ${ruleId}:`, err); resumeReconnectAfterFailedCleanup(ruleId, pausedReconnect); return { success: false, error }; } } if (conn && bridge?.stopPortForward) { try { const result = await bridge.stopPortForward(conn.tunnelId); if (result.success) { finishRuleCleanup(ruleId); } else { resumeReconnectAfterFailedCleanup(ruleId, pausedReconnect); } return result; } catch (err) { resumeReconnectAfterFailedCleanup(ruleId, pausedReconnect); return { success: false, error: err instanceof Error ? err.message : String(err) }; } } finishRuleCleanup(ruleId); return { success: true }; })(); ruleCleanupPromises.set(ruleId, cleanupPromise); void cleanupPromise.then( () => { if (ruleCleanupPromises.get(ruleId) === cleanupPromise) ruleCleanupPromises.delete(ruleId); }, () => { if (ruleCleanupPromises.get(ruleId) === cleanupPromise) ruleCleanupPromises.delete(ruleId); }, ); return cleanupPromise; }; /** Fire-and-forget compatibility wrapper for imports and local UI actions. */ export const stopAndCleanupRule = (ruleId: string): void => { void stopAndCleanupRuleAndWait(ruleId).then( (result) => { if (!result.success) finishRuleCleanup(ruleId); }, () => finishRuleCleanup(ruleId), ); }; // Tunnel ID prefix and UUID regex pattern for parsing const TUNNEL_ID_PREFIX = 'pf-'; // UUID format: 8-4-4-4-12 hexadecimal characters const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Parse rule ID from tunnel ID * Tunnel ID format is "pf-{ruleId}-{timestamp}" where ruleId is a UUID */ const parseRuleIdFromTunnelId = (tunnelId: string): string | null => { if (!tunnelId.startsWith(TUNNEL_ID_PREFIX)) { return null; } // Remove prefix and split remaining parts const withoutPrefix = tunnelId.slice(TUNNEL_ID_PREFIX.length); const parts = withoutPrefix.split('-'); // UUID has 5 parts (8-4-4-4-12), so we need at least 6 parts (5 UUID + timestamp) if (parts.length < 6) { return null; } // Reconstruct the UUID from first 5 parts const ruleId = parts.slice(0, 5).join('-'); // Validate it's a proper UUID format if (!UUID_REGEX.test(ruleId)) { return null; } return ruleId; }; const resolveBackendRuleId = (tunnel: { ruleId?: string; tunnelId: string }): string | null => { const explicitRuleId = tunnel.ruleId?.trim(); return explicitRuleId || parseRuleIdFromTunnelId(tunnel.tunnelId); }; const resolveBackendStatus = (status: string): PortForwardingConnection['status'] => { if (status === 'active') return 'active'; if (status === 'connecting' || status === 'stopping') return 'connecting'; if (status === 'error') return 'error'; if (status === 'inactive') return 'inactive'; return 'connecting'; }; export type PortForwardRuntimeAuthority = { available: boolean; epoch?: string; revision?: number; }; let lastRuntimeAuthority: PortForwardRuntimeAuthority = { available: false }; export const getPortForwardRuntimeAuthority = (): PortForwardRuntimeAuthority => lastRuntimeAuthority; /** * Prefer the authoritative snapshot protocol (#2288). Fall back to list() for * older bridges so tests and transitional builds keep working. */ export const fetchPortForwardSnapshot = async (): Promise<{ available: boolean; epoch?: string; revision?: number; records: Array<{ ruleId?: string; tunnelId: string; status: string; error?: string; cleanupRequired?: boolean; }>; }> => { const bridge = netcattyBridge.get(); if (bridge?.getPortForwardSnapshot) { try { const snapshot = await bridge.getPortForwardSnapshot(); lastRuntimeAuthority = { available: true, epoch: snapshot.epoch, revision: snapshot.revision, }; return { available: true, epoch: snapshot.epoch, revision: snapshot.revision, records: snapshot.records.map((record) => ({ ruleId: record.ruleId, tunnelId: record.tunnelId, status: record.phase, error: record.error, cleanupRequired: record.cleanupRequired, })), }; } catch (err) { lastRuntimeAuthority = { available: false }; logger.warn('[PortForwardingService] Snapshot failed:', err); return { available: false, records: [] }; } } if (!bridge?.listPortForwards) { lastRuntimeAuthority = { available: false }; return { available: false, records: [] }; } try { const tunnels = await bridge.listPortForwards(); lastRuntimeAuthority = { available: true, epoch: 'legacy', revision: 0 }; return { available: true, epoch: 'legacy', revision: 0, records: tunnels.map((tunnel) => ({ ruleId: tunnel.ruleId, tunnelId: tunnel.tunnelId, status: tunnel.status, error: tunnel.error, })), }; } catch (err) { lastRuntimeAuthority = { available: false }; logger.warn('[PortForwardingService] Legacy list snapshot failed:', err); return { available: false, records: [] }; } }; /** * Sync active connections with backend * Called on app startup to restore state of tunnels that may still be running * This updates the local activeConnections map to match the backend state. */ export interface PortForwardingBackendSyncOptions { shouldReconnect?: (ruleId: string) => boolean; onStatusChange?: ( ruleId: string, status: PortForwardingRule['status'], error?: string, ) => void; } const backendSyncOptions: PortForwardingBackendSyncOptions = {}; const rememberBackendSyncOptions = (options: PortForwardingBackendSyncOptions): void => { if (options.shouldReconnect) backendSyncOptions.shouldReconnect = options.shouldReconnect; if (options.onStatusChange) backendSyncOptions.onStatusChange = options.onStatusChange; }; const configureSyncedConnection = ( connection: PortForwardingConnection, ruleId: string, ): void => { if (backendSyncOptions.shouldReconnect) { connection.syncedShouldReconnect = () => backendSyncOptions.shouldReconnect?.(ruleId) ?? false; } if (backendSyncOptions.onStatusChange) { connection.syncedOnStatusChange = (status, error) => { backendSyncOptions.onStatusChange?.(ruleId, status, error); }; } }; const subscribeSyncedConnection = async ( ruleId: string, connection: PortForwardingConnection, ): Promise => { const bridge = netcattyBridge.get(); if (!bridge) return activeConnections.get(ruleId) === connection; if (!connection.unsubscribe) { const onStatusChange = (status: PortForwardingRule['status'], error?: string) => { connection.syncedOnStatusChange?.(status, error); }; const unsubscribe = bridge.onPortForwardStatus?.( connection.tunnelId, (status, error) => { const current = activeConnections.get(ruleId); if (current !== connection) return; if (status === 'inactive') { if (current.reconnectTimerCallback) { current.unsubscribe?.(); current.unsubscribe = undefined; return; } if ( !manualStopsInProgress.has(ruleId) && !rulesPendingCleanup.has(ruleId) ) { const reconnectScheduled = scheduleReconnectIfNeeded( ruleId, connection.syncedShouldReconnect?.() ?? false, onStatusChange, ); if (reconnectScheduled) { current.unsubscribe?.(); current.unsubscribe = undefined; return; } } current.unsubscribe?.(); clearReconnectTimer(ruleId); activeConnections.delete(ruleId); onStatusChange('inactive'); return; } current.status = status; current.error = error ?? undefined; if (status === 'error') { if (stopAllInProgress.has(ruleId)) { onStatusChange(status, error ?? undefined); return; } const reconnectScheduled = scheduleReconnectIfNeeded( ruleId, connection.syncedShouldReconnect?.() ?? false, onStatusChange, ); if (reconnectScheduled) return; } onStatusChange(status, error ?? undefined); }, ); if (activeConnections.get(ruleId) === connection) { connection.unsubscribe = unsubscribe; } else { unsubscribe?.(); } } const snapshot = await bridge.subscribePortForward?.(connection.tunnelId); if (activeConnections.get(ruleId) !== connection) return false; if (snapshot?.status === 'inactive') { connection.unsubscribe?.(); activeConnections.delete(ruleId); connection.syncedOnStatusChange?.('inactive'); return false; } if (snapshot) { connection.status = resolveBackendStatus(snapshot.status); connection.error = snapshot.error; } return true; }; export const syncWithBackend = async ( options: PortForwardingBackendSyncOptions = {}, ): Promise => { rememberBackendSyncOptions(options); const bridge = netcattyBridge.get(); if (!bridge?.getPortForwardSnapshot && !bridge?.listPortForwards) { logger.warn('[PortForwardingService] Backend not available for sync'); lastRuntimeAuthority = { available: false }; return; } try { const snapshot = await fetchPortForwardSnapshot(); if (!snapshot.available) { logger.warn('[PortForwardingService] Backend snapshot unavailable during sync'); return; } logger.info(`[PortForwardingService] Backend reports ${snapshot.records.length} active tunnels`); for (const tunnel of snapshot.records) { const ruleId = resolveBackendRuleId(tunnel); if (ruleId) { const existing = activeConnections.get(ruleId); if (existing?.tunnelId !== tunnel.tunnelId) existing?.unsubscribe?.(); // Update local connection tracking const connection: PortForwardingConnection = existing?.tunnelId === tunnel.tunnelId ? existing : { ruleId, tunnelId: tunnel.tunnelId, status: resolveBackendStatus(tunnel.status), reconnectAttempts: existing?.reconnectAttempts ?? 0, }; connection.status = resolveBackendStatus(tunnel.status); connection.error = tunnel.error; configureSyncedConnection(connection, ruleId); activeConnections.set(ruleId, connection); if (!await subscribeSyncedConnection(ruleId, connection)) continue; logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`); } } } catch (err) { lastRuntimeAuthority = { available: false }; logger.error('[PortForwardingService] Failed to sync with backend:', err); } }; /** * Reconcile renderer-side connection state with the backend (heartbeat). * * Returns the set of ruleIds whose status changed so the caller can update * React state accordingly. * * Cases handled: * 1. Renderer thinks a tunnel is active, but backend says it's gone * → clean up activeConnections, return ruleId as "gone" * 2. Backend has an active tunnel that the renderer doesn't track * → add to activeConnections, return ruleId as "appeared" */ export const reconcileWithBackend = async (): Promise<{ snapshotAvailable: boolean; epoch?: string; revision?: number; gone: string[]; appeared: string[]; }> => { const result = { snapshotAvailable: false, epoch: undefined as string | undefined, revision: undefined as number | undefined, gone: [] as string[], appeared: [] as string[], }; const bridge = netcattyBridge.get(); if (!bridge?.getPortForwardSnapshot && !bridge?.listPortForwards) return result; try { const snapshot = await fetchPortForwardSnapshot(); if (!snapshot.available) return result; result.snapshotAvailable = true; result.epoch = snapshot.epoch; result.revision = snapshot.revision; const backendRuleIds = new Set(); for (const tunnel of snapshot.records) { const ruleId = resolveBackendRuleId(tunnel); if (ruleId) { backendRuleIds.add(ruleId); // Case 2: backend has it, renderer doesn't — insert it if (!activeConnections.has(ruleId)) { const connection: PortForwardingConnection = { ruleId, tunnelId: tunnel.tunnelId, status: resolveBackendStatus(tunnel.status), error: tunnel.error, }; configureSyncedConnection(connection, ruleId); activeConnections.set(ruleId, connection); if (!await subscribeSyncedConnection(ruleId, connection)) { result.gone.push(ruleId); continue; } result.appeared.push(ruleId); } else { // Case 3: renderer tracks it, but status may have changed // (e.g. connecting → active after SSH handshake completed // in another window). const existing = activeConnections.get(ruleId)!; const backendStatus = resolveBackendStatus(tunnel.status); if (existing.tunnelId !== tunnel.tunnelId) { existing.unsubscribe?.(); const replacement: PortForwardingConnection = { ruleId, tunnelId: tunnel.tunnelId, status: backendStatus, error: tunnel.error, reconnectAttempts: existing.reconnectAttempts ?? 0, }; configureSyncedConnection(replacement, ruleId); activeConnections.set(ruleId, replacement); if (!await subscribeSyncedConnection(ruleId, replacement)) { result.gone.push(ruleId); continue; } result.appeared.push(ruleId); continue; } if (existing.status !== backendStatus || existing.error !== tunnel.error) { existing.status = backendStatus; existing.error = tunnel.error; result.appeared.push(ruleId); } } } } // Case 1: renderer thinks a tunnel is active/connecting, but backend // says it's gone. Preserve only a local handshake that has not appeared // in the backend yet, or a reconnect that is already scheduled. for (const [ruleId, conn] of activeConnections) { if (!backendRuleIds.has(ruleId)) { if ( conn.status === 'connecting' && (conn.locallyInitiated || conn.reconnectTimerCallback) ) { continue; } conn.unsubscribe?.(); clearReconnectTimer(ruleId); activeConnections.delete(ruleId); result.gone.push(ruleId); } } if (result.gone.length || result.appeared.length) { logger.info( `[PortForwardingService] Reconcile: ${result.gone.length} gone, ${result.appeared.length} appeared`, ); } } catch (err) { lastRuntimeAuthority = { available: false }; logger.warn('[PortForwardingService] Reconcile failed:', err); } return result; }; /** * Start a port forwarding tunnel * @param enableReconnect - If true, will automatically attempt to reconnect on disconnect */ export const startPortForward = async ( rule: PortForwardingRule, host: Host, hosts: Host[], keys: SSHKey[], identities: Identity[], onStatusChange: (status: PortForwardingRule['status'], error?: string) => void, enableReconnect = false, terminalSettings?: Pick, knownHosts?: KnownHost[], ): Promise<{ success: boolean; error?: string }> => { const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) }; const bridge = netcattyBridge.get(); if (rulesPendingCleanup.has(rule.id)) { return { success: false, error: 'This port forwarding rule is currently being stopped.' }; } const existingConnection = activeConnections.get(rule.id); if ( existingConnection && (existingConnection.status === 'active' || existingConnection.status === 'connecting') && !existingConnection.reconnectStartAuthorized ) { onStatusChange(existingConnection.status, existingConnection.error); return { success: true }; } if (existingConnection) { existingConnection.reconnectStartAuthorized = false; existingConnection.reconnectSuppressed = false; } // Clear any existing reconnect timer clearReconnectTimer(rule.id); if (!bridge?.startPortForward) { // Fallback for browser/dev mode - simulate the connection logger.warn('[PortForwardingService] Backend not available, simulating connection...'); return simulateConnection(rule, onStatusChange); } try { // Generate a unique tunnel ID const tunnelId = `pf-${rule.id}-${Date.now()}`; if (host.proxyProfileId && !host.proxyConfig) { throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`); } if (findMissingProxyIdentityId(host.proxyConfig, identities)) { throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname)); } if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) { throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname)); } const resolved = resolveHostAuth({ host, keys, identities }); const key = resolved.key; const proxy = host.proxyConfig ? resolveProxyConfigAuth(host.proxyConfig, identities) : undefined; let jumpHosts: NetcattyJumpHost[] | undefined; if (host.hostChain?.hostIds?.length) { const resolvedJumpHosts = host.hostChain.hostIds.map((hostId) => hosts.find((candidate) => candidate.id === hostId), ); const missingJumpHostIds = host.hostChain.hostIds.filter((_, index) => !resolvedJumpHosts[index]); if (missingJumpHostIds.length > 0) { throw new Error(`Missing jump host configuration for host chain: ${missingJumpHostIds.join(", ")}`); } jumpHosts = resolvedJumpHosts .filter((jumpHost): jumpHost is Host => Boolean(jumpHost)) .map((jumpHost, index) => { if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) { throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`); } if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) { throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname)); } if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) { throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname)); } const hasConfiguredJumpProxyEndpoint = index === 0 && hasUsableProxyConfig(jumpHost.proxyConfig); if ( hasConfiguredJumpProxyEndpoint && hasUnreadableProxyCredential(jumpHost.proxyConfig, identities) ) { throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`); } const jumpResolved = resolveHostAuth({ host: jumpHost, keys, identities }); const jumpKey = jumpResolved.key; const jumpPassword = sanitizeCredentialValue(jumpResolved.password); const jumpKeyAuth = resolveBridgeKeyAuth({ key: jumpKey, fallbackIdentityFilePaths: jumpResolved.authMethod === "password" || jumpResolved.keyId ? undefined : jumpHost.identityFilePaths, passphrase: jumpResolved.passphrase, }); const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey, jumpResolved.authMethod); const hasJumpKeyMaterial = Boolean( jumpAgentAuth.useSshAgent || jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length, ); const hasUnreadableJumpCredential = isEncryptedCredentialPlaceholder(jumpResolved.password) || isEncryptedCredentialPlaceholder(jumpKey?.privateKey) || isEncryptedCredentialPlaceholder(jumpResolved.passphrase); if ( (jumpResolved.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpResolved.password) && !jumpPassword) || (jumpResolved.authMethod !== "password" && jumpResolved.authMethod !== "auto" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial) ) { throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`); } const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings); const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost); return { hostname: jumpHost.hostname, hostId: jumpHost.id, port: jumpHost.port || 22, username: jumpResolved.username || 'root', authMethod: jumpResolved.authMethod, requiresMfa: !!jumpHost.requiresMfa, password: jumpPassword, privateKey: jumpKeyAuth.privateKey, certificate: jumpKey?.certificate, passphrase: jumpKeyAuth.passphrase, publicKey: jumpKey?.publicKey, keyId: jumpResolved.keyId, keySource: jumpKey?.source, label: jumpHost.label, proxy: hasUsableProxyConfig(jumpHost.proxyConfig) ? resolveProxyConfigAuth(jumpHost.proxyConfig, identities) : undefined, identityFilePaths: jumpKeyAuth.identityFilePaths, ...jumpAgentAuth, keepaliveInterval: hopKeepalive.interval, keepaliveCountMax: hopKeepalive.countMax, sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000, sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000, verifyHostKeys: globalTerminalSettings.verifyHostKeys, legacyAlgorithms: jumpHost.legacyAlgorithms, skipEcdsaHostKey: jumpHost.skipEcdsaHostKey, algorithmOverrides: jumpHost.algorithms, }; }); } const usesTargetProxyForFirstHop = !!proxy && !jumpHosts?.[0]?.proxy; if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) { throw new Error('Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.'); } const keyAuth = resolveBridgeKeyAuth({ key, fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId ? undefined : host.identityFilePaths, passphrase: resolved.passphrase, }); const targetAgentAuth = resolveBridgeSshAgentAuth(host, key, resolved.authMethod); const password = sanitizeCredentialValue(resolved.password); const hasKeyMaterial = Boolean( targetAgentAuth.useSshAgent || keyAuth.privateKey || keyAuth.identityFilePaths?.length, ); const hasUnreadableCredential = isEncryptedCredentialPlaceholder(resolved.password) || isEncryptedCredentialPlaceholder(key?.privateKey) || isEncryptedCredentialPlaceholder(resolved.passphrase); if ( (resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) || (resolved.authMethod !== "password" && resolved.authMethod !== "auto" && hasUnreadableCredential && !password && !hasKeyMaterial) ) { throw new Error('Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.'); } // Subscribe to status updates first const handleTunnelStatus = (status: PortForwardingRule['status'], error?: string | null) => { const conn = activeConnections.get(rule.id); if (status === 'inactive') { if (conn?.reconnectTimerCallback) { conn.unsubscribe?.(); conn.unsubscribe = undefined; conn.locallyInitiated = false; return; } if ( !manualStopsInProgress.has(rule.id) && !rulesPendingCleanup.has(rule.id) && !exhaustedReconnectRules.has(rule.id) ) { const reconnectScheduled = scheduleReconnectIfNeeded( rule.id, enableReconnect, onStatusChange, ); if (reconnectScheduled) { conn?.unsubscribe?.(); if (conn) conn.unsubscribe = undefined; return; } } conn?.unsubscribe?.(); clearReconnectTimer(rule.id); activeConnections.delete(rule.id); onStatusChange('inactive'); return; } if (conn) { conn.status = status; conn.error = error ?? undefined; if (status !== 'connecting') conn.locallyInitiated = false; } // Handle auto-reconnect on error/disconnect if (status === 'error') { if (stopAllInProgress.has(rule.id)) { onStatusChange(status, error ?? undefined); return; } const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange); if (reconnectScheduled) { return; } } onStatusChange(status, error ?? undefined); }; const unsubscribe = bridge.onPortForwardStatus?.(tunnelId, handleTunnelStatus); // Store connection info (preserve reconnect attempts if this is a reconnect) const existingConn = activeConnections.get(rule.id); activeConnections.set(rule.id, { ruleId: rule.id, tunnelId, status: 'connecting', unsubscribe, locallyInitiated: true, reconnectAttempts: existingConn?.reconnectAttempts ?? 0, }); onStatusChange('connecting'); // Start the tunnel const connectionTimeouts = resolveHostSshConnectionTimeouts(host); const result = await bridge.startPortForward({ ruleId: rule.id, tunnelId, type: rule.type, localPort: rule.localPort, bindAddress: rule.bindAddress, remoteHost: rule.remoteHost, remotePort: rule.remotePort, hostname: host.hostname, hostId: host.id, port: host.port, username: resolved.username, authMethod: resolved.authMethod, requiresMfa: !!host.requiresMfa, password, privateKey: keyAuth.privateKey, certificate: key?.certificate, keyId: resolved.keyId, passphrase: keyAuth.passphrase, knownHosts, verifyHostKeys: globalTerminalSettings.verifyHostKeys, proxy, jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined, identityFilePaths: keyAuth.identityFilePaths, ...targetAgentAuth, legacyAlgorithms: host.legacyAlgorithms, skipEcdsaHostKey: host.skipEcdsaHostKey, algorithmOverrides: host.algorithms, keepaliveInterval: resolveHostKeepalive(host, globalTerminalSettings).interval, keepaliveCountMax: resolveHostKeepalive(host, globalTerminalSettings).countMax, sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000, sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000, }); if (!result.success) { // Intentional cancellation (rule deleted/replaced during handshake). // Clean up quietly — no error state, no reconnect. if ((result as { cancelled?: boolean }).cancelled) { activeConnections.delete(rule.id); unsubscribe?.(); onStatusChange('inactive'); return { success: false, error: undefined }; } if (result.blockedByCleanup && result.tunnelId) { unsubscribe?.(); activeConnections.set(rule.id, { ruleId: rule.id, tunnelId: result.tunnelId, status: 'error', error: result.error, }); onStatusChange('error', result.error); return { success: false, error: result.error }; } // Check if we should attempt reconnect const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange); if (reconnectScheduled) { return { success: false, error: result.error }; } activeConnections.delete(rule.id); unsubscribe?.(); onStatusChange('error', result.error); return { success: false, error: result.error }; } if (result.reused && result.tunnelId) { // A different window won the start race. Adopt the backend's durable // tunnel instead of keeping this renderer's unused attempt id. unsubscribe?.(); const adoptedStatus = result.status === 'active' ? 'active' : 'connecting'; const adoptedUnsubscribe = bridge.onPortForwardStatus?.( result.tunnelId, handleTunnelStatus, ); activeConnections.set(rule.id, { ruleId: rule.id, tunnelId: result.tunnelId, status: adoptedStatus, unsubscribe: adoptedUnsubscribe, locallyInitiated: false, reconnectAttempts: existingConn?.reconnectAttempts ?? 0, }); // Close the reply/listener race: an adopted tunnel may have stopped // after the backend replied but before this renderer subscribed. const snapshot = await bridge.getPortForwardStatus?.(result.tunnelId); const adoptedConnection = activeConnections.get(rule.id); if (snapshot && adoptedConnection?.tunnelId === result.tunnelId) { if (snapshot.status === 'inactive') { adoptedUnsubscribe?.(); activeConnections.delete(rule.id); onStatusChange('inactive'); return { success: false, error: 'Port forwarding tunnel stopped before adoption completed' }; } adoptedConnection.status = snapshot.status; adoptedConnection.error = snapshot.error; } const current = activeConnections.get(rule.id); if (!current) { return { success: false, error: 'Port forwarding tunnel stopped before adoption completed', }; } onStatusChange(current.status, current.error); return { success: true }; } // Reset reconnect attempts on successful connection const conn = activeConnections.get(rule.id); if (conn) { conn.reconnectAttempts = 0; } exhaustedReconnectRules.delete(rule.id); return { success: true }; } catch (err) { const error = err instanceof Error ? err.message : 'Unknown error'; // Check if we should attempt reconnect const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange); if (reconnectScheduled) { return { success: false, error }; } onStatusChange('error', error); activeConnections.delete(rule.id); return { success: false, error }; } }; /** * Stop a port forwarding tunnel */ export const stopPortForward = async ( ruleId: string, onStatusChange: (status: PortForwardingRule['status'], error?: string) => void ): Promise<{ success: boolean; error?: string }> => { const bridge = netcattyBridge.get(); const conn = activeConnections.get(ruleId); // User intent takes effect immediately. Do not let an already queued network // recovery restart this rule while the backend stop request is still pending. exhaustedReconnectRules.delete(ruleId); // Clear any pending reconnect timer clearReconnectTimer(ruleId); if (!bridge?.stopPortForwardByRuleId && !conn) { onStatusChange('inactive'); return { success: true }; } if (!bridge?.stopPortForwardByRuleId && !bridge?.stopPortForward) { // Fallback for browser/dev mode logger.warn('[PortForwardingService] Backend not available, simulating stop...'); conn?.unsubscribe?.(); activeConnections.delete(ruleId); onStatusChange('inactive'); return { success: true }; } manualStopsInProgress.add(ruleId); try { if (bridge.stopPortForwardByRuleId) { const result = await bridge.stopPortForwardByRuleId(ruleId); if ((result.failed ?? 0) > 0) { const error = result.errors?.filter(Boolean).join('; ') || `Failed to stop ${result.failed} port forwarding tunnel(s)`; clearReconnectTimer(ruleId); preserveFailedStopConnection(ruleId, conn, error); onStatusChange('error', error); return { success: false, error }; } } else if (conn && bridge.stopPortForward) { const result = await bridge.stopPortForward(conn.tunnelId); if (!result.success) return result; } finishRuleCleanup(ruleId); onStatusChange('inactive'); return { success: true }; } catch (err) { const error = err instanceof Error ? err.message : 'Unknown error'; clearReconnectTimer(ruleId); preserveFailedStopConnection(ruleId, conn, error); onStatusChange('error', error); return { success: false, error }; } finally { manualStopsInProgress.delete(ruleId); } }; /** * Get the current status of a tunnel */ export const getPortForwardStatus = async ( ruleId: string ): Promise => { const conn = activeConnections.get(ruleId); if (!conn) return 'inactive'; return conn.status; }; /** * Check if backend is available */ export const isBackendAvailable = (): boolean => { return !!(netcattyBridge.get()?.startPortForward); }; export type BulkPortForwardRuleError = { ruleId: string; label: string; error: string; }; export type StartAllPortForwardsResult = { started: number; skipped: number; failed: number; errors: BulkPortForwardRuleError[]; }; export type StopAllActivePortForwardsResult = { stopped: number; failed: number; errors: BulkPortForwardRuleError[]; }; export type StopAllPortForwardsResult = { backendStopAllFailed: boolean; directStopAttemptedRuleIds: string[]; directStopFailures: BulkPortForwardRuleError[]; error?: string; }; type StartAllPortForwardHostResolver = (rule: PortForwardingRule) => Host | undefined; type StartAllPortForwardRuleLookup = (ruleId: string) => PortForwardingRule | undefined; const isTrackedRuntimeBusy = (ruleId: string): boolean => isPortForwardingRuntimeBusy(getActiveConnection(ruleId)); /** * Start each inactive/error rule sequentially via startPortForward. * Re-reads the live rule before each start so deletes/edits during the * queue are honored. Skips tracked active, connecting, or error runtimes. */ export const startAllPortForwards = async ( rules: PortForwardingRule[], resolveHost: StartAllPortForwardHostResolver, hosts: Host[], keys: SSHKey[], identities: Identity[], onStatusChange: (ruleId: string, status: PortForwardingRule['status'], error?: string) => void, terminalSettings?: Pick, knownHosts?: KnownHost[], getRule?: StartAllPortForwardRuleLookup, hostNotFoundMessage?: string, ): Promise => { const result: StartAllPortForwardsResult = { started: 0, skipped: 0, failed: 0, errors: [], }; const queuedIds = selectStartablePortForwardingRules(rules, isTrackedRuntimeBusy) .map((rule) => rule.id); result.skipped = rules.length - queuedIds.length; const resolveLiveRule = (ruleId: string): PortForwardingRule | undefined => getRule ? getRule(ruleId) : rules.find((rule) => rule.id === ruleId); for (const ruleId of queuedIds) { const rule = resolveLiveRule(ruleId); if (!rule || !isPortForwardingRuleStartable(rule, isTrackedRuntimeBusy(rule.id))) { result.skipped += 1; continue; } const host = resolveHost(rule); if (!host) { const error = hostNotFoundMessage ?? 'Host not found'; onStatusChange(rule.id, 'error', error); result.failed += 1; result.errors.push({ ruleId: rule.id, label: rule.label, error }); continue; } const startResult = await startPortForward( rule, host, hosts, keys, identities, (status, error) => onStatusChange(rule.id, status, error), Boolean(rule.autoStart), terminalSettings, knownHosts, ); if (startResult.success) { result.started += 1; } else { result.failed += 1; result.errors.push({ ruleId: rule.id, label: rule.label, error: startResult.error || 'Failed to start', }); } } return result; }; /** * Stop running tunnels one-by-one, then call stopAllPortForwards as a safety net. */ export const stopAllActivePortForwards = async ( rules: PortForwardingRule[], onStatusChange: (ruleId: string, status: PortForwardingRule['status'], error?: string) => void, ): Promise => { const targets = selectStoppablePortForwardingRules(rules, isTrackedRuntimeBusy); const result: StopAllActivePortForwardsResult = { stopped: 0, failed: 0, errors: [], }; const failedTargetErrors = new Map(); for (const rule of targets) { stopAllInProgress.add(rule.id); let stopResult: { success: boolean; error?: string }; try { stopResult = await stopPortForward(rule.id, (status, error) => { onStatusChange(rule.id, status, error); }); } finally { stopAllInProgress.delete(rule.id); } if (stopResult.success) { result.stopped += 1; } else { failedTargetErrors.set(rule.id, { ruleId: rule.id, label: rule.label, error: stopResult.error || 'Failed to stop', }); } } const cleanupResult = await stopAllPortForwards(); const cleanupFailures = new Map( cleanupResult.directStopFailures.map((error) => [error.ruleId, error]), ); for (const [ruleId, error] of failedTargetErrors) { if ( cleanupResult.directStopAttemptedRuleIds.includes(ruleId) && !cleanupFailures.has(ruleId) ) { result.stopped += 1; continue; } result.failed += 1; result.errors.push(error); } const reportedRuleIds = new Set(result.errors.map((error) => error.ruleId)); for (const error of cleanupResult.directStopFailures) { if (reportedRuleIds.has(error.ruleId)) continue; result.failed += 1; result.errors.push(error); } if (cleanupResult.backendStopAllFailed) { result.failed += 1; result.errors.push({ ruleId: 'backend-stop-all', label: 'All port forwarding tunnels', error: cleanupResult.error || 'Failed to stop all port forwarding tunnels', }); } return result; }; /** * Stop all active tunnels (cleanup on unmount) */ export const stopAllPortForwards = async (): Promise => { const bridge = netcattyBridge.get(); const result: StopAllPortForwardsResult = { backendStopAllFailed: false, directStopAttemptedRuleIds: [], directStopFailures: [], }; const guardedRuleIds = new Set(); // Stop everything the renderer knows about for (const [ruleId, conn] of activeConnections) { // Clear any pending reconnect timer clearReconnectTimer(ruleId); manualStopsInProgress.add(ruleId); stopAllInProgress.add(ruleId); guardedRuleIds.add(ruleId); try { if (bridge?.stopPortForward) { result.directStopAttemptedRuleIds.push(ruleId); const stopResult = await bridge.stopPortForward(conn.tunnelId); if (stopResult && !stopResult.success) { throw new Error(stopResult.error || `Failed to stop tunnel ${conn.tunnelId}`); } } conn.unsubscribe?.(); activeConnections.delete(ruleId); } catch (err) { result.directStopFailures.push({ ruleId, label: ruleId, error: err instanceof Error ? err.message : String(err), }); logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err); preserveFailedStopConnection( ruleId, conn, err instanceof Error ? err.message : String(err), ); } finally { manualStopsInProgress.delete(ruleId); } } // Also ask the backend to stop ALL tunnels it knows about. // This covers tunnels that were started by other windows or that // this renderer doesn't have in its activeConnections map (e.g. // settings window opened before initializeStore finished). if (bridge?.stopAllPortForwards) { try { await bridge.stopAllPortForwards(); } catch (err) { result.backendStopAllFailed = true; result.error = err instanceof Error ? err.message : String(err); logger.warn('[PortForwardingService] Backend stopAllPortForwards failed:', err); } } for (const ruleId of guardedRuleIds) stopAllInProgress.delete(ruleId); return result; }; /** * Simulate connection for development/browser mode */ const simulateConnection = async ( rule: PortForwardingRule, onStatusChange: (status: PortForwardingRule['status'], error?: string) => void ): Promise<{ success: boolean; error?: string }> => { onStatusChange('connecting'); // Simulate connection delay await new Promise(resolve => setTimeout(resolve, 1000)); // Random success/failure for demo const success = Math.random() > 0.1; // 90% success rate if (success) { // Store simulated connection activeConnections.set(rule.id, { ruleId: rule.id, tunnelId: `simulated-${rule.id}`, status: 'active', }); onStatusChange('active'); return { success: true }; } else { onStatusChange('error', 'Simulated connection failure'); return { success: false, error: 'Simulated connection failure' }; } }; export default { startPortForward, startAllPortForwards, stopPortForward, getPortForwardStatus, isBackendAvailable, stopAllPortForwards, stopAllActivePortForwards, setReconnectCallback, clearReconnectTimer, };