From eda60ae3b6eafb7cbbd3ff1d7eb5959e4db9a383 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 18 Oct 2025 15:15:45 -0500 Subject: [PATCH] feat: Improve dashboard API, improve tab system, various other fixes --- .gitignore | 1 + src/backend/database/db/index.ts | 11 + src/backend/homepage.ts | 6 +- src/backend/ssh/file-manager.ts | 67 ++++- src/backend/ssh/terminal.ts | 282 +++++++++--------- src/types/index.ts | 2 + src/ui/Desktop/Apps/Dashboard/Dashboard.tsx | 80 ++++- .../Desktop/Apps/File Manager/FileManager.tsx | 19 ++ .../Desktop/Apps/Host Manager/HostManager.tsx | 3 +- src/ui/Desktop/Apps/Terminal/Terminal.tsx | 11 +- src/ui/Desktop/DesktopApp.tsx | 1 + src/ui/Desktop/Navigation/Tabs/Tab.tsx | 43 +-- src/ui/Desktop/Navigation/TopNavbar.tsx | 266 +++++++++++------ src/ui/main-axios.ts | 2 +- 14 files changed, 502 insertions(+), 292 deletions(-) diff --git a/.gitignore b/.gitignore index a3188d42..67859b45 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ dist-ssr /.claude/ /ssl/ .env +/.mcp.json diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index acb67d36..c5e44bc3 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -255,6 +255,17 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ); + CREATE TABLE IF NOT EXISTS recent_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + host_id INTEGER NOT NULL, + host_name TEXT NOT NULL, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); + `); migrateSchema(); diff --git a/src/backend/homepage.ts b/src/backend/homepage.ts index e36e8804..82e1c95d 100644 --- a/src/backend/homepage.ts +++ b/src/backend/homepage.ts @@ -189,7 +189,7 @@ app.post("/activity/log", async (req, res) => { hostName, }, userId, - )) as { id: number }; + )) as unknown as { id: number }; // Keep only the last 100 activities per user to prevent bloat const allActivities = await SimpleDBOps.select( @@ -253,10 +253,6 @@ const PORT = 30006; app.listen(PORT, async () => { try { await authManager.initialize(); - homepageLogger.success(`Homepage API listening on port ${PORT}`, { - operation: "server_start", - port: PORT, - }); } catch (err) { homepageLogger.error("Failed to initialize AuthManager", err, { operation: "auth_init_error", diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index b3bb9b3d..9f3d83cb 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -107,6 +107,9 @@ interface PendingTOTPSession { port?: number; username?: string; userId?: string; + prompts?: Array<{ prompt: string; echo: boolean }>; + totpPromptIndex?: number; + resolvedPassword?: string; } const sshSessions: Record = {}; @@ -459,30 +462,28 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { promptsCount: prompts.length, }); - const totpPrompt = prompts.find((p) => + const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); - if (totpPrompt) { + if (totpPromptIndex !== -1) { if (responseSent) return; responseSent = true; if (pendingTOTPSessions[sessionId]) { fileLogger.warn( - "TOTP session already exists, cleaning up old client", + "TOTP session already exists, ignoring duplicate keyboard-interactive", { operation: "file_keyboard_interactive", hostId, sessionId, }, ); - try { - pendingTOTPSessions[sessionId].client.end(); - } catch (e) { - // Ignore cleanup errors - } + // Don't respond to duplicate keyboard-interactive events + // The first one is still being processed + return; } pendingTOTPSessions[sessionId] = { @@ -496,19 +497,23 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { port, username, userId, + prompts, + totpPromptIndex, + resolvedPassword: resolvedCredentials.password, }; fileLogger.info("Created TOTP session", { operation: "file_keyboard_interactive_totp", hostId, sessionId, - prompt: totpPrompt.prompt, + prompt: prompts[totpPromptIndex].prompt, + promptsCount: prompts.length, }); res.json({ requires_totp: true, sessionId, - prompt: totpPrompt.prompt, + prompt: prompts[totpPromptIndex].prompt, }); } else { if (resolvedCredentials.password) { @@ -580,15 +585,40 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { sessionId, userId, codeLength: totpCode.length, + promptsCount: session.prompts?.length || 0, }); - session.finish([totpCode]); + // Build responses for ALL prompts, just like in terminal.ts + 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 ""; + }); + + fileLogger.info("Full keyboard-interactive response for file manager", { + operation: "file_totp_full_response", + sessionId, + userId, + totalPrompts: session.prompts?.length || 0, + responsesProvided: responses.filter((r) => r !== "").length, + }); let responseSent = false; + let responseTimeout: NodeJS.Timeout; - session.client.on("ready", () => { + // Remove old event listeners from /connect endpoint to avoid conflicts + session.client.removeAllListeners("ready"); + session.client.removeAllListeners("error"); + + // CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition + session.client.once("ready", () => { if (responseSent) return; responseSent = true; + clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; @@ -666,9 +696,10 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { } }); - session.client.on("error", (err) => { + session.client.once("error", (err) => { if (responseSent) return; responseSent = true; + clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; @@ -682,13 +713,21 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { res.status(401).json({ status: "error", message: "Invalid TOTP code" }); }); - setTimeout(() => { + responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; + fileLogger.warn("TOTP verification timeout", { + operation: "file_totp_verify", + sessionId, + userId, + }); res.status(408).json({ error: "TOTP verification timeout" }); } }, 60000); + + // Now that event listeners are attached, submit the TOTP response + session.finish(responses); }); app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 303d6344..8c90f15a 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -479,151 +479,155 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearTimeout(connectionTimeout); - sshConn!.shell( - { - rows: data.rows, - cols: data.cols, - term: "xterm-256color", - } as PseudoTtyOptions, - (err, stream) => { - if (err) { - sshLogger.error("Shell error", err, { - operation: "ssh_shell", - hostId: id, - ip, - port, - username, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Shell error: " + err.message, - }), - ); - return; - } - - sshStream = stream; - - stream.on("data", (data: Buffer) => { - try { - const utf8String = data.toString("utf-8"); - ws.send(JSON.stringify({ type: "data", data: utf8String })); - } catch (error) { - sshLogger.error("Error encoding terminal data", error, { - operation: "terminal_data_encoding", + // Small delay to let connection stabilize after keyboard-interactive auth + // This helps prevent "No response from server" errors with TOTP + setTimeout(() => { + sshConn!.shell( + { + rows: data.rows, + cols: data.cols, + term: "xterm-256color", + } as PseudoTtyOptions, + (err, stream) => { + if (err) { + sshLogger.error("Shell error", err, { + operation: "ssh_shell", hostId: id, - dataLength: data.length, + ip, + port, + username, }); ws.send( JSON.stringify({ - type: "data", - data: data.toString("latin1"), + type: "error", + message: "Shell error: " + err.message, }), ); + return; } - }); - stream.on("close", () => { - ws.send( - JSON.stringify({ - type: "disconnected", - message: "Connection lost", - }), - ); - }); + sshStream = stream; - stream.on("error", (err: Error) => { - sshLogger.error("SSH stream error", err, { - operation: "ssh_stream", - hostId: id, - ip, - port, - username, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "SSH stream error: " + err.message, - }), - ); - }); - - setupPingInterval(); - - if (initialPath && initialPath.trim() !== "") { - const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; - stream.write(cdCommand); - } - - if (executeCommand && executeCommand.trim() !== "") { - setTimeout(() => { - const command = `${executeCommand}\n`; - stream.write(command); - }, 500); - } - - ws.send( - JSON.stringify({ type: "connected", message: "SSH connected" }), - ); - - // Log activity to homepage API - if (id && hostConfig.userId) { - (async () => { + stream.on("data", (data: Buffer) => { try { - // Fetch host name from database - const hosts = await SimpleDBOps.select( - getDb() - .select() - .from(sshData) - .where( - and( - eq(sshData.id, id), - eq(sshData.userId, hostConfig.userId!), - ), - ), - "ssh_data", - hostConfig.userId!, + const utf8String = data.toString("utf-8"); + ws.send(JSON.stringify({ type: "data", data: utf8String })); + } catch (error) { + sshLogger.error("Error encoding terminal data", error, { + operation: "terminal_data_encoding", + hostId: id, + dataLength: data.length, + }); + ws.send( + JSON.stringify({ + type: "data", + data: data.toString("latin1"), + }), ); + } + }); - const hostName = - hosts.length > 0 && hosts[0].name - ? hosts[0].name - : `${username}@${ip}:${port}`; + stream.on("close", () => { + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Connection lost", + }), + ); + }); - await axios.post( - "http://localhost:30006/activity/log", - { - type: "terminal", + stream.on("error", (err: Error) => { + sshLogger.error("SSH stream error", err, { + operation: "ssh_stream", + hostId: id, + ip, + port, + username, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH stream error: " + err.message, + }), + ); + }); + + setupPingInterval(); + + if (initialPath && initialPath.trim() !== "") { + const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; + stream.write(cdCommand); + } + + if (executeCommand && executeCommand.trim() !== "") { + setTimeout(() => { + const command = `${executeCommand}\n`; + stream.write(command); + }, 500); + } + + ws.send( + JSON.stringify({ type: "connected", message: "SSH connected" }), + ); + + // Log activity to homepage API + if (id && hostConfig.userId) { + (async () => { + try { + // Fetch host name from database + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where( + and( + eq(sshData.id, id), + eq(sshData.userId, hostConfig.userId!), + ), + ), + "ssh_data", + hostConfig.userId!, + ); + + const hostName = + hosts.length > 0 && hosts[0].name + ? hosts[0].name + : `${username}@${ip}:${port}`; + + await axios.post( + "http://localhost:30006/activity/log", + { + type: "terminal", + hostId: id, + hostName, + }, + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`, + }, + }, + ); + + sshLogger.info("Terminal activity logged", { + operation: "activity_log", + userId: hostConfig.userId, hostId: id, hostName, - }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`, - }, - }, - ); - - sshLogger.info("Terminal activity logged", { - operation: "activity_log", - userId: hostConfig.userId, - hostId: id, - hostName, - }); - } catch (error) { - sshLogger.warn("Failed to log terminal activity", { - operation: "activity_log_error", - userId: hostConfig.userId, - hostId: id, - error: - error instanceof Error ? error.message : "Unknown error", - }); - } - })(); - } - }, - ); + }); + } catch (error) { + sshLogger.warn("Failed to log terminal activity", { + operation: "activity_log_error", + userId: hostConfig.userId, + hostId: id, + error: + error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, + ); + }, 100); // Small delay to stabilize connection after keyboard-interactive auth }); sshConn.on("error", (err: Error) => { @@ -716,19 +720,6 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveFinish = (totpResponses: string[]) => { const totpCode = (totpResponses[0] || "").trim(); - sshLogger.info("TOTP response being sent to SSH server", { - operation: "totp_verification", - hostId: id, - responseLength: totpCode.length, - }); - - console.log( - `[SSH TOTP Response] Host ${id}: TOTP code: "${totpCode}" (length: ${totpCode.length})`, - ); - console.log(`[SSH TOTP Response] Calling finish() with array:`, [ - totpCode, - ]); - // Respond to ALL prompts, not just TOTP const responses = prompts.map((p, index) => { if (index === totpPromptIndex) { @@ -740,9 +731,10 @@ wss.on("connection", async (ws: WebSocket, req) => { return ""; }); - sshLogger.info("Full keyboard-interactive response", { - operation: "totp_full_response", + sshLogger.info("TOTP response being sent to SSH server", { + operation: "totp_verification", hostId: id, + totpCodeLength: totpCode.length, totalPrompts: prompts.length, responsesProvided: responses.filter((r) => r !== "").length, }); diff --git a/src/types/index.ts b/src/types/index.ts index 242409fe..f4748628 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -265,6 +265,7 @@ export interface TabContextTab { title: string; hostConfig?: SSHHost; terminalRef?: any; + initialTab?: string; // For ssh_manager: "host_viewer" | "add_host" | "credentials" | "add_credential" } // ============================================================================ @@ -339,6 +340,7 @@ export interface CredentialSelectorProps { export interface HostManagerProps { onSelectView?: (view: string) => void; isTopbarOpen?: boolean; + initialTab?: string; } export interface SSHManagerHostEditorProps { diff --git a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx index d1c343ef..e0498294 100644 --- a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx +++ b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx @@ -78,7 +78,7 @@ export function Dashboard({ Array<{ id: number; name: string; cpu: number | null; ram: number | null }> >([]); - const { addTab } = useTabs(); + const { addTab, setCurrentTab, tabs: tabList } = useTabs(); let sidebarState: "expanded" | "collapsed" = "expanded"; try { @@ -160,14 +160,27 @@ export function Dashboard({ const hosts = await getSSHHosts(); setTotalServers(hosts.length); - const tunnels = await getTunnelStatuses(); - setTotalTunnels(Object.keys(tunnels).length); + // Count total tunnels across all hosts + let totalTunnelsCount = 0; + for (const host of hosts) { + if (host.tunnelConnections) { + try { + const tunnelConnections = JSON.parse(host.tunnelConnections); + if (Array.isArray(tunnelConnections)) { + totalTunnelsCount += tunnelConnections.length; + } + } catch { + // Ignore parse errors + } + } + } + setTotalTunnels(totalTunnelsCount); const credentials = await getCredentials(); setTotalCredentials(credentials.length); - // Fetch recent activity - const activity = await getRecentActivity(10); + // Fetch recent activity (35 items) + const activity = await getRecentActivity(35); setRecentActivity(activity); // Fetch server stats for first 5 servers @@ -237,6 +250,55 @@ export function Dashboard({ }); }; + // Quick Actions handlers + const handleAddHost = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_host", + }); + setCurrentTab(id); + } + }; + + const handleAddCredential = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_credential", + }); + setCurrentTab(id); + } + }; + + const handleOpenAdminSettings = () => { + const adminTab = tabList.find((t) => t.type === "admin"); + if (adminTab) { + setCurrentTab(adminTab.id); + } else { + const id = addTab({ type: "admin", title: "Admin Settings" }); + setCurrentTab(id); + } + }; + + const handleOpenUserProfile = () => { + const userProfileTab = tabList.find((t) => t.type === "user_profile"); + if (userProfileTab) { + setCurrentTab(userProfileTab.id); + } else { + const id = addTab({ type: "user_profile", title: "User Profile" }); + setCurrentTab(id); + } + }; + return ( <> {!loggedIn ? ( @@ -486,7 +548,7 @@ export function Dashboard({