diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 6420aa74..c0647ed4 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -670,6 +670,84 @@ router.get( }, ); +// Route: Export SSH host with decrypted credentials (requires data access) +// GET /ssh/db/host/:id/export +router.get( + "/db/host/:id/export", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const hostId = req.params.id; + const userId = (req as any).userId; + + if (!isNonEmptyString(userId) || !hostId) { + return res.status(400).json({ error: "Invalid userId or hostId" }); + } + + try { + // Fetch decrypted host data using SimpleDBOps + const hosts = await SimpleDBOps.select( + db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return res.status(404).json({ error: "SSH host not found" }); + } + + const host = hosts[0]; + + // Resolve credentials if using credential-based auth + const resolvedHost = (await resolveHostCredentials(host)) || host; + + // Format for export (include all fields including sensitive data) + const exportData = { + name: resolvedHost.name, + ip: resolvedHost.ip, + port: resolvedHost.port, + username: resolvedHost.username, + authType: resolvedHost.authType, + password: resolvedHost.password || null, + key: resolvedHost.key || null, + keyPassword: resolvedHost.keyPassword || null, + keyType: resolvedHost.keyType || null, + folder: resolvedHost.folder, + tags: + typeof resolvedHost.tags === "string" + ? resolvedHost.tags.split(",").filter(Boolean) + : resolvedHost.tags || [], + pin: !!resolvedHost.pin, + enableTerminal: !!resolvedHost.enableTerminal, + enableTunnel: !!resolvedHost.enableTunnel, + enableFileManager: !!resolvedHost.enableFileManager, + defaultPath: resolvedHost.defaultPath, + tunnelConnections: resolvedHost.tunnelConnections + ? JSON.parse(resolvedHost.tunnelConnections) + : [], + }; + + sshLogger.success("Host exported with decrypted credentials", { + operation: "host_export", + hostId: parseInt(hostId), + userId, + }); + + res.json(exportData); + } catch (err) { + sshLogger.error("Failed to export SSH host", err, { + operation: "host_export", + hostId: parseInt(hostId), + userId, + }); + res.status(500).json({ error: "Failed to export SSH host" }); + } + }, +); + // Route: Delete SSH host by id (requires JWT) // DELETE /ssh/host/:id router.delete( diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index c59c9ff9..6feecc84 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -750,6 +750,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ let diskPercent: number | null = null; let usedHuman: string | null = null; let totalHuman: string | null = null; + let availableHuman: string | null = null; try { const [diskOutHuman, diskOutBytes] = await Promise.all([ execCommand(client, "df -h -P / | tail -n +2"), @@ -773,6 +774,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ if (humanParts.length >= 6 && bytesParts.length >= 6) { totalHuman = humanParts[1] || null; usedHuman = humanParts[2] || null; + availableHuman = humanParts[3] || null; // Parse Available column from df output const totalBytes = Number(bytesParts[1]); const usedBytes = Number(bytesParts[2]); @@ -796,6 +798,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ diskPercent = null; usedHuman = null; totalHuman = null; + availableHuman = null; } const result = { @@ -805,7 +808,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, }, - disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, + disk: { + percent: toFixedNum(diskPercent, 0), + usedHuman, + totalHuman, + availableHuman, // Include available space in response + }, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 405afabb..7706fe13 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -427,7 +427,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshStream = stream; stream.on("data", (data: Buffer) => { - ws.send(JSON.stringify({ type: "data", data: data.toString() })); + ws.send(JSON.stringify({ type: "data", data: data.toString("utf-8") })); }); stream.on("close", () => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b062e4c5..0e6d735e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -564,10 +564,11 @@ "downloadSample": "Download Sample", "formatGuide": "Format Guide", "exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?", - "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will not include this data for security reasons. You'll need to reconfigure authentication after import. Do you want to continue?", + "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will include this data in plaintext. Please keep the file secure and delete it after use. Do you want to continue?", "uncategorized": "Uncategorized", "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "failedToDeleteHost": "Failed to delete host", + "failedToExportHost": "Failed to export host. Please ensure you're logged in and have access to the host data.", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", "noHostsInJson": "No hosts found in JSON file", "maxHostsAllowed": "Maximum 100 hosts allowed per import", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e3a8cfaf..62069e11 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -548,10 +548,11 @@ "downloadSample": "下载示例", "formatGuide": "格式指南", "exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?", - "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?", + "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。导出的文件将以明文形式包含这些数据。请妥善保管文件,使用后建议删除。您确定要继续吗?", "uncategorized": "未分类", "confirmDelete": "确定要删除 \"{{name}}\" 吗?", "failedToDeleteHost": "删除主机失败", + "failedToExportHost": "导出主机失败。请确保您已登录并有权访问主机数据。", "jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组", "noHostsInJson": "JSON 文件中未找到主机", "maxHostsAllowed": "每次导入最多允许 100 个主机", diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx index fd0aed87..cdb57631 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx @@ -21,6 +21,7 @@ import { bulkImportSSHHosts, updateSSHHost, renameFolder, + exportSSHHostWithCredentials, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -159,46 +160,36 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { performExport(host, actualAuthType); }; - const performExport = (host: SSHHost, actualAuthType: string) => { - const exportData: any = { - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - authType: actualAuthType, - folder: host.folder, - tags: host.tags, - pin: host.pin, - enableTerminal: host.enableTerminal, - enableTunnel: host.enableTunnel, - enableFileManager: host.enableFileManager, - defaultPath: host.defaultPath, - tunnelConnections: host.tunnelConnections, - }; + const performExport = async (host: SSHHost, actualAuthType: string) => { + try { + // Fetch decrypted host data from backend + const decryptedHost = await exportSSHHostWithCredentials(host.id); - if (actualAuthType === "credential") { - exportData.credentialId = null; + // Use decrypted data for export (includes password, key, etc.) + const cleanExportData = Object.fromEntries( + Object.entries(decryptedHost).filter( + ([_, value]) => value !== undefined, + ), + ); + + const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success( + `Exported host configuration for ${host.name || host.username}@${host.ip}`, + ); + } catch (error) { + toast.error(t("hosts.failedToExportHost")); } - - const cleanExportData = Object.fromEntries( - Object.entries(exportData).filter(([_, value]) => value !== undefined), - ); - - const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast.success( - `Exported host configuration for ${host.name || host.username}@${host.ip}`, - ); }; const handleEdit = (host: SSHHost) => { diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index ce5ec323..413f4613 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -434,10 +434,9 @@ export function Server({