Files
NetMesh/infrastructure/services/portForwardingService.ts

1587 lines
55 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<typeof setTimeout>;
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<string, PortForwardingConnection>();
// 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<string>();
const rulesPendingCleanup = new Set<string>();
const manualStopsInProgress = new Set<string>();
const stopAllInProgress = new Set<string>();
const ruleCleanupPromises = new Map<string, Promise<{ success: boolean; error?: string }>>();
const deferredReconnects = new Map<string, {
enableReconnect: boolean;
onStatusChange: (status: PortForwardingRule['status'], error?: string) => 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<boolean> => {
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<void> => {
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<string>();
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<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>,
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<PortForwardingRule['status']> => {
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<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>,
knownHosts?: KnownHost[],
getRule?: StartAllPortForwardRuleLookup,
hostNotFoundMessage?: string,
): Promise<StartAllPortForwardsResult> => {
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<StopAllActivePortForwardsResult> => {
const targets = selectStoppablePortForwardingRules(rules, isTrackedRuntimeBusy);
const result: StopAllActivePortForwardsResult = {
stopped: 0,
failed: 0,
errors: [],
};
const failedTargetErrors = new Map<string, BulkPortForwardRuleError>();
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<StopAllPortForwardsResult> => {
const bridge = netcattyBridge.get();
const result: StopAllPortForwardsResult = {
backendStopAllFailed: false,
directStopAttemptedRuleIds: [],
directStopFailures: [],
};
const guardedRuleIds = new Set<string>();
// 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,
};