diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 99ce1e95..1de80050 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -94,7 +94,16 @@ interface SSHSession { timeout?: NodeJS.Timeout; } +interface PendingTOTPSession { + client: SSHClient; + finish: (responses: string[]) => void; + config: import("ssh2").ConnectConfig; + createdAt: number; + sessionId: string; +} + const sshSessions: Record = {}; +const pendingTOTPSessions: Record = {}; function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; @@ -239,6 +248,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { host: ip, port: port || 22, username, + tryKeyboard: true, readyTimeout: 60000, keepaliveInterval: 30000, keepaliveCountMax: 3, @@ -364,9 +374,142 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { cleanupSession(sessionId); }); + client.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + fileLogger.info("Keyboard-interactive authentication requested", { + operation: "file_keyboard_interactive", + hostId, + sessionId, + promptsCount: prompts.length, + }); + + const totpPrompt = prompts.find((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPrompt) { + if (responseSent) return; + responseSent = true; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + }; + + res.json({ + requires_totp: true, + sessionId, + prompt: totpPrompt.prompt, + }); + } else { + if (resolvedCredentials.password) { + const responses = prompts.map(() => resolvedCredentials.password || ""); + finish(responses); + } else { + finish(prompts.map(() => "")); + } + } + }, + ); + client.connect(config); }); +app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { + const { sessionId, totpCode } = req.body; + + const userId = (req as any).userId; + + if (!userId) { + fileLogger.error("TOTP verification rejected: no authenticated user", { + operation: "file_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) { + fileLogger.warn("TOTP session not found or expired", { + operation: "file_totp_verify", + sessionId, + userId, + }); + return res.status(404).json({ error: "TOTP session expired. Please reconnect." }); + } + + delete pendingTOTPSessions[sessionId]; + + if (Date.now() - session.createdAt > 120000) { + try { + session.client.end(); + } catch {} + return res.status(408).json({ error: "TOTP session timeout. Please reconnect." }); + } + + session.finish([totpCode]); + + let responseSent = false; + + session.client.on("ready", () => { + if (responseSent) return; + responseSent = true; + + sshSessions[sessionId] = { + client: session.client, + isConnected: true, + lastActive: Date.now(), + }; + scheduleSessionCleanup(sessionId); + + fileLogger.success("TOTP verification successful", { + operation: "file_totp_verify", + sessionId, + userId, + }); + + res.json({ status: "success", message: "TOTP verified, SSH connection established" }); + }); + + session.client.on("error", (err) => { + if (responseSent) return; + responseSent = true; + + fileLogger.error("TOTP verification failed", { + operation: "file_totp_verify", + sessionId, + userId, + error: err.message, + }); + + res.status(401).json({ status: "error", message: "Invalid TOTP code" }); + }); + + setTimeout(() => { + if (!responseSent) { + responseSent = true; + res.status(408).json({ error: "TOTP verification timeout" }); + } + }, 60000); +}); + app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { const { sessionId } = req.body; cleanupSession(sessionId); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index cc8c7e29..0ca9ead6 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -95,6 +95,40 @@ class SSHConnectionPool { reject(err); }); + client.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + const totpPrompt = prompts.find((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPrompt) { + statsLogger.warn( + `Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`, + { + operation: "server_stats_totp_detected", + hostId: host.id, + }, + ); + client.end(); + reject(new Error("TOTP authentication required but not supported in Server Stats")); + } else if (host.password) { + const responses = prompts.map(() => host.password || ""); + finish(responses); + } else { + finish(prompts.map(() => "")); + } + }, + ); + try { client.connect(buildSshConfig(host)); } catch (err) { @@ -487,6 +521,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { host: host.ip, port: host.port || 22, username: host.username || "root", + tryKeyboard: true, readyTimeout: 10_000, algorithms: { kex: [ @@ -651,7 +686,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ } return requestQueue.queueRequest(host.id, async () => { - return withSshConnection(host, async (client) => { + try { + return await withSshConnection(host, async (client) => { let cpuPercent: number | null = null; let cores: number | null = null; let loadTriplet: [number, number, number] | null = null; @@ -812,6 +848,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ metricsCache.set(host.id, result); return result; }); + } catch (error) { + if (error instanceof Error && error.message.includes("TOTP authentication required")) { + throw error; + } + throw error; + } }); } @@ -982,6 +1024,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const metrics = await collectMetrics(host); res.json({ ...metrics, lastChecked: new Date().toISOString() }); } catch (err) { + if (err instanceof Error && err.message.includes("TOTP authentication required")) { + return res.status(403).json({ + error: "TOTP_REQUIRED", + message: "Server Stats unavailable for TOTP-enabled servers", + cpu: { percent: null, cores: null, load: null }, + memory: { percent: null, usedGiB: null, totalGiB: null }, + disk: { percent: null, usedHuman: null, totalHuman: null }, + lastChecked: new Date().toISOString(), + }); + } + statsLogger.error("Failed to collect metrics", err); if (err instanceof Error && err.message.includes("timeout")) { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 94650bd6..c60eb0ce 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; + let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -257,6 +258,33 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send(JSON.stringify({ type: "pong" })); break; + case "totp_response": + if (keyboardInteractiveFinish && data?.code) { + const totpCode = data.code; + sshLogger.info("TOTP code received from user", { + operation: "totp_response", + userId, + codeLength: totpCode.length, + }); + + keyboardInteractiveFinish([totpCode]); + keyboardInteractiveFinish = null; + } else { + sshLogger.warn("TOTP response received but no callback available", { + operation: "totp_response_error", + userId, + hasCallback: !!keyboardInteractiveFinish, + hasCode: !!data?.code, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "TOTP authentication state lost. Please reconnect.", + }), + ); + } + break; + default: sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", @@ -557,10 +585,56 @@ wss.on("connection", async (ws: WebSocket, req) => { cleanupSSH(connectionTimeout); }); + sshConn.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + sshLogger.info("Keyboard-interactive authentication requested", { + operation: "ssh_keyboard_interactive", + hostId: id, + promptsCount: prompts.length, + instructions: instructions || "none", + }); + + const totpPrompt = prompts.find((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPrompt) { + keyboardInteractiveFinish = finish; + ws.send( + JSON.stringify({ + type: "totp_required", + prompt: totpPrompt.prompt, + }), + ); + } else { + if (resolvedCredentials.password) { + const responses = prompts.map(() => resolvedCredentials.password || ""); + finish(responses); + } else { + sshLogger.warn("Keyboard-interactive requires password but none available", { + operation: "ssh_keyboard_interactive_no_password", + hostId: id, + }); + finish(prompts.map(() => "")); + } + } + }, + ); + const connectConfig: any = { host: ip, port, username, + tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f9284c9c..5e767728 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -712,7 +712,11 @@ "connectionTimeout": "Connection timeout", "terminalTitle": "Terminal - {{host}}", "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runTitle": "Running {{command}} - {{host}}" + "runTitle": "Running {{command}} - {{host}}", + "totpRequired": "Two-Factor Authentication Required", + "totpCodeLabel": "Verification Code", + "totpPlaceholder": "000000", + "totpVerify": "Verify" }, "fileManager": { "title": "File Manager", @@ -996,7 +1000,9 @@ "fileComparison": "File Comparison: {{file1}} vs {{file2}}", "fileTooLarge": "File too large: {{error}}", "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", - "loadFileFailed": "Failed to load file: {{error}}" + "loadFileFailed": "Failed to load file: {{error}}", + "connectedSuccessfully": "Connected successfully", + "totpVerificationFailed": "TOTP verification failed" }, "tunnels": { "title": "SSH Tunnels", @@ -1096,6 +1102,8 @@ "refreshing": "Refreshing...", "serverOffline": "Server Offline", "cannotFetchMetrics": "Cannot fetch metrics from offline server", + "totpRequired": "TOTP Authentication Required", + "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", "load": "Load", "free": "Free", "available": "Available" diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 90c072f1..6a927ab0 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -706,6 +706,10 @@ "terminalTitle": "终端 - {{host}}", "terminalWithPath": "终端 - {{host}}:{{path}}", "runTitle": "运行 {{command}} - {{host}}", + "totpRequired": "需要双因素认证", + "totpCodeLabel": "验证码", + "totpPlaceholder": "000000", + "totpVerify": "验证", "connect": "连接主机", "disconnect": "断开连接", "clear": "清屏", @@ -987,7 +991,9 @@ "fileComparison": "文件对比:{{file1}} 与 {{file2}}", "fileTooLarge": "文件过大:{{error}}", "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", - "loadFileFailed": "加载文件失败:{{error}}" + "loadFileFailed": "加载文件失败:{{error}}", + "connectedSuccessfully": "连接成功", + "totpVerificationFailed": "TOTP 验证失败" }, "tunnels": { "title": "SSH 隧道", @@ -1075,6 +1081,8 @@ "refreshing": "正在刷新...", "serverOffline": "服务器离线", "cannotFetchMetrics": "无法从离线服务器获取指标", + "totpRequired": "需要 TOTP 认证", + "totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能", "load": "负载" }, "auth": { diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index ea991003..779b2c33 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; +import { TOTPDialog } from "@/ui/components/TOTPDialog"; import { Upload, FolderPlus, @@ -38,6 +39,7 @@ import { renameSSHItem, moveSSHItem, connectSSH, + verifySSHTOTP, getSSHStatus, keepSSHAlive, identifySSHSymlink, @@ -98,6 +100,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [searchQuery, setSearchQuery] = useState(""); const [lastRefreshTime, setLastRefreshTime] = useState(0); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + const [totpRequired, setTotpRequired] = useState(false); + const [totpSessionId, setTotpSessionId] = useState(null); + const [totpPrompt, setTotpPrompt] = useState(""); const [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [isClosing, setIsClosing] = useState(false); @@ -288,6 +293,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { userId: currentHost.userId, }); + if (result?.requires_totp) { + setTotpRequired(true); + setTotpSessionId(sessionId); + setTotpPrompt(result.prompt || "Verification code:"); + setIsLoading(false); + return; + } + setSshSessionId(sessionId); try { @@ -1238,6 +1251,47 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setEditingFile(null); } + async function handleTotpSubmit(code: string) { + if (!totpSessionId || !code) return; + + try { + setIsLoading(true); + const result = await verifySSHTOTP(totpSessionId, code); + + if (result?.status === "success") { + setTotpRequired(false); + setTotpPrompt(""); + setSshSessionId(totpSessionId); + setTotpSessionId(null); + + try { + const response = await listSSHFiles(totpSessionId, currentPath); + const files = Array.isArray(response) + ? response + : response?.files || []; + setFiles(files); + clearSelection(); + initialLoadDoneRef.current = true; + toast.success(t("fileManager.connectedSuccessfully")); + } catch (dirError: any) { + console.error("Failed to load initial directory:", dirError); + } + } + } catch (error: any) { + console.error("TOTP verification failed:", error); + toast.error(t("fileManager.totpVerificationFailed")); + } finally { + setIsLoading(false); + } + } + + function handleTotpCancel() { + setTotpRequired(false); + setTotpPrompt(""); + setTotpSessionId(null); + if (onClose) onClose(); + } + function generateUniqueName( baseName: string, type: "file" | "directory", @@ -1806,6 +1860,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { /> + + ); } diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index 697de4d4..ff78c1e5 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -119,11 +119,16 @@ export function Server({ setMetrics(data); setShowStatsUI(true); } - } catch (error) { + } catch (error: any) { if (!cancelled) { setMetrics(null); setShowStatsUI(false); - toast.error(t("serverStats.failedToFetchMetrics")); + if (error?.code === "TOTP_REQUIRED" || + (error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) { + toast.error(t("serverStats.totpUnavailable")); + } else { + toast.error(t("serverStats.failedToFetchMetrics")); + } } } finally { if (!cancelled) { @@ -210,17 +215,28 @@ export function Server({ setMetrics(data); setShowStatsUI(true); } catch (error: any) { - if (error?.response?.status === 503) { + if (error?.code === "TOTP_REQUIRED" || + (error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) { + toast.error(t("serverStats.totpUnavailable")); + setMetrics(null); + setShowStatsUI(false); + } else if (error?.response?.status === 503 || error?.status === 503) { setServerStatus("offline"); - } else if (error?.response?.status === 504) { + setMetrics(null); + setShowStatsUI(false); + } else if (error?.response?.status === 504 || error?.status === 504) { setServerStatus("offline"); - } else if (error?.response?.status === 404) { + setMetrics(null); + setShowStatsUI(false); + } else if (error?.response?.status === 404 || error?.status === 404) { setServerStatus("offline"); + setMetrics(null); + setShowStatsUI(false); } else { setServerStatus("offline"); + setMetrics(null); + setShowStatsUI(false); } - setMetrics(null); - setShowStatsUI(false); } finally { setIsRefreshing(false); } diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 2de03a7d..5d04e93c 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -13,6 +13,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getCookie, isElectron } from "@/ui/main-axios.ts"; +import { TOTPDialog } from "@/ui/components/TOTPDialog"; interface SSHTerminalProps { hostConfig: any; @@ -55,6 +56,8 @@ export const Terminal = forwardRef(function SSHTerminal( const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); + const [totpRequired, setTotpRequired] = useState(false); + const [totpPrompt, setTotpPrompt] = useState(""); const isVisibleRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -102,6 +105,25 @@ export const Terminal = forwardRef(function SSHTerminal( } catch (_) {} } + function handleTotpSubmit(code: string) { + if (webSocketRef.current && code) { + webSocketRef.current.send( + JSON.stringify({ + type: "totp_response", + data: { code }, + }), + ); + setTotpRequired(false); + setTotpPrompt(""); + } + } + + function handleTotpCancel() { + setTotpRequired(false); + setTotpPrompt(""); + if (onClose) onClose(); + } + function scheduleNotify(cols: number, rows: number) { if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; @@ -422,6 +444,9 @@ export const Terminal = forwardRef(function SSHTerminal( if (onClose) { onClose(); } + } else if (msg.type === "totp_required") { + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Verification code:"); } } catch (error) { toast.error(t("terminal.messageParseError")); @@ -729,6 +754,13 @@ export const Terminal = forwardRef(function SSHTerminal( )} + + ); }); diff --git a/src/ui/components/TOTPDialog.tsx b/src/ui/components/TOTPDialog.tsx new file mode 100644 index 00000000..8fa7f182 --- /dev/null +++ b/src/ui/components/TOTPDialog.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Shield } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface TOTPDialogProps { + isOpen: boolean; + prompt: string; + onSubmit: (code: string) => void; + onCancel: () => void; +} + +export function TOTPDialog({ + isOpen, + prompt, + onSubmit, + onCancel, +}: TOTPDialogProps) { + const { t } = useTranslation(); + + if (!isOpen) return null; + + return ( +
+
+
+
+ +

+ {t("terminal.totpRequired")} +

+
+

{prompt}

+
{ + e.preventDefault(); + const input = e.currentTarget.elements.namedItem( + "totpCode", + ) as HTMLInputElement; + if (input && input.value.trim()) { + onSubmit(input.value.trim()); + } + }} + className="space-y-4" + > +
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 4bdb4514..bcebeae8 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -526,8 +526,8 @@ function handleApiError(error: unknown, operation: string): never { if (axios.isAxiosError(error)) { const status = error.response?.status; - const message = error.response?.data?.error || error.message; - const code = error.response?.data?.code; + const message = error.response?.data?.message || error.response?.data?.error || error.message; + const code = error.response?.data?.code || error.response?.data?.error; const url = error.config?.url; const method = error.config?.method?.toUpperCase(); @@ -554,11 +554,15 @@ function handleApiError(error: unknown, operation: string): never { throw new ApiError(errorMessage, 401, "AUTH_REQUIRED"); } else if (status === 403) { authLogger.warn(`Access denied: ${method} ${url}`, errorContext); - throw new ApiError( - "Access denied. You do not have permission to perform this action.", + const apiError = new ApiError( + code === "TOTP_REQUIRED" + ? message + : "Access denied. You do not have permission to perform this action.", 403, - "ACCESS_DENIED", + code || "ACCESS_DENIED", ); + (apiError as any).response = error.response; + throw apiError; } else if (status === 404) { apiLogger.warn(`Not found: ${method} ${url}`, errorContext); throw new ApiError( @@ -1057,6 +1061,21 @@ export async function disconnectSSH(sessionId: string): Promise { } } +export async function verifySSHTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify SSH TOTP"); + } +} + export async function getSSHStatus( sessionId: string, ): Promise<{ connected: boolean }> {