Fix SSH encryption and add file download functionality

- Fix SSH authentication by ensuring all database operations use EncryptedDBOperations for automatic encryption/decryption
- Resolve SSH connection failures caused by encrypted password data being passed to authentication
- Add comprehensive file download functionality for SSH file manager (Issue #228)
- Update database migration to add require_password column for SSH sessions
- Enhance debugging and logging for SSH connection troubleshooting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-16 13:24:25 +08:00
parent 182b60a428
commit 957bc5e41b
10 changed files with 426 additions and 30 deletions

View File

@@ -250,6 +250,12 @@ const migrateSchema = () => {
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists(
"ssh_data",
"require_password",
"INTEGER NOT NULL DEFAULT 1",
);
// SSH credentials table migrations for encryption support
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

@@ -210,9 +210,9 @@ router.post(
}
try {
const result = await db.insert(sshData).values(sshDataObj).returning();
const result = await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
if (result.length === 0) {
if (!result) {
sshLogger.warn("No host returned after creation", {
operation: "host_create",
userId,
@@ -223,7 +223,7 @@ router.post(
return res.status(500).json({ error: "Failed to create host" });
}
const createdHost = result[0];
const createdHost = result;
const baseHost = {
...createdHost,
tags:
@@ -401,15 +401,17 @@ router.put(
}
try {
await db
.update(sshData)
.set(sshDataObj)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
await EncryptedDBOperations.update(
sshData,
'ssh_data',
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
sshDataObj
);
const updatedHosts = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
const updatedHosts = await EncryptedDBOperations.select(
db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))),
'ssh_data'
);
if (updatedHosts.length === 0) {
sshLogger.warn("Updated host not found after update", {
@@ -482,10 +484,10 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const data = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
const data = await EncryptedDBOperations.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
'ssh_data'
);
const result = await Promise.all(
data.map(async (row: any) => {
@@ -1102,14 +1104,15 @@ router.put(
}
try {
const updatedHosts = await db
.update(sshData)
.set({
const updatedHosts = await EncryptedDBOperations.update(
sshData,
'ssh_data',
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
})
.where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName)))
.returning();
}
);
const updatedCredentials = await db
.update(sshCredentials)
@@ -1249,7 +1252,7 @@ router.post(
updatedAt: new Date().toISOString(),
};
await db.insert(sshData).values(sshDataObj);
await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
results.success++;
} catch (error) {
results.failed++;

View File

@@ -1334,6 +1334,130 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const {
sessionId,
path: filePath,
hostId,
userId,
} = req.body;
if (!sessionId || !filePath) {
fileLogger.warn("Missing download parameters", {
operation: "file_download",
sessionId,
hasFilePath: !!filePath,
});
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.warn("SSH session not found or not connected for download", {
operation: "file_download",
sessionId,
isConnected: sshConn?.isConnected,
});
return res.status(400).json({ error: "SSH session not found or not connected" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Use SFTP to read file for binary safety
sshConn.client.sftp((err, sftp) => {
if (err) {
fileLogger.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
}
// Get file stats first to check if it's a regular file and get size
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
fileLogger.error("File stat failed for download:", statErr);
return res.status(500).json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
fileLogger.warn("Attempted to download non-file", {
operation: "file_download",
sessionId,
filePath,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
});
return res.status(400).json({ error: "Cannot download directories or special files" });
}
// Check file size (limit to 100MB for safety)
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
if (stats.size > MAX_FILE_SIZE) {
fileLogger.warn("File too large for download", {
operation: "file_download",
sessionId,
filePath,
fileSize: stats.size,
maxSize: MAX_FILE_SIZE,
});
return res.status(400).json({
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`
});
}
// Read file content
sftp.readFile(filePath, (readErr, data) => {
if (readErr) {
fileLogger.error("File read failed for download:", readErr);
return res.status(500).json({ error: `Failed to read file: ${readErr.message}` });
}
// Convert to base64 for safe transport
const base64Content = data.toString('base64');
const fileName = filePath.split('/').pop() || 'download';
fileLogger.success("File downloaded successfully", {
operation: "file_download",
sessionId,
filePath,
fileName,
fileSize: stats.size,
hostId,
userId,
});
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
});
});
// Helper function to determine MIME type based on file extension
function getMimeType(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
'txt': 'text/plain',
'json': 'application/json',
'js': 'text/javascript',
'html': 'text/html',
'css': 'text/css',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'pdf': 'application/pdf',
'zip': 'application/zip',
'tar': 'application/x-tar',
'gz': 'application/gzip',
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);

View File

@@ -358,6 +358,17 @@ async function resolveHostCredentials(
host: any,
): Promise<SSHHostWithCredentials | undefined> {
try {
statsLogger.debug(`Resolving credentials for host ${host.id}`, {
operation: 'credential_resolve',
hostId: host.id,
authType: host.authType,
credentialId: host.credentialId,
hasPassword: !!host.password,
hasKey: !!host.key,
passwordLength: host.password?.length || 0,
keyLength: host.key?.length || 0
});
const baseHost: any = {
id: host.id,
name: host.name,
@@ -397,6 +408,16 @@ async function resolveHostCredentials(
if (credentials.length > 0) {
const credential = credentials[0];
statsLogger.debug(`Using credential ${credential.id} for host ${host.id}`, {
operation: 'credential_resolve',
credentialId: credential.id,
authType: credential.authType,
hasPassword: !!credential.password,
hasKey: !!credential.key,
passwordLength: credential.password?.length || 0,
keyLength: credential.key?.length || 0
});
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
@@ -426,9 +447,25 @@ async function resolveHostCredentials(
addLegacyCredentials(baseHost, host);
}
} else {
statsLogger.debug(`Using legacy credentials for host ${host.id}`, {
operation: 'credential_resolve',
hasPassword: !!host.password,
hasKey: !!host.key,
passwordLength: host.password?.length || 0,
keyLength: host.key?.length || 0
});
addLegacyCredentials(baseHost, host);
}
statsLogger.debug(`Final resolved host ${host.id}`, {
operation: 'credential_resolve',
authType: baseHost.authType,
hasPassword: !!baseHost.password,
hasKey: !!baseHost.key,
passwordLength: baseHost.password?.length || 0,
keyLength: baseHost.key?.length || 0
});
return baseHost;
} catch (error) {
statsLogger.error(
@@ -446,6 +483,18 @@ function addLegacyCredentials(baseHost: any, host: any): void {
}
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
operation: 'ssh_config',
authType: host.authType,
hasPassword: !!host.password,
hasKey: !!host.key,
username: host.username,
passwordLength: host.password?.length || 0,
keyLength: host.key?.length || 0,
passwordType: typeof host.password,
passwordRaw: host.password ? JSON.stringify(host.password.substring(0, 20)) : null
});
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
@@ -458,12 +507,26 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
statsLogger.debug(`Using password auth for ${host.ip}`, {
operation: 'ssh_config',
passwordLength: host.password.length,
passwordFirst3: host.password.substring(0, 3),
passwordLast3: host.password.substring(host.password.length - 3),
passwordType: typeof host.password,
passwordIsString: typeof host.password === 'string'
});
(base as any).password = host.password;
} else if (host.authType === "key") {
if (!host.key) {
throw new Error(`No SSH key available for host ${host.ip}`);
}
statsLogger.debug(`Using key auth for ${host.ip}`, {
operation: 'ssh_config',
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + '...',
hasPassphrase: !!host.keyPassword
});
try {
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
throw new Error("Invalid private key format");

View File

@@ -4,6 +4,7 @@ import { db } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
const wss = new WebSocketServer({ port: 8082 });
@@ -174,18 +175,38 @@ wss.on("connection", (ws: WebSocket) => {
}
}, 60000);
sshLogger.debug(`Terminal SSH setup`, {
operation: 'terminal_ssh',
hostId: id,
ip,
authType,
hasPassword: !!password,
passwordLength: password?.length || 0,
hasCredentialId: !!credentialId
});
if (password) {
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
operation: 'terminal_ssh_password'
});
} else {
sshLogger.debug(`No password provided`, {
operation: 'terminal_ssh_password'
});
}
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
const credentials = await EncryptedDBOperations.select(
db.select().from(sshCredentials).where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
);
),
'ssh_credentials'
);
if (credentials.length > 0) {
const credential = credentials[0];

View File

@@ -581,6 +581,7 @@
"folder": "Folder",
"connectToSsh": "Connect to SSH to use file operations",
"uploadFile": "Upload File",
"downloadFile": "Download File",
"newFile": "New File",
"newFolder": "New Folder",
"rename": "Rename",
@@ -593,7 +594,9 @@
"clickToSelectFile": "Click to select a file",
"chooseFile": "Choose File",
"uploading": "Uploading...",
"downloading": "Downloading...",
"uploadingFile": "Uploading {{name}}...",
"downloadingFile": "Downloading {{name}}...",
"creatingFile": "Creating {{name}}...",
"creatingFolder": "Creating {{name}}...",
"deletingItem": "Deleting {{type}} {{name}}...",
@@ -615,6 +618,10 @@
"renaming": "Renaming...",
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
"failedToUploadFile": "Failed to upload file",
"fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully",
"failedToDownloadFile": "Failed to download file",
"noFileContent": "No file content received",
"filePath": "File Path",
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
"failedToCreateFile": "Failed to create file",
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",

View File

@@ -596,6 +596,7 @@
"folder": "文件夹",
"connectToSsh": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"downloadFile": "下载文件",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
@@ -608,7 +609,9 @@
"clickToSelectFile": "点击选择文件",
"chooseFile": "选择文件",
"uploading": "上传中...",
"downloading": "下载中...",
"uploadingFile": "正在上传 {{name}}...",
"downloadingFile": "正在下载 {{name}}...",
"creatingFile": "正在创建 {{name}}...",
"creatingFolder": "正在创建 {{name}}...",
"deletingItem": "正在删除 {{type}} {{name}}...",
@@ -630,6 +633,10 @@
"renaming": "重命名中...",
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
"failedToUploadFile": "上传文件失败",
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
"failedToDownloadFile": "下载文件失败",
"noFileContent": "未收到文件内容",
"filePath": "文件路径",
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
"failedToCreateFile": "创建文件失败",
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Folder, File, Trash2, Pin } from "lucide-react";
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SSHConnection {
@@ -32,6 +32,7 @@ interface FileManagerLeftSidebarVileViewerProps {
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDownloadFile?: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
@@ -47,6 +48,7 @@ export function FileManagerLeftSidebarFileViewer({
onOpenFile,
onOpenFolder,
onStarFile,
onDownloadFile,
onDeleteFile,
isLoading,
error,
@@ -104,6 +106,17 @@ export function FileManagerLeftSidebarFileViewer({
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button>
{item.type === "file" && onDownloadFile && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDownloadFile(item)}
title={t("fileManager.downloadFile")}
>
<Download className="w-4 h-4 text-blue-400" />
</Button>
)}
<Button
size="icon"
variant="ghost"

View File

@@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Upload,
Download,
FilePlus,
FolderPlus,
Trash2,
@@ -27,12 +28,14 @@ export function FileManagerOperations({
}: FileManagerOperationsProps) {
const { t } = useTranslation();
const [showUpload, setShowUpload] = useState(false);
const [showDownload, setShowDownload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [downloadPath, setDownloadPath] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFolderName, setNewFolderName] = useState("");
const [deletePath, setDeletePath] = useState("");
@@ -154,6 +157,66 @@ export function FileManagerOperations({
}
};
const handleDownload = async () => {
if (!downloadPath.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const fileName = downloadPath.split('/').pop() || 'download';
const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }),
);
try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile(
sshSessionId,
downloadPath.trim(),
);
toast.dismiss(loadingToast);
if (response?.content) {
// Convert base64 to blob and trigger download
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = response.fileName || fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
onSuccess(
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }),
);
} else {
onError(t("fileManager.noFileContent"));
}
setShowDownload(false);
setDownloadPath("");
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
@@ -344,7 +407,7 @@ export function FileManagerOperations({
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
size="sm"
@@ -357,6 +420,18 @@ export function FileManagerOperations({
<span className="truncate">{t("fileManager.uploadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDownload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.downloadFile")}
>
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.downloadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
@@ -397,7 +472,7 @@ export function FileManagerOperations({
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2"
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
title={t("fileManager.deleteItem")}
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
@@ -516,6 +591,64 @@ export function FileManagerOperations({
</Card>
)}
{showDownload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.downloadFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDownload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.filePath")}
</label>
<Input
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDownload}
disabled={!downloadPath.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.downloading")
: t("fileManager.downloadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowDownload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">

View File

@@ -1050,6 +1050,25 @@ export async function uploadSSHFile(
}
}
export async function downloadSSHFile(
sessionId: string,
filePath: string,
hostId?: number,
userId?: string,
): Promise<any> {
try {
const response = await fileManagerApi.post("/ssh/downloadFile", {
sessionId,
path: filePath,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "download SSH file");
}
}
export async function createSSHFile(
sessionId: string,
path: string,