diff --git a/README.md b/README.md index b1543a37..bba837ea 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ channel, however, response times may be longer.

Termix Demo 7 +Termix Demo 8

@@ -152,7 +153,7 @@ channel, however, response times may be longer. Your browser does not support the video tag.

-Videos and images may be out of date. +Some videos and images may be out of date or may not perfectly showcase features. # License diff --git a/repo-images/Image 4.png b/repo-images/Image 4.png index e2dfa99a..a47da14f 100644 Binary files a/repo-images/Image 4.png and b/repo-images/Image 4.png differ diff --git a/repo-images/Image 8.png b/repo-images/Image 8.png new file mode 100644 index 00000000..03bf9daf Binary files /dev/null and b/repo-images/Image 8.png differ diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 924b4941..8e7e9086 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -269,12 +269,6 @@ router.post( overrideCredentialUsername, } = hostData; - console.log("POST /db/ssh - Received SOCKS5 data:", { - useSocks5, - socks5Host, - socks5ProxyChain, - }); - if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index a591baa5..f7ea4a5b 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -1,6 +1,7 @@ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; +import axios from "axios"; import { Client as SSHClient } from "ssh2"; import type { ClientChannel } from "ssh2"; import { getDb } from "../database/db/index.js"; @@ -9,6 +10,7 @@ import { eq, and } from "drizzle-orm"; import { logger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; import type { AuthenticatedRequest, SSHHost } from "../../types/index.js"; const dockerLogger = logger; @@ -22,10 +24,41 @@ interface SSHSession { hostId?: number; } +interface PendingTOTPSession { + client: SSHClient; + finish: (responses: string[]) => void; + config: any; + createdAt: number; + sessionId: string; + hostId?: number; + ip?: string; + port?: number; + username?: string; + userId?: string; + prompts?: Array<{ prompt: string; echo: boolean }>; + totpPromptIndex?: number; + resolvedPassword?: string; + totpAttempts: number; +} + const sshSessions: Record = {}; +const pendingTOTPSessions: Record = {}; const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; +setInterval(() => { + const now = Date.now(); + Object.keys(pendingTOTPSessions).forEach((sessionId) => { + const session = pendingTOTPSessions[sessionId]; + if (now - session.createdAt > 180000) { + try { + session.client.end(); + } catch {} + delete pendingTOTPSessions[sessionId]; + } + }); +}, 60000); + function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { @@ -336,7 +369,20 @@ app.use(authManager.createAuthMiddleware()); // POST /docker/ssh/connect - Establish SSH session app.post("/docker/ssh/connect", async (req, res) => { - const { sessionId, hostId } = req.body; + const { + sessionId, + hostId, + userProvidedPassword, + userProvidedSshKey, + userProvidedKeyPassword, + forceKeyboardInteractive, + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, + } = req.body; const userId = (req as any).userId; if (!userId) { @@ -433,6 +479,17 @@ app.post("/docker/ssh/connect", async (req, res) => { authType: host.authType, }; + if (userProvidedPassword) { + resolvedCredentials.password = userProvidedPassword; + } + if (userProvidedSshKey) { + resolvedCredentials.sshKey = userProvidedSshKey; + resolvedCredentials.authType = "key"; + } + if (userProvidedKeyPassword) { + resolvedCredentials.keyPassword = userProvidedKeyPassword; + } + if (host.credentialId) { const ownerId = host.userId; @@ -495,7 +552,9 @@ app.post("/docker/ssh/connect", async (req, res) => { host: host.ip, port: host.port || 22, username: host.username, - tryKeyboard: true, + tryKeyboard: + resolvedCredentials.authType === "none" || + forceKeyboardInteractive === true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, @@ -503,26 +562,64 @@ app.post("/docker/ssh/connect", async (req, res) => { tcpKeepAliveInitialDelay: 30000, }; - if ( - resolvedCredentials.authType === "password" && - resolvedCredentials.password - ) { - config.password = resolvedCredentials.password; + if (resolvedCredentials.authType === "none") { + } else if (resolvedCredentials.authType === "password") { + if (!forceKeyboardInteractive && resolvedCredentials.password) { + config.password = resolvedCredentials.password; + } } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey ) { - const cleanKey = resolvedCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (resolvedCredentials.keyPassword) { - config.passphrase = resolvedCredentials.keyPassword; + try { + if ( + !resolvedCredentials.sshKey.includes("-----BEGIN") || + !resolvedCredentials.sshKey.includes("-----END") + ) { + dockerLogger.error("Invalid SSH key format", { + operation: "docker_connect", + sessionId, + hostId, + }); + return res.status(400).json({ + error: "Invalid private key format", + }); + } + + const cleanKey = resolvedCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedCredentials.keyPassword) { + config.passphrase = resolvedCredentials.keyPassword; + } + } catch (error) { + dockerLogger.error("SSH key processing error", error, { + operation: "docker_connect", + sessionId, + hostId, + }); + return res.status(400).json({ + error: "SSH key format error: Invalid private key format", + }); } + } else if (resolvedCredentials.authType === "key") { + dockerLogger.error( + "SSH key authentication requested but no key provided", + { + operation: "docker_connect", + sessionId, + hostId, + }, + ); + return res.status(400).json({ + error: "SSH key authentication requested but no key provided", + }); } let responseSent = false; + let keyboardInteractiveResponded = false; client.on("ready", () => { if (responseSent) return; @@ -552,10 +649,21 @@ app.post("/docker/ssh/connect", async (req, res) => { userId, }); - res.status(500).json({ - success: false, - message: err.message || "SSH connection failed", - }); + if ( + resolvedCredentials.authType === "none" && + (err.message.includes("authentication") || + err.message.includes("All configured authentication methods failed")) + ) { + res.json({ + status: "auth_required", + reason: "no_keyboard", + }); + } else { + res.status(500).json({ + success: false, + message: err.message || "SSH connection failed", + }); + } }); client.on("close", () => { @@ -565,7 +673,214 @@ app.post("/docker/ssh/connect", async (req, res) => { } }); - if (host.jumpHosts && host.jumpHosts.length > 0) { + client.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + const totpPromptIndex = prompts.findIndex((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPromptIndex !== -1) { + if (pendingTOTPSessions[sessionId]) { + const existingSession = pendingTOTPSessions[sessionId]; + if (existingSession.totpAttempts >= 3) { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + client.end(); + res.status(401).json({ + error: "Maximum TOTP attempts reached", + code: "TOTP_MAX_ATTEMPTS", + }); + } + return; + } + existingSession.totpAttempts++; + if (!responseSent) { + responseSent = true; + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[totpPromptIndex].prompt, + attempts_remaining: 3 - existingSession.totpAttempts, + }); + } + return; + } + + if (responseSent) { + return; + } + responseSent = true; + keyboardInteractiveResponded = true; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + }; + + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[totpPromptIndex].prompt, + }); + } else { + const passwordPromptIndex = prompts.findIndex((p) => + /password/i.test(p.prompt), + ); + + if ( + resolvedCredentials.authType === "none" && + passwordPromptIndex !== -1 + ) { + if (responseSent) return; + responseSent = true; + client.end(); + res.json({ + status: "auth_required", + reason: "no_keyboard", + }); + return; + } + + const hasStoredPassword = + resolvedCredentials.password && + resolvedCredentials.authType !== "none"; + + if (!hasStoredPassword && passwordPromptIndex !== -1) { + if (pendingTOTPSessions[sessionId]) { + const existingSession = pendingTOTPSessions[sessionId]; + if (existingSession.totpAttempts >= 3) { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + client.end(); + res.status(401).json({ + error: "Maximum password attempts reached", + code: "PASSWORD_MAX_ATTEMPTS", + }); + } + return; + } + existingSession.totpAttempts++; + if (!responseSent) { + responseSent = true; + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + isPassword: true, + attempts_remaining: 3 - existingSession.totpAttempts, + }); + } + return; + } + + if (responseSent) return; + responseSent = true; + keyboardInteractiveResponded = true; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex: passwordPromptIndex, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + }; + + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + isPassword: true, + }); + return; + } + + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + } + }, + ); + + if ( + useSocks5 && + (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0)) + ) { + try { + const socks5Socket = await createSocks5Connection( + host.ip, + host.port || 22, + { + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain: socks5ProxyChain as any, + }, + ); + + if (socks5Socket) { + config.sock = socks5Socket; + client.connect(config); + return; + } + } catch (socks5Error) { + dockerLogger.error("SOCKS5 connection failed", socks5Error, { + operation: "docker_socks5_connect", + sessionId, + hostId, + proxyHost: socks5Host, + proxyPort: socks5Port || 1080, + }); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + }); + } + return; + } + } else if (host.jumpHosts && host.jumpHosts.length > 0) { const jumpClient = await createJumpHostChain( host.jumpHosts as Array<{ hostId: number }>, userId, @@ -633,6 +948,169 @@ app.post("/docker/ssh/disconnect", async (req, res) => { res.json({ success: true, message: "SSH session disconnected" }); }); +// POST /docker/ssh/connect-totp - Verify TOTP and complete connection +app.post("/docker/ssh/connect-totp", async (req, res) => { + const { sessionId, totpCode } = req.body; + const userId = (req as any).userId; + + if (!userId) { + dockerLogger.error("TOTP verification rejected: no authenticated user", { + operation: "docker_totp_auth", + sessionId, + }); + return res.status(401).json({ error: "Authentication required" }); + } + + if (!sessionId || !totpCode) { + return res.status(400).json({ error: "Session ID and TOTP code required" }); + } + + const session = pendingTOTPSessions[sessionId]; + + if (!session) { + dockerLogger.warn("TOTP session not found or expired", { + operation: "docker_totp_verify", + sessionId, + userId, + availableSessions: Object.keys(pendingTOTPSessions), + }); + return res + .status(404) + .json({ error: "TOTP session expired. Please reconnect." }); + } + + if (Date.now() - session.createdAt > 180000) { + delete pendingTOTPSessions[sessionId]; + try { + session.client.end(); + } catch {} + dockerLogger.warn("TOTP session timeout before code submission", { + operation: "docker_totp_verify", + sessionId, + userId, + age: Date.now() - session.createdAt, + }); + return res + .status(408) + .json({ error: "TOTP session timeout. Please reconnect." }); + } + + const responses = (session.prompts || []).map((p, index) => { + if (index === session.totpPromptIndex) { + return totpCode; + } + if (/password/i.test(p.prompt) && session.resolvedPassword) { + return session.resolvedPassword; + } + return ""; + }); + + let responseSent = false; + let responseTimeout: NodeJS.Timeout; + + session.client.once("ready", () => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + setTimeout(() => { + sshSessions[sessionId] = { + client: session.client, + isConnected: true, + lastActive: Date.now(), + activeOperations: 0, + hostId: session.hostId, + }; + scheduleSessionCleanup(sessionId); + + res.json({ + status: "success", + message: "TOTP verified, SSH connection established", + }); + + if (session.hostId && session.userId) { + (async () => { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where( + and( + eq(sshData.id, session.hostId!), + eq(sshData.userId, session.userId!), + ), + ), + "ssh_data", + session.userId!, + ); + + const hostName = + hosts.length > 0 && hosts[0].name + ? hosts[0].name + : `${session.username}@${session.ip}:${session.port}`; + + await axios.post( + "http://localhost:30006/activity/log", + { + type: "docker", + hostId: session.hostId, + hostName, + }, + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, + }, + }, + ); + } catch (error) { + dockerLogger.warn("Failed to log Docker activity (TOTP)", { + operation: "activity_log_error", + userId: session.userId, + hostId: session.hostId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, 200); + }); + + session.client.once("error", (err) => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + dockerLogger.error("TOTP verification failed", { + operation: "docker_totp_verify", + sessionId, + userId, + error: err.message, + }); + + res.status(401).json({ status: "error", message: "Invalid TOTP code" }); + }); + + responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + dockerLogger.warn("TOTP verification timeout", { + operation: "docker_totp_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "TOTP verification timeout" }); + } + }, 60000); + + session.finish(responses); +}); + // POST /docker/ssh/keepalive - Keep session alive app.post("/docker/ssh/keepalive", async (req, res) => { const { sessionId } = req.body; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 939acb21..ee4ca96a 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -279,6 +279,7 @@ interface PendingTOTPSession { prompts?: Array<{ prompt: string; echo: boolean }>; totpPromptIndex?: number; resolvedPassword?: string; + totpAttempts: number; } const sshSessions: Record = {}; @@ -449,7 +450,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { host: ip, port, username, - tryKeyboard: true, + tryKeyboard: + resolvedCredentials.authType === "none" || + forceKeyboardInteractive === true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, @@ -681,29 +684,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); if (totpPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; + if (pendingTOTPSessions[sessionId]) { + const existingSession = pendingTOTPSessions[sessionId]; + if (existingSession.totpAttempts >= 3) { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + client.end(); + res.status(401).json({ + error: "Maximum TOTP attempts reached", + code: "TOTP_MAX_ATTEMPTS", + }); } - return ""; - }); - finish(responses); + return; + } + existingSession.totpAttempts++; + if (!responseSent) { + responseSent = true; + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[totpPromptIndex].prompt, + attempts_remaining: 3 - existingSession.totpAttempts, + }); + } + return; + } + + if (responseSent) { return; } responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - keyboardInteractiveResponded = true; pendingTOTPSessions[sessionId] = { @@ -720,6 +731,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { prompts, totpPromptIndex, resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, }; res.json({ @@ -753,29 +765,38 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } if (!hasStoredPassword && passwordPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; + if (pendingTOTPSessions[sessionId]) { + const existingSession = pendingTOTPSessions[sessionId]; + if (existingSession.totpAttempts >= 3) { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + client.end(); + res.status(401).json({ + error: "Maximum password attempts reached", + code: "PASSWORD_MAX_ATTEMPTS", + }); } - return ""; - }); - finish(responses); + return; + } + existingSession.totpAttempts++; + if (!responseSent) { + responseSent = true; + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + isPassword: true, + attempts_remaining: 3 - existingSession.totpAttempts, + }); + } + return; + } + + if (responseSent) { return; } responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - keyboardInteractiveResponded = true; pendingTOTPSessions[sessionId] = { @@ -792,6 +813,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { prompts, totpPromptIndex: passwordPromptIndex, resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, }; res.json({ diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 83f622b2..a6ed59c7 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -331,6 +331,8 @@ wss.on("connection", async (ws: WebSocket, req) => { let sshStream: ClientChannel | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; + let totpAttempts = 0; + let totpTimeout: NodeJS.Timeout | null = null; let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; let isConnecting = false; @@ -447,9 +449,13 @@ wss.on("connection", async (ws: WebSocket, req) => { case "totp_response": { const totpData = data as TOTPResponseData; if (keyboardInteractiveFinish && totpData?.code) { + if (totpTimeout) { + clearTimeout(totpTimeout); + totpTimeout = null; + } const totpCode = totpData.code; + totpAttempts++; keyboardInteractiveFinish([totpCode]); - keyboardInteractiveFinish = null; } else { sshLogger.warn("TOTP response received but no callback available", { operation: "totp_response_error", @@ -470,9 +476,12 @@ wss.on("connection", async (ws: WebSocket, req) => { case "password_response": { const passwordData = data as TOTPResponseData; if (keyboardInteractiveFinish && passwordData?.code) { + if (totpTimeout) { + clearTimeout(totpTimeout); + totpTimeout = null; + } const password = passwordData.code; keyboardInteractiveFinish([password]); - keyboardInteractiveFinish = null; } else { sshLogger.warn( "Password response received but no callback available", @@ -609,6 +618,13 @@ wss.on("connection", async (ws: WebSocket, req) => { isConnecting, isConnected, }); + ws.send( + JSON.stringify({ + type: "error", + message: "Connection already in progress", + code: "DUPLICATE_CONNECTION", + }), + ); return; } @@ -972,11 +988,29 @@ wss.on("connection", async (ws: WebSocket, req) => { if (totpPromptIndex !== -1) { if (totpPromptSent) { - sshLogger.warn("TOTP prompt asked again - ignoring duplicate", { - operation: "ssh_keyboard_interactive_totp_duplicate", - hostId: id, - prompts: promptTexts, - }); + if (totpAttempts >= 3) { + sshLogger.error("TOTP maximum attempts reached", { + operation: "ssh_keyboard_interactive_totp_max_attempts", + hostId: id, + attempts: totpAttempts, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Maximum TOTP attempts reached", + code: "TOTP_MAX_ATTEMPTS", + }), + ); + cleanupSSH(); + return; + } + ws.send( + JSON.stringify({ + type: "totp_retry", + attempts_remaining: 3 - totpAttempts, + prompt: prompts[totpPromptIndex].prompt, + }), + ); return; } totpPromptSent = true; @@ -997,6 +1031,23 @@ wss.on("connection", async (ws: WebSocket, req) => { finish(responses); }; + + totpTimeout = setTimeout(() => { + if (keyboardInteractiveFinish) { + keyboardInteractiveFinish = null; + totpPromptSent = false; + totpAttempts = 0; + ws.send( + JSON.stringify({ + type: "error", + message: "TOTP verification timeout", + code: "TOTP_TIMEOUT", + }), + ); + cleanupSSH(); + } + }, 180000); + ws.send( JSON.stringify({ type: "totp_required", @@ -1056,7 +1107,9 @@ wss.on("connection", async (ws: WebSocket, req) => { host: ip, port, username, - tryKeyboard: true, + tryKeyboard: + resolvedCredentials.authType === "none" || + hostConfig.forceKeyboardInteractive === true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 30000, @@ -1356,16 +1409,19 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn = null; } + if (totpTimeout) { + clearTimeout(totpTimeout); + totpTimeout = null; + } + totpPromptSent = false; + totpAttempts = 0; isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; isConnecting = false; isConnected = false; - - setTimeout(() => { - isCleaningUp = false; - }, 100); + isCleaningUp = false; } // Note: PTY-level keepalive (writing \x00 to the stream) was removed. diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 70e06747..fbef615b 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -349,11 +349,11 @@ function resetRetryState(tunnelName: string): void { }); } -function handleDisconnect( +async function handleDisconnect( tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, -): void { +): Promise { if (tunnelVerifications.has(tunnelName)) { try { const verification = tunnelVerifications.get(tunnelName); @@ -363,7 +363,11 @@ function handleDisconnect( tunnelVerifications.delete(tunnelName); } - cleanupTunnelResources(tunnelName); + while (cleanupInProgress.has(tunnelName)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await cleanupTunnelResources(tunnelName); if (manualDisconnects.has(tunnelName)) { resetRetryState(tunnelName); diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts index 50f36df9..a3894e74 100644 --- a/src/backend/ssh/widgets/login-stats-collector.ts +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -70,12 +70,7 @@ export async function collectLoginStats(client: Client): Promise { } } } - } catch (e) { - statsLogger.debug("Failed to collect recent login stats", { - operation: "recent_login_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } + } catch (e) {} try { const failedOut = await execCommand( @@ -131,12 +126,7 @@ export async function collectLoginStats(client: Client): Promise { } } } - } catch (e) { - statsLogger.debug("Failed to collect failed login stats", { - operation: "failed_login_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } + } catch (e) {} return { recentLogins: recentLogins.slice(0, 10), diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts index bd3a3bd9..c24b75e6 100644 --- a/src/backend/ssh/widgets/network-collector.ts +++ b/src/backend/ssh/widgets/network-collector.ts @@ -68,12 +68,7 @@ export async function collectNetworkMetrics(client: Client): Promise<{ txBytes: null, }); } - } catch (e) { - statsLogger.debug("Failed to collect network interface stats", { - operation: "network_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } + } catch (e) {} return { interfaces }; } diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts index 221ab177..09d62612 100644 --- a/src/backend/ssh/widgets/processes-collector.ts +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -54,12 +54,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{ const runningCount2 = Number(runningCount.stdout.trim()); runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null; - } catch (e) { - statsLogger.debug("Failed to collect process stats", { - operation: "process_stats_failed", - error: e instanceof Error ? e.message : String(e), - }); - } + } catch (e) {} return { total: totalProcesses, diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts index e62c3ed0..c5007d55 100644 --- a/src/backend/ssh/widgets/system-collector.ts +++ b/src/backend/ssh/widgets/system-collector.ts @@ -23,10 +23,7 @@ export async function collectSystemMetrics(client: Client): Promise<{ kernel = kernelOut.stdout.trim() || null; os = osOut.stdout.trim() || null; } catch (e) { - statsLogger.debug("Failed to collect system info", { - operation: "system_info_failed", - error: e instanceof Error ? e.message : String(e), - }); + // No error log } return { diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts index 87e8dfcc..3571b8a0 100644 --- a/src/backend/ssh/widgets/uptime-collector.ts +++ b/src/backend/ssh/widgets/uptime-collector.ts @@ -21,12 +21,7 @@ export async function collectUptimeMetrics(client: Client): Promise<{ uptimeFormatted = `${days}d ${hours}h ${minutes}m`; } } - } catch (e) { - statsLogger.debug("Failed to collect uptime", { - operation: "uptime_failed", - error: e instanceof Error ? e.message : String(e), - }); - } + } catch (e) {} return { seconds: uptimeSeconds, diff --git a/src/hooks/use-confirmation.ts b/src/hooks/use-confirmation.ts index 3362768a..eca415b4 100644 --- a/src/hooks/use-confirmation.ts +++ b/src/hooks/use-confirmation.ts @@ -47,12 +47,12 @@ export function useConfirmation() { variantOrConfirmLabel === "destructive"; const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel; - if (typeof opts === "string" && callback) { + if (typeof opts === "string") { toast(opts, { action: { label: confirmLabel, onClick: () => { - callback(); + if (callback) callback(); resolve(true); }, }, @@ -63,7 +63,7 @@ export function useConfirmation() { }, }, } as any); - } else if (typeof opts === "object" && callback) { + } else if (typeof opts === "object") { const actualConfirmLabel = opts.confirmText || confirmLabel; const actualCancelLabel = opts.cancelText || cancelLabel; @@ -71,7 +71,7 @@ export function useConfirmation() { action: { label: actualConfirmLabel, onClick: () => { - callback(); + if (callback) callback(); resolve(true); }, }, diff --git a/src/locales/en.json b/src/locales/en.json index a130a044..697fd477 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -473,6 +473,7 @@ "hosts": "Hosts", "credentials": "Credentials", "terminal": "Terminal", + "docker": "Docker", "tunnels": "Tunnels", "fileManager": "File Manager", "serverStats": "Server Stats", @@ -2402,6 +2403,8 @@ "disconnectedFromContainer": "Disconnected from container", "containerMustBeRunning": "Container must be running to access console", "authenticationRequired": "Authentication required", + "verificationCodePrompt": "Enter verification code", + "totpVerificationFailed": "TOTP verification failed. Please try again.", "connectedTo": "Connected to {{containerName}}", "disconnected": "Disconnected", "consoleError": "Console error", diff --git a/src/types/index.ts b/src/types/index.ts index 945f1927..bf0ecb1a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -552,6 +552,7 @@ export interface AlertManagerProps { export interface SSHTunnelObjectProps { host: SSHHost; + tunnelIndex?: number; tunnelStatuses: Record; tunnelActions: Record; onTunnelAction: ( diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 13ddf982..24451ae8 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -196,7 +196,7 @@ function AppContent() { style={{ background: "var(--bg-elevated)", backgroundImage: `repeating-linear-gradient( - 225deg, + 45deg, transparent, transparent 35px, ${lineColor} 35px, diff --git a/src/ui/desktop/apps/admin/tabs/RolesTab.tsx b/src/ui/desktop/apps/admin/tabs/RolesTab.tsx index 2654772a..9264fb31 100644 --- a/src/ui/desktop/apps/admin/tabs/RolesTab.tsx +++ b/src/ui/desktop/apps/admin/tabs/RolesTab.tsx @@ -136,7 +136,6 @@ export function RolesTab(): React.ReactElement { return (
- {/* Roles Section */}

@@ -149,6 +148,15 @@ export function RolesTab(): React.ReactElement {

+ + diff --git a/src/ui/desktop/apps/features/docker/DockerManager.tsx b/src/ui/desktop/apps/features/docker/DockerManager.tsx index e181c6b0..9dbdb76f 100644 --- a/src/ui/desktop/apps/features/docker/DockerManager.tsx +++ b/src/ui/desktop/apps/features/docker/DockerManager.tsx @@ -16,12 +16,15 @@ import { listDockerContainers, validateDockerAvailability, keepaliveDockerSession, + verifyDockerTOTP, } from "@/ui/main-axios.ts"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { AlertCircle } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; import { ContainerList } from "./components/ContainerList.tsx"; import { ContainerDetail } from "./components/ContainerDetail.tsx"; +import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; +import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; interface DockerManagerProps { hostConfig?: SSHHost; @@ -53,6 +56,13 @@ export function DockerManager({ const [isValidating, setIsValidating] = React.useState(false); const [viewMode, setViewMode] = React.useState<"list" | "detail">("list"); const [isLoadingContainers, setIsLoadingContainers] = React.useState(false); + const [totpRequired, setTotpRequired] = React.useState(false); + const [totpSessionId, setTotpSessionId] = React.useState(null); + const [totpPrompt, setTotpPrompt] = React.useState(""); + const [showAuthDialog, setShowAuthDialog] = React.useState(false); + const [authReason, setAuthReason] = React.useState< + "no_keyboard" | "auth_failed" | "timeout" + >("no_keyboard"); React.useEffect(() => { if (hostConfig?.id !== currentHostConfig?.id) { @@ -103,17 +113,52 @@ export function DockerManager({ window.removeEventListener("ssh-hosts:changed", handleHostsChanged); }, [hostConfig?.id]); + const initializingRef = React.useRef(false); + React.useEffect(() => { const initSession = async () => { if (!currentHostConfig?.id || !currentHostConfig.enableDocker) { return; } + if (initializingRef.current) return; + initializingRef.current = true; + + if (sessionId) { + initializingRef.current = false; + return; + } + setIsConnecting(true); const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; try { - await connectDockerSession(sid, currentHostConfig.id); + const result = await connectDockerSession(sid, currentHostConfig.id, { + useSocks5: currentHostConfig.useSocks5, + socks5Host: currentHostConfig.socks5Host, + socks5Port: currentHostConfig.socks5Port, + socks5Username: currentHostConfig.socks5Username, + socks5Password: currentHostConfig.socks5Password, + socks5ProxyChain: currentHostConfig.socks5ProxyChain, + }); + + if (result?.requires_totp) { + setTotpRequired(true); + setTotpSessionId(sid); + setTotpPrompt(result.prompt || t("docker.verificationCodePrompt")); + setIsConnecting(false); + return; + } + + if (result?.status === "auth_required") { + setShowAuthDialog(true); + setAuthReason( + result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed", + ); + setIsConnecting(false); + return; + } + setSessionId(sid); setIsValidating(true); @@ -140,6 +185,7 @@ export function DockerManager({ initSession(); return () => { + initializingRef.current = false; if (sessionId) { disconnectDockerSession(sessionId).catch(() => { // Silently handle disconnect errors @@ -208,6 +254,109 @@ export function DockerManager({ setSelectedContainer(null); }, []); + const handleTotpSubmit = async (code: string) => { + if (!totpSessionId || !code) return; + + try { + setIsConnecting(true); + const result = await verifyDockerTOTP(totpSessionId, code); + + if (result?.status === "success") { + setTotpRequired(false); + setTotpPrompt(""); + setSessionId(totpSessionId); + setTotpSessionId(null); + + setIsValidating(true); + const validation = await validateDockerAvailability(totpSessionId); + setDockerValidation(validation); + setIsValidating(false); + + if (!validation.available) { + toast.error( + validation.error || "Docker is not available on this host", + ); + } + } + } catch (error) { + console.error("TOTP verification failed:", error); + toast.error(t("docker.totpVerificationFailed")); + } finally { + setIsConnecting(false); + } + }; + + const handleTotpCancel = () => { + setTotpRequired(false); + setTotpSessionId(null); + setTotpPrompt(""); + setIsConnecting(false); + }; + + const handleAuthSubmit = async (credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => { + if (!currentHostConfig?.id) return; + + setShowAuthDialog(false); + setIsConnecting(true); + + const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + try { + const result = await connectDockerSession(sid, currentHostConfig.id, { + userProvidedPassword: credentials.password, + userProvidedSshKey: credentials.sshKey, + userProvidedKeyPassword: credentials.keyPassword, + useSocks5: currentHostConfig.useSocks5, + socks5Host: currentHostConfig.socks5Host, + socks5Port: currentHostConfig.socks5Port, + socks5Username: currentHostConfig.socks5Username, + socks5Password: currentHostConfig.socks5Password, + socks5ProxyChain: currentHostConfig.socks5ProxyChain, + }); + + if (result?.requires_totp) { + setTotpRequired(true); + setTotpSessionId(sid); + setTotpPrompt(result.prompt || t("docker.verificationCodePrompt")); + setIsConnecting(false); + return; + } + + if (result?.status === "auth_required") { + setShowAuthDialog(true); + setAuthReason("auth_failed"); + setIsConnecting(false); + return; + } + + setSessionId(sid); + + setIsValidating(true); + const validation = await validateDockerAvailability(sid); + setDockerValidation(validation); + setIsValidating(false); + + if (!validation.available) { + toast.error(validation.error || "Docker is not available on this host"); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to connect"); + setIsConnecting(false); + setIsValidating(false); + } finally { + setIsConnecting(false); + } + }; + + const handleAuthCancel = () => { + setShowAuthDialog(false); + setIsConnecting(false); + }; + const topMarginPx = isTopbarOpen ? 74 : 16; const leftMarginPx = sidebarState === "collapsed" ? 16 : 8; const bottomMarginPx = 8; @@ -384,6 +533,26 @@ export function DockerManager({ )} + + {currentHostConfig && ( + + )} ); } diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index 5fdc286b..fc6d7a5c 100644 --- a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx @@ -469,6 +469,27 @@ export function ServerStats({ {t("nav.fileManager")} )} + + {currentHostConfig?.enableDocker && ( + + )} diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 41bd795b..8bb5a962 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -144,6 +144,9 @@ export const Terminal = forwardRef( const shouldNotReconnectRef = useRef(false); const isReconnectingRef = useRef(false); const isConnectingRef = useRef(false); + const connectionAttemptIdRef = useRef(0); + const totpAttemptsRef = useRef(0); + const totpTimeoutRef = useRef(null); const connectionTimeoutRef = useRef(null); const activityLoggedRef = useRef(false); const keyHandlerAttachedRef = useRef(false); @@ -372,6 +375,10 @@ export const Terminal = forwardRef( function handleTotpSubmit(code: string) { if (webSocketRef.current && code) { + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + totpTimeoutRef.current = null; + } webSocketRef.current.send( JSON.stringify({ type: isPasswordPrompt ? "password_response" : "totp_response", @@ -385,6 +392,10 @@ export const Terminal = forwardRef( } function handleTotpCancel() { + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + totpTimeoutRef.current = null; + } setTotpRequired(false); setTotpPrompt(""); if (onClose) onClose(); @@ -461,6 +472,10 @@ export const Terminal = forwardRef( clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + totpTimeoutRef.current = null; + } webSocketRef.current?.close(); setIsConnected(false); setIsConnecting(false); @@ -530,6 +545,11 @@ export const Terminal = forwardRef( }), ); + const delay = Math.min( + 2000 * Math.pow(2, reconnectAttempts.current - 1), + 8000, + ); + reconnectTimeoutRef.current = setTimeout(() => { if ( isUnmountingRef.current || @@ -561,7 +581,7 @@ export const Terminal = forwardRef( } isReconnectingRef.current = false; - }, 2000 * reconnectAttempts.current); + }, delay); } function connectToHost(cols: number, rows: number) { @@ -570,6 +590,11 @@ export const Terminal = forwardRef( } isConnectingRef.current = true; + connectionAttemptIdRef.current++; + + if (!isReconnectingRef.current) { + reconnectAttempts.current = 0; + } const isDev = !isElectron() && @@ -652,7 +677,7 @@ export const Terminal = forwardRef( attemptReconnection(); } } - }, 15000); + }, 35000); ws.send( JSON.stringify({ @@ -852,6 +877,7 @@ export const Terminal = forwardRef( onClose(); } } else if (msg.type === "totp_required") { + totpAttemptsRef.current = 0; setTotpRequired(true); setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel")); setIsPasswordPrompt(false); @@ -859,7 +885,25 @@ export const Terminal = forwardRef( clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + } + totpTimeoutRef.current = setTimeout(() => { + setTotpRequired(false); + toast.error(t("terminal.totpTimeout")); + if (webSocketRef.current) { + webSocketRef.current.close(); + } + }, 180000); + } else if (msg.type === "totp_retry") { + totpAttemptsRef.current++; + const attemptsRemaining = + msg.attempts_remaining || 3 - totpAttemptsRef.current; + toast.error( + `Invalid code. ${attemptsRemaining} ${attemptsRemaining === 1 ? "attempt" : "attempts"} remaining.`, + ); } else if (msg.type === "password_required") { + totpAttemptsRef.current = 0; setTotpRequired(true); setTotpPrompt(msg.prompt || t("common.password")); setIsPasswordPrompt(true); @@ -867,6 +911,16 @@ export const Terminal = forwardRef( clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + } + totpTimeoutRef.current = setTimeout(() => { + setTotpRequired(false); + toast.error(t("terminal.passwordTimeout")); + if (webSocketRef.current) { + webSocketRef.current.close(); + } + }, 180000); } else if (msg.type === "keyboard_interactive_available") { setKeyboardInteractiveDetected(true); setIsConnecting(false); @@ -888,13 +942,24 @@ export const Terminal = forwardRef( } }); + const currentAttemptId = connectionAttemptIdRef.current; + ws.addEventListener("close", (event) => { + if (currentAttemptId !== connectionAttemptIdRef.current) { + return; + } + setIsConnected(false); isConnectingRef.current = false; if (terminal) { terminal.clear(); } + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + totpTimeoutRef.current = null; + } + if (event.code === 1008) { console.error("WebSocket authentication failed:", event.reason); setConnectionError("Authentication failed - please re-login"); @@ -914,7 +979,8 @@ export const Terminal = forwardRef( if ( !wasDisconnectedBySSH.current && !isUnmountingRef.current && - !shouldNotReconnectRef.current + !shouldNotReconnectRef.current && + !isConnectingRef.current ) { wasDisconnectedBySSH.current = false; attemptReconnection(); @@ -922,6 +988,10 @@ export const Terminal = forwardRef( }); ws.addEventListener("error", () => { + if (currentAttemptId !== connectionAttemptIdRef.current) { + return; + } + setIsConnected(false); isConnectingRef.current = false; setConnectionError(t("terminal.websocketError")); @@ -929,7 +999,17 @@ export const Terminal = forwardRef( terminal.clear(); } setIsConnecting(false); - if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { + + if (totpTimeoutRef.current) { + clearTimeout(totpTimeoutRef.current); + totpTimeoutRef.current = null; + } + + if ( + !isUnmountingRef.current && + !shouldNotReconnectRef.current && + !isConnectingRef.current + ) { wasDisconnectedBySSH.current = false; attemptReconnection(); } @@ -1245,6 +1325,7 @@ export const Terminal = forwardRef( clearTimeout(reconnectTimeoutRef.current); if (connectionTimeoutRef.current) clearTimeout(connectionTimeoutRef.current); + if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current); if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; diff --git a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx index 0325df54..bff07a55 100644 --- a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx +++ b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx @@ -116,7 +116,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { useEffect(() => { fetchTunnelStatuses(); - const interval = setInterval(fetchTunnelStatuses, 5000); + const interval = setInterval(fetchTunnelStatuses, 1000); return () => clearInterval(interval); }, [fetchTunnelStatuses]); diff --git a/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx b/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx index ebbeeb2e..979bb040 100644 --- a/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx +++ b/src/ui/desktop/apps/features/tunnel/TunnelObject.tsx @@ -24,6 +24,7 @@ import type { export function TunnelObject({ host, + tunnelIndex, tunnelStatuses, tunnelActions, onTunnelAction, @@ -32,9 +33,9 @@ export function TunnelObject({ }: SSHTunnelObjectProps): React.ReactElement { const { t } = useTranslation(); - const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { - const tunnel = host.tunnelConnections[tunnelIndex]; - const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; + const getTunnelStatus = (idx: number): TunnelStatus | undefined => { + const tunnel = host.tunnelConnections[idx]; + const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; return tunnelStatuses[tunnelName]; }; @@ -116,10 +117,14 @@ export function TunnelObject({
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
- {host.tunnelConnections.map((tunnel, tunnelIndex) => { - const status = getTunnelStatus(tunnelIndex); + {(tunnelIndex !== undefined + ? [tunnelIndex] + : host.tunnelConnections.map((_, idx) => idx) + ).map((idx) => { + const tunnel = host.tunnelConnections[idx]; + const status = getTunnelStatus(idx); const statusDisplay = getTunnelStatusDisplay(status); - const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; const isActionLoading = tunnelActions[tunnelName]; const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; @@ -131,7 +136,7 @@ export function TunnelObject({ return (
@@ -162,11 +167,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction( - "disconnect", - host, - tunnelIndex, - ) + onTunnelAction("disconnect", host, idx) } className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > @@ -179,7 +180,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction("cancel", host, tunnelIndex) + onTunnelAction("cancel", host, idx) } className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > @@ -191,7 +192,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction("connect", host, tunnelIndex) + onTunnelAction("connect", host, idx) } disabled={isConnecting || isDisconnecting} className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs" @@ -344,15 +345,20 @@ export function TunnelObject({ {!compact && (

- {t("tunnels.tunnelConnections")} ({host.tunnelConnections.length}) + {t("tunnels.tunnelConnections")} ( + {tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})

)} {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
- {host.tunnelConnections.map((tunnel, tunnelIndex) => { - const status = getTunnelStatus(tunnelIndex); + {(tunnelIndex !== undefined + ? [tunnelIndex] + : host.tunnelConnections.map((_, idx) => idx) + ).map((idx) => { + const tunnel = host.tunnelConnections[idx]; + const status = getTunnelStatus(idx); const statusDisplay = getTunnelStatusDisplay(status); - const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; + const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`; const isActionLoading = tunnelActions[tunnelName]; const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; @@ -364,7 +370,7 @@ export function TunnelObject({ return (
@@ -395,11 +401,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction( - "disconnect", - host, - tunnelIndex, - ) + onTunnelAction("disconnect", host, idx) } className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > @@ -412,7 +414,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction("cancel", host, tunnelIndex) + onTunnelAction("cancel", host, idx) } className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > @@ -424,7 +426,7 @@ export function TunnelObject({ size="sm" variant="outline" onClick={() => - onTunnelAction("connect", host, tunnelIndex) + onTunnelAction("connect", host, idx) } disabled={isConnecting || isDisconnecting} className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs" diff --git a/src/ui/desktop/apps/features/tunnel/TunnelViewer.tsx b/src/ui/desktop/apps/features/tunnel/TunnelViewer.tsx index 51ef337e..f63324b9 100644 --- a/src/ui/desktop/apps/features/tunnel/TunnelViewer.tsx +++ b/src/ui/desktop/apps/features/tunnel/TunnelViewer.tsx @@ -47,16 +47,12 @@ export function TunnelViewer({
{activeHost.tunnelConnections.map((t, idx) => ( - onTunnelAction(action, activeHost, idx) - } + onTunnelAction={onTunnelAction} compact bare /> diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx index d66f3bf1..0f94d9e5 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx @@ -279,6 +279,17 @@ export function HostSharingTab({ {t("rbac.shareHost")} + + setShareType(v as "user" | "role")} diff --git a/src/ui/desktop/navigation/TOTPDialog.tsx b/src/ui/desktop/navigation/TOTPDialog.tsx index b871e97f..ae9d292e 100644 --- a/src/ui/desktop/navigation/TOTPDialog.tsx +++ b/src/ui/desktop/navigation/TOTPDialog.tsx @@ -30,14 +30,13 @@ export function TOTPDialog({ className="absolute inset-0 bg-canvas rounded-md" style={{ backgroundColor: backgroundColor || undefined }} /> -
+

{t("terminal.totpRequired")}

-

{prompt}

{ e.preventDefault(); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 28a7bfa2..a2852a37 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -3337,15 +3337,57 @@ export async function revokeHostAccess( export async function connectDockerSession( sessionId: string, hostId: number, -): Promise<{ success: boolean; message: string }> { + config?: { + userProvidedPassword?: string; + userProvidedSshKey?: string; + userProvidedKeyPassword?: string; + forceKeyboardInteractive?: boolean; + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: unknown; + }, +): Promise<{ + success?: boolean; + message?: string; + requires_totp?: boolean; + prompt?: string; + isPassword?: boolean; + status?: string; + reason?: string; +}> { try { const response = await dockerApi.post("/ssh/connect", { sessionId, hostId, + ...config, + }); + return response.data; + } catch (error: any) { + if (error.response?.data?.status === "auth_required") { + return error.response.data; + } + if (error.response?.data?.requires_totp) { + return error.response.data; + } + throw handleApiError(error, "connect to Docker SSH session"); + } +} + +export async function verifyDockerTOTP( + sessionId: string, + totpCode: string, +): Promise<{ status: string; message: string }> { + try { + const response = await dockerApi.post("/ssh/connect-totp", { + sessionId, + totpCode, }); return response.data; } catch (error) { - throw handleApiError(error, "connect to Docker SSH session"); + throw handleApiError(error, "verify Docker TOTP"); } }