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:
@@ -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");
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user