diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 0c1b0db4..373a129a 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1,4 +1,4 @@ -import express from "express"; +import express, { type Response } from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import { Client } from "ssh2"; @@ -13,6 +13,7 @@ import type { TunnelStatus, VerificationData, ErrorType, + AuthenticatedRequest, } from "../../types/index.js"; import { CONNECTION_STATES } from "../../types/index.js"; import { tunnelLogger, sshLogger } from "../utils/logger.js"; @@ -20,6 +21,8 @@ import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import { PermissionManager } from "../utils/permission-manager.js"; const app = express(); app.use( @@ -64,6 +67,10 @@ app.use( app.use(cookieParser()); app.use(express.json()); +const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + const activeTunnels = new Map(); const retryCounters = new Map(); const connectionStatus = new Map(); @@ -78,6 +85,7 @@ const tunnelConnecting = new Set(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); +const pendingTunnelOperations = new Map>(); function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if ( @@ -155,10 +163,75 @@ function getTunnelMarker(tunnelName: string) { return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; } -function cleanupTunnelResources( +function normalizeTunnelName( + hostId: number, + tunnelIndex: number, + displayName: string, + sourcePort: number, + endpointHost: string, + endpointPort: number, +): string { + return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`; +} + +function parseTunnelName(tunnelName: string): { + hostId?: number; + tunnelIndex?: number; + displayName: string; + sourcePort: string; + endpointHost: string; + endpointPort: string; + isLegacyFormat: boolean; +} { + const parts = tunnelName.split("::"); + + if (parts.length === 6) { + return { + hostId: parseInt(parts[0]), + tunnelIndex: parseInt(parts[1]), + displayName: parts[2], + sourcePort: parts[3], + endpointHost: parts[4], + endpointPort: parts[5], + isLegacyFormat: false, + }; + } + + tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`); + + const legacyParts = tunnelName.split("_"); + return { + displayName: legacyParts[0] || "unknown", + sourcePort: legacyParts[legacyParts.length - 3] || "0", + endpointHost: legacyParts[legacyParts.length - 2] || "unknown", + endpointPort: legacyParts[legacyParts.length - 1] || "0", + isLegacyFormat: true, + }; +} + +function validateTunnelConfig( + tunnelName: string, + tunnelConfig: TunnelConfig, +): boolean { + const parsed = parseTunnelName(tunnelName); + + if (parsed.isLegacyFormat) { + return true; + } + + return ( + parsed.hostId === tunnelConfig.sourceHostId && + parsed.tunnelIndex === tunnelConfig.tunnelIndex && + String(parsed.sourcePort) === String(tunnelConfig.sourcePort) && + parsed.endpointHost === tunnelConfig.endpointHost && + String(parsed.endpointPort) === String(tunnelConfig.endpointPort) + ); +} + +async function cleanupTunnelResources( tunnelName: string, forceCleanup = false, -): void { +): Promise { if (cleanupInProgress.has(tunnelName)) { return; } @@ -171,13 +244,16 @@ function cleanupTunnelResources( const tunnelConfig = tunnelConfigs.get(tunnelName); if (tunnelConfig) { - killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { - cleanupInProgress.delete(tunnelName); - if (err) { - tunnelLogger.error( - `Failed to kill remote tunnel for '${tunnelName}': ${err.message}`, - ); - } + await new Promise((resolve) => { + killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { + cleanupInProgress.delete(tunnelName); + if (err) { + tunnelLogger.error( + `Failed to kill remote tunnel for '${tunnelName}': ${err.message}`, + ); + } + resolve(); + }); }); } else { cleanupInProgress.delete(tunnelName); @@ -491,38 +567,81 @@ async function connectSSHTunnel( authMethod: tunnelConfig.sourceAuthMethod, }; - if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { - try { - const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId); - if (userDataKey) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), - "ssh_credentials", - tunnelConfig.sourceUserId, - ); + const effectiveUserId = + tunnelConfig.requestingUserId || tunnelConfig.sourceUserId; - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedSourceCredentials = { - password: credential.password as string | undefined, - sshKey: (credential.private_key || - credential.privateKey || - credential.key) as string | undefined, - keyPassword: (credential.key_password || credential.keyPassword) as - | string - | undefined, - keyType: (credential.key_type || credential.keyType) as - | string - | undefined, - authMethod: (credential.auth_type || credential.authType) as string, - }; + if (tunnelConfig.sourceCredentialId && effectiveUserId) { + try { + if ( + tunnelConfig.requestingUserId && + tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId + ) { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + + if (tunnelConfig.sourceHostId) { + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + tunnelConfig.sourceHostId, + tunnelConfig.requestingUserId, + ); + + if (sharedCred) { + resolvedSourceCredentials = { + password: sharedCred.password, + sshKey: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + authMethod: sharedCred.authType, + }; + tunnelLogger.info("Resolved shared credentials for tunnel source", { + operation: "tunnel_connect_shared_cred", + tunnelName, + userId: effectiveUserId, + }); + } else { + const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`; + tunnelLogger.error(errorMessage); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: errorMessage, + }); + return; + } + } + } else { + const userDataKey = DataCrypto.getUserDataKey(effectiveUserId); + if (userDataKey) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), + "ssh_credentials", + effectiveUserId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedSourceCredentials = { + password: credential.password as string | undefined, + sshKey: (credential.private_key || + credential.privateKey || + credential.key) as string | undefined, + keyPassword: (credential.key_password || + credential.keyPassword) as string | undefined, + keyType: (credential.key_type || credential.keyType) as + | string + | undefined, + authMethod: (credential.auth_type || + credential.authType) as string, + }; + } } } } catch (error) { - tunnelLogger.warn("Failed to resolve source credentials from database", { + tunnelLogger.warn("Failed to resolve source credentials", { operation: "tunnel_connect", tunnelName, credentialId: tunnelConfig.sourceCredentialId, @@ -1349,103 +1468,312 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { res.json({ name: tunnelName, status }); }); -app.post("/ssh/tunnel/connect", (req, res) => { - const tunnelConfig: TunnelConfig = req.body; +app.post( + "/ssh/tunnel/connect", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const tunnelConfig: TunnelConfig = req.body; + const userId = req.userId; - if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({ error: "Invalid tunnel configuration" }); - } + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } - const tunnelName = tunnelConfig.name; + if (!tunnelConfig || !tunnelConfig.name) { + return res.status(400).json({ error: "Invalid tunnel configuration" }); + } - cleanupTunnelResources(tunnelName); + const tunnelName = tunnelConfig.name; - manualDisconnects.delete(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + try { + if (!validateTunnelConfig(tunnelName, tunnelConfig)) { + tunnelLogger.error(`Tunnel config validation failed`, { + operation: "tunnel_connect", + tunnelName, + configHostId: tunnelConfig.sourceHostId, + configTunnelIndex: tunnelConfig.tunnelIndex, + }); + return res.status(400).json({ + error: "Tunnel configuration does not match tunnel name", + }); + } - tunnelConfigs.set(tunnelName, tunnelConfig); + if (tunnelConfig.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + tunnelConfig.sourceHostId, + "read", + ); - connectSSHTunnel(tunnelConfig, 0).catch((error) => { - tunnelLogger.error( - `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - }); + if (!accessInfo.hasAccess) { + tunnelLogger.warn("User attempted tunnel connect without access", { + operation: "tunnel_connect_unauthorized", + userId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + }); + return res.status(403).json({ error: "Access denied to this host" }); + } - res.json({ message: "Connection request received", tunnelName }); -}); + if (accessInfo.isShared && !accessInfo.isOwner) { + tunnelConfig.requestingUserId = userId; + tunnelLogger.info("Shared host tunnel connect", { + operation: "tunnel_connect_shared", + userId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + }); + } + } -app.post("/ssh/tunnel/disconnect", (req, res) => { - const { tunnelName } = req.body; + if (pendingTunnelOperations.has(tunnelName)) { + try { + await pendingTunnelOperations.get(tunnelName); + } catch (error) { + tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName }); + } + } - if (!tunnelName) { - return res.status(400).json({ error: "Tunnel name required" }); - } + const operation = (async () => { + manualDisconnects.delete(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); - manualDisconnects.add(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + await cleanupTunnelResources(tunnelName); - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } + if (tunnelConfigs.has(tunnelName)) { + const existingConfig = tunnelConfigs.get(tunnelName); + if ( + existingConfig && + (existingConfig.sourceHostId !== tunnelConfig.sourceHostId || + existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex) + ) { + throw new Error(`Tunnel name collision detected: ${tunnelName}`); + } + } - cleanupTunnelResources(tunnelName, true); + // If endpoint details are missing, resolve them from database + if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) { + tunnelLogger.info("Resolving endpoint host details from database", { + operation: "tunnel_connect_resolve_endpoint", + tunnelName, + endpointHost: tunnelConfig.endpointHost, + }); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true, - }); + try { + const systemCrypto = SystemCrypto.getInstance(); + const internalAuthToken = await systemCrypto.getInternalAuthToken(); - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); + const allHostsResponse = await axios.get( + "http://localhost:30001/ssh/db/host/internal/all", + { + headers: { + "Content-Type": "application/json", + "X-Internal-Auth-Token": internalAuthToken, + }, + }, + ); - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); + const allHosts: SSHHost[] = allHostsResponse.data || []; + const endpointHost = allHosts.find( + (h) => + h.name === tunnelConfig.endpointHost || + `${h.username}@${h.ip}` === tunnelConfig.endpointHost, + ); - res.json({ message: "Disconnect request received", tunnelName }); -}); + if (!endpointHost) { + throw new Error( + `Endpoint host '${tunnelConfig.endpointHost}' not found in database`, + ); + } -app.post("/ssh/tunnel/cancel", (req, res) => { - const { tunnelName } = req.body; + // Populate endpoint fields + tunnelConfig.endpointIP = endpointHost.ip; + tunnelConfig.endpointSSHPort = endpointHost.port; + tunnelConfig.endpointUsername = endpointHost.username; + tunnelConfig.endpointPassword = endpointHost.password; + tunnelConfig.endpointAuthMethod = endpointHost.authType; + tunnelConfig.endpointSSHKey = endpointHost.key; + tunnelConfig.endpointKeyPassword = endpointHost.keyPassword; + tunnelConfig.endpointKeyType = endpointHost.keyType; + tunnelConfig.endpointCredentialId = endpointHost.credentialId; + tunnelConfig.endpointUserId = endpointHost.userId; - if (!tunnelName) { - return res.status(400).json({ error: "Tunnel name required" }); - } + tunnelLogger.info("Endpoint host details resolved", { + operation: "tunnel_connect_endpoint_resolved", + tunnelName, + endpointIP: tunnelConfig.endpointIP, + endpointUsername: tunnelConfig.endpointUsername, + }); + } catch (resolveError) { + tunnelLogger.error( + "Failed to resolve endpoint host", + resolveError, + { + operation: "tunnel_connect_resolve_endpoint_failed", + tunnelName, + endpointHost: tunnelConfig.endpointHost, + }, + ); + throw new Error( + `Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, + ); + } + } - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); + tunnelConfigs.set(tunnelName, tunnelConfig); + await connectSSHTunnel(tunnelConfig, 0); + })(); - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } + pendingTunnelOperations.set(tunnelName, operation); - if (countdownIntervals.has(tunnelName)) { - clearInterval(countdownIntervals.get(tunnelName)!); - countdownIntervals.delete(tunnelName); - } + res.json({ message: "Connection request received", tunnelName }); - cleanupTunnelResources(tunnelName, true); + operation.finally(() => { + pendingTunnelOperations.delete(tunnelName); + }); + } catch (error) { + tunnelLogger.error("Failed to process tunnel connect", error, { + operation: "tunnel_connect", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to connect tunnel" }); + } + }, +); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true, - }); +app.post( + "/ssh/tunnel/disconnect", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const { tunnelName } = req.body; + const userId = req.userId; - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } - res.json({ message: "Cancel request received", tunnelName }); -}); + try { + const config = tunnelConfigs.get(tunnelName); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "read", + ); + if (!accessInfo.hasAccess) { + return res.status(403).json({ error: "Access denied" }); + } + } + + manualDisconnects.add(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + await cleanupTunnelResources(tunnelName, true); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); + + res.json({ message: "Disconnect request received", tunnelName }); + } catch (error) { + tunnelLogger.error("Failed to disconnect tunnel", error, { + operation: "tunnel_disconnect", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to disconnect tunnel" }); + } + }, +); + +app.post( + "/ssh/tunnel/cancel", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const { tunnelName } = req.body; + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } + + try { + const config = tunnelConfigs.get(tunnelName); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "read", + ); + if (!accessInfo.hasAccess) { + return res.status(403).json({ error: "Access denied" }); + } + } + + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } + + await cleanupTunnelResources(tunnelName, true); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); + + res.json({ message: "Cancel request received", tunnelName }); + } catch (error) { + tunnelLogger.error("Failed to cancel tunnel retry", error, { + operation: "tunnel_cancel", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to cancel tunnel retry" }); + } + }, +); async function initializeAutoStartTunnels(): Promise { try { @@ -1491,12 +1819,19 @@ async function initializeAutoStartTunnels(): Promise { ); if (endpointHost) { + const tunnelIndex = + host.tunnelConnections.indexOf(tunnelConnection); const tunnelConfig: TunnelConfig = { - name: `${host.name || `${host.username}@${host.ip}`}_${ - tunnelConnection.sourcePort - }_${tunnelConnection.endpointHost}_${ - tunnelConnection.endpointPort - }`, + name: normalizeTunnelName( + host.id, + tunnelIndex, + host.name || `${host.username}@${host.ip}`, + tunnelConnection.sourcePort, + tunnelConnection.endpointHost, + tunnelConnection.endpointPort, + ), + sourceHostId: host.id, + tunnelIndex: tunnelIndex, hostName: host.name || `${host.username}@${host.ip}`, sourceIP: host.ip, sourceSSHPort: host.port, @@ -1512,6 +1847,7 @@ async function initializeAutoStartTunnels(): Promise { endpointIP: endpointHost.ip, endpointSSHPort: endpointHost.port, endpointUsername: endpointHost.username, + endpointHost: tunnelConnection.endpointHost, endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || diff --git a/src/types/index.ts b/src/types/index.ts index 5be5b549..1c7b0d58 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -212,6 +212,14 @@ export interface TunnelConnection { export interface TunnelConfig { name: string; + + // Unique identifiers for collision prevention + sourceHostId: number; + tunnelIndex: number; + + // User context for RBAC + requestingUserId?: string; + hostName: string; sourceIP: string; sourceSSHPort: number; @@ -226,6 +234,7 @@ export interface TunnelConfig { endpointIP: string; endpointSSHPort: number; endpointUsername: string; + endpointHost: string; endpointPassword?: string; endpointAuthMethod: string; endpointSSHKey?: string; diff --git a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx index c6204092..2c67b8e2 100644 --- a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx +++ b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx @@ -126,26 +126,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { tunnelIndex: number, ) => { const tunnel = host.tunnelConnections[tunnelIndex]; - const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${ - tunnel.sourcePort - }_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; setTunnelActions((prev) => ({ ...prev, [tunnelName]: true })); try { if (action === "connect") { + // Try to find endpoint host in user's accessible hosts const endpointHost = allHosts.find( (h) => h.name === tunnel.endpointHost || `${h.username}@${h.ip}` === tunnel.endpointHost, ); - if (!endpointHost) { - throw new Error(t("tunnels.endpointHostNotFound")); - } - + // For shared users who don't have access to endpoint host, + // send a minimal config and let backend resolve endpoint details const tunnelConfig = { name: tunnelName, + sourceHostId: host.id, + tunnelIndex: tunnelIndex, hostName: host.name || `${host.username}@${host.ip}`, sourceIP: host.ip, sourceSSHPort: host.port, @@ -159,24 +158,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { sourceKeyType: host.authType === "key" ? host.keyType : undefined, sourceCredentialId: host.credentialId, sourceUserId: host.userId, - endpointIP: endpointHost.ip, - endpointSSHPort: endpointHost.port, - endpointUsername: endpointHost.username, + endpointHost: tunnel.endpointHost, + endpointIP: endpointHost?.ip, + endpointSSHPort: endpointHost?.port, + endpointUsername: endpointHost?.username, endpointPassword: - endpointHost.authType === "password" + endpointHost?.authType === "password" ? endpointHost.password : undefined, - endpointAuthMethod: endpointHost.authType, + endpointAuthMethod: endpointHost?.authType, endpointSSHKey: - endpointHost.authType === "key" ? endpointHost.key : undefined, + endpointHost?.authType === "key" ? endpointHost.key : undefined, endpointKeyPassword: - endpointHost.authType === "key" + endpointHost?.authType === "key" ? endpointHost.keyPassword : undefined, endpointKeyType: - endpointHost.authType === "key" ? endpointHost.keyType : undefined, - endpointCredentialId: endpointHost.credentialId, - endpointUserId: endpointHost.userId, + endpointHost?.authType === "key" ? endpointHost.keyType : undefined, + endpointCredentialId: endpointHost?.credentialId, + endpointUserId: endpointHost?.userId, sourcePort: tunnel.sourcePort, endpointPort: tunnel.endpointPort, maxRetries: tunnel.maxRetries, @@ -191,6 +191,19 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { socks5ProxyChain: host.socks5ProxyChain, }; + console.log("Tunnel connect config:", { + tunnelName, + sourceHostId: tunnelConfig.sourceHostId, + sourceCredentialId: tunnelConfig.sourceCredentialId, + sourceUserId: tunnelConfig.sourceUserId, + hasSourcePassword: !!tunnelConfig.sourcePassword, + hasSourceKey: !!tunnelConfig.sourceSSHKey, + hasEndpointHost: !!endpointHost, + endpointHost: tunnel.endpointHost, + isShared: (host as any).isShared, + ownerId: (host as any).ownerId, + }); + await connectTunnel(tunnelConfig); } else if (action === "disconnect") { await disconnectTunnel(tunnelName); @@ -199,7 +212,15 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { } await fetchTunnelStatuses(); - } catch { + } catch (error) { + console.error("Tunnel action failed:", { + action, + tunnelName, + hostId: host.id, + tunnelIndex, + error: error instanceof Error ? error.message : String(error), + fullError: error, + }); } finally { setTunnelActions((prev) => ({ ...prev, [tunnelName]: false })); } diff --git a/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx b/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx index 757b4b1a..ebbeeb2e 100644 --- a/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx +++ b/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx @@ -34,9 +34,7 @@ export function TunnelObject({ const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const tunnel = host.tunnelConnections[tunnelIndex]; - const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${ - tunnel.sourcePort - }_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; return tunnelStatuses[tunnelName]; }; @@ -121,9 +119,7 @@ export function TunnelObject({ {host.tunnelConnections.map((tunnel, tunnelIndex) => { const status = getTunnelStatus(tunnelIndex); const statusDisplay = getTunnelStatusDisplay(status); - const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${ - tunnel.sourcePort - }_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; const isActionLoading = tunnelActions[tunnelName]; const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; @@ -356,9 +352,7 @@ export function TunnelObject({ {host.tunnelConnections.map((tunnel, tunnelIndex) => { const status = getTunnelStatus(tunnelIndex); const statusDisplay = getTunnelStatusDisplay(status); - const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${ - tunnel.sourcePort - }_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; const isActionLoading = tunnelActions[tunnelName]; const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx index 09907831..ba5d4070 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx @@ -7,10 +7,20 @@ import { } from "@/components/ui/form.tsx"; import { Switch } from "@/components/ui/switch.tsx"; import type { HostDockerTabProps } from "./shared/tab-types"; +import { Button } from "@/components/ui/button.tsx"; +import React from "react"; export function HostDockerTab({ control, t }: HostDockerTabProps) { return (
+