diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 9b16c12b..b25767fc 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1124,36 +1124,98 @@ async function deploySSHKeyToHost( connectionTimeout = setTimeout(() => { conn.destroy(); resolve({ success: false, error: "Connection timeout" }); - }, 30000); + }, 120000); conn.on("ready", async () => { clearTimeout(connectionTimeout); + authLogger.info("SSH connection established for key deployment", { + host: hostConfig.ip, + username: hostConfig.username, + authType: hostConfig.authType, + }); try { + authLogger.info("Ensuring .ssh directory exists", { host: hostConfig.ip }); await new Promise((resolveCmd, rejectCmd) => { - conn.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh", (err, stream) => { - if (err) return rejectCmd(err); + const cmdTimeout = setTimeout(() => { + rejectCmd(new Error("mkdir command timeout")); + }, 10000); // Reduced to 10 seconds + + // Use a more robust command that handles existing directories + conn.exec("test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh", (err, stream) => { + if (err) { + clearTimeout(cmdTimeout); + authLogger.error("mkdir command error", { host: hostConfig.ip, error: err.message }); + return rejectCmd(err); + } stream.on("close", (code) => { + clearTimeout(cmdTimeout); + authLogger.info("mkdir command completed", { host: hostConfig.ip, code }); if (code === 0) { resolveCmd(); } else { rejectCmd(new Error(`mkdir command failed with code ${code}`)); } }); + + stream.on("data", (data) => { + authLogger.info("mkdir command output", { host: hostConfig.ip, output: data.toString() }); + }); }); }); const keyExists = await new Promise( (resolveCheck, rejectCheck) => { - const keyPattern = publicKey.split(" ")[1]; + const checkTimeout = setTimeout(() => { + rejectCheck(new Error("Key check timeout")); + }, 5000); // Reduced to 5 seconds + + // Parse public key - handle both JSON and plain text formats + let actualPublicKey = publicKey; + try { + // Try to parse as JSON first + const parsed = JSON.parse(publicKey); + if (parsed.data) { + actualPublicKey = parsed.data; + authLogger.info("Parsed public key from JSON format", { host: hostConfig.ip }); + } + } catch (e) { + // Not JSON, use as-is + authLogger.info("Using public key as plain text", { host: hostConfig.ip }); + } + + // Validate public key format + const keyParts = actualPublicKey.trim().split(" "); + if (keyParts.length < 2) { + clearTimeout(checkTimeout); + authLogger.error("Invalid public key format", { host: hostConfig.ip, publicKey: actualPublicKey.substring(0, 50) + "..." }); + return rejectCheck(new Error("Invalid public key format - must contain at least 2 parts")); + } + + const keyPattern = keyParts[1]; + authLogger.info("Checking for existing key", { host: hostConfig.ip, keyPattern: keyPattern.substring(0, 20) + "..." }); + + // Use a simpler approach - just check if the file exists and has content conn.exec( - `grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, + `if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`, (err, stream) => { - if (err) return rejectCheck(err); + if (err) { + clearTimeout(checkTimeout); + authLogger.error("Key check error", { host: hostConfig.ip, error: err.message }); + return rejectCheck(err); + } + + let output = ''; + stream.on('data', (data) => { + output += data.toString(); + }); stream.on("close", (code) => { - resolveCheck(code === 0); + clearTimeout(checkTimeout); + const exists = output.trim() === '0'; + authLogger.info("Key check completed", { host: hostConfig.ip, code, output: output.trim(), exists }); + resolveCheck(exists); }); }, ); @@ -1166,14 +1228,40 @@ async function deploySSHKeyToHost( return; } + authLogger.info("Adding SSH key to authorized_keys", { host: hostConfig.ip }); await new Promise((resolveAdd, rejectAdd) => { - const escapedKey = publicKey.replace(/'/g, "'\\''"); + const addTimeout = setTimeout(() => { + rejectAdd(new Error("Key add timeout")); + }, 10000); // Reduced to 10 seconds + + // Parse public key - handle both JSON and plain text formats + let actualPublicKey = publicKey; + try { + // Try to parse as JSON first + const parsed = JSON.parse(publicKey); + if (parsed.data) { + actualPublicKey = parsed.data; + } + } catch (e) { + // Not JSON, use as-is + } + + // Use printf instead of echo for more reliable key addition + const escapedKey = actualPublicKey.replace(/\\/g, '\\\\').replace(/'/g, "'\\''"); + authLogger.info("Adding key to authorized_keys", { host: hostConfig.ip, keyLength: actualPublicKey.length }); + conn.exec( - `echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, + `printf '%s\\n' '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { - if (err) return rejectAdd(err); + if (err) { + clearTimeout(addTimeout); + authLogger.error("Key add error", { host: hostConfig.ip, error: err.message }); + return rejectAdd(err); + } stream.on("close", (code) => { + clearTimeout(addTimeout); + authLogger.info("Key add completed", { host: hostConfig.ip, code }); if (code === 0) { resolveAdd(); } else { @@ -1182,20 +1270,61 @@ async function deploySSHKeyToHost( ); } }); + + stream.on("data", (data) => { + authLogger.info("Key add output", { host: hostConfig.ip, output: data.toString() }); + }); }, ); }); + authLogger.info("Verifying key deployment", { host: hostConfig.ip }); const verifySuccess = await new Promise( (resolveVerify, rejectVerify) => { - const keyPattern = publicKey.split(" ")[1]; + const verifyTimeout = setTimeout(() => { + rejectVerify(new Error("Key verification timeout")); + }, 5000); // Reduced to 5 seconds + + // Parse public key - handle both JSON and plain text formats + let actualPublicKey = publicKey; + try { + // Try to parse as JSON first + const parsed = JSON.parse(publicKey); + if (parsed.data) { + actualPublicKey = parsed.data; + } + } catch (e) { + // Not JSON, use as-is + } + + // Use the same key pattern extraction as above + const keyParts = actualPublicKey.trim().split(" "); + if (keyParts.length < 2) { + clearTimeout(verifyTimeout); + authLogger.error("Invalid public key format for verification", { host: hostConfig.ip, publicKey: actualPublicKey.substring(0, 50) + "..." }); + return rejectVerify(new Error("Invalid public key format - must contain at least 2 parts")); + } + + const keyPattern = keyParts[1]; conn.exec( - `grep -q "${keyPattern}" ~/.ssh/authorized_keys`, + `grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`, (err, stream) => { - if (err) return rejectVerify(err); + if (err) { + clearTimeout(verifyTimeout); + authLogger.error("Key verification error", { host: hostConfig.ip, error: err.message }); + return rejectVerify(err); + } + + let output = ''; + stream.on('data', (data) => { + output += data.toString(); + }); stream.on("close", (code) => { - resolveVerify(code === 0); + clearTimeout(verifyTimeout); + const verified = output.trim() === '0'; + authLogger.info("Key verification completed", { host: hostConfig.ip, code, output: output.trim(), verified }); + resolveVerify(verified); }); }, ); @@ -1223,7 +1352,32 @@ async function deploySSHKeyToHost( conn.on("error", (err) => { clearTimeout(connectionTimeout); - resolve({ success: false, error: err.message }); + let errorMessage = err.message; + + // Log detailed error information for debugging + authLogger.error("SSH connection failed during key deployment", { + host: hostConfig.ip, + username: hostConfig.username, + authType: hostConfig.authType, + hasPassword: !!hostConfig.password, + hasPrivateKey: !!hostConfig.privateKey, + error: err.message, + errorCode: (err as any).code, + }); + + if (err.message.includes("All configured authentication methods failed")) { + errorMessage = "Authentication failed. Please check your credentials and ensure the SSH service is running."; + } else if (err.message.includes("ENOTFOUND") || err.message.includes("ENOENT")) { + errorMessage = "Could not resolve hostname or connect to server."; + } else if (err.message.includes("ECONNREFUSED")) { + errorMessage = "Connection refused. The server may not be running or the port may be incorrect."; + } else if (err.message.includes("ETIMEDOUT")) { + errorMessage = "Connection timed out. Check your network connection and server availability."; + } else if (err.message.includes("authentication failed") || err.message.includes("Permission denied")) { + errorMessage = "Authentication failed. Please check your username and password/key."; + } + + resolve({ success: false, error: errorMessage }); }); try { @@ -1231,26 +1385,101 @@ async function deploySSHKeyToHost( host: hostConfig.ip, port: hostConfig.port || 22, username: hostConfig.username, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, }; if (hostConfig.authType === "password" && hostConfig.password) { connectionConfig.password = hostConfig.password; } else if (hostConfig.authType === "key" && hostConfig.privateKey) { - connectionConfig.privateKey = hostConfig.privateKey; - if (hostConfig.keyPassword) { - connectionConfig.passphrase = hostConfig.keyPassword; + try { + if ( + !hostConfig.privateKey.includes("-----BEGIN") || + !hostConfig.privateKey.includes("-----END") + ) { + throw new Error("Invalid private key format"); + } + + const cleanKey = hostConfig.privateKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + connectionConfig.privateKey = Buffer.from(cleanKey, "utf8"); + + if (hostConfig.keyPassword) { + connectionConfig.passphrase = hostConfig.keyPassword; + } + } catch (keyError) { + clearTimeout(connectionTimeout); + resolve({ + success: false, + error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, + }); + return; } } else { + clearTimeout(connectionTimeout); resolve({ success: false, - error: "Invalid authentication configuration", + error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, }); return; } + // Log connection attempt + authLogger.info("Attempting SSH connection for key deployment", { + host: connectionConfig.host, + port: connectionConfig.port, + username: connectionConfig.username, + authType: hostConfig.authType, + hasPassword: !!connectionConfig.password, + hasPrivateKey: !!connectionConfig.privateKey, + hasPassphrase: !!connectionConfig.passphrase, + }); + conn.connect(connectionConfig); } catch (error) { clearTimeout(connectionTimeout); + authLogger.error("Failed to initiate SSH connection", { + host: hostConfig.ip, + error: error instanceof Error ? error.message : "Unknown error", + }); resolve({ success: false, error: error instanceof Error ? error.message : "Connection failed", @@ -1276,11 +1505,24 @@ router.post( } try { - const credential = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, credentialId)) - .limit(1); + const userId = (req as any).userId; + if (!userId) { + return res.status(401).json({ + success: false, + error: "Authentication required", + }); + } + + const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); + const credential = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, credentialId)) + .limit(1), + "ssh_credentials", + userId, + ); if (!credential || credential.length === 0) { return res.status(404).json({ @@ -1304,12 +1546,15 @@ router.post( error: "Public key is required for deployment", }); } - - const targetHost = await db - .select() - .from(sshData) - .where(eq(sshData.id, targetHostId)) - .limit(1); + const targetHost = await SimpleDBOps.select( + db + .select() + .from(sshData) + .where(eq(sshData.id, targetHostId)) + .limit(1), + "ssh_data", + userId, + ); if (!targetHost || targetHost.length === 0) { return res.status(404).json({ @@ -1330,29 +1575,84 @@ router.post( keyPassword: hostData.keyPassword, }; + authLogger.info("Host configuration for SSH key deployment", { + hostId: targetHostId, + ip: hostConfig.ip, + port: hostConfig.port, + username: hostConfig.username, + authType: hostConfig.authType, + hasPassword: !!hostConfig.password, + hasPrivateKey: !!hostConfig.privateKey, + hasKeyPassword: !!hostConfig.keyPassword, + passwordLength: hostConfig.password ? hostConfig.password.length : 0, + }); + if (hostData.authType === "credential" && hostData.credentialId) { - const hostCredential = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, hostData.credentialId)) - .limit(1); - - if (hostCredential && hostCredential.length > 0) { - const cred = hostCredential[0]; - - hostConfig.authType = cred.authType; - hostConfig.username = cred.username; - - if (cred.authType === "password") { - hostConfig.password = cred.password; - } else if (cred.authType === "key") { - hostConfig.privateKey = cred.privateKey || cred.key; - hostConfig.keyPassword = cred.keyPassword; - } - } else { + const userId = (req as any).userId; + if (!userId) { + authLogger.error("Missing userId for credential resolution", { + hostId: targetHostId, + credentialId: hostData.credentialId, + }); return res.status(400).json({ success: false, - error: "Host credential not found", + error: "Authentication required for credential resolution", + }); + } + + try { + const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); + const hostCredential = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, hostData.credentialId)) + .limit(1), + "ssh_credentials", + userId, + ); + + if (hostCredential && hostCredential.length > 0) { + const cred = hostCredential[0]; + + hostConfig.authType = cred.authType; + hostConfig.username = cred.username; + + if (cred.authType === "password") { + hostConfig.password = cred.password; + } else if (cred.authType === "key") { + hostConfig.privateKey = cred.privateKey || cred.key; + hostConfig.keyPassword = cred.keyPassword; + } + + authLogger.info("Resolved host credentials for SSH key deployment", { + hostId: targetHostId, + credentialId: hostData.credentialId, + authType: hostConfig.authType, + username: hostConfig.username, + hasPassword: !!hostConfig.password, + hasPrivateKey: !!hostConfig.privateKey, + hasKeyPassword: !!hostConfig.keyPassword, + }); + } else { + authLogger.error("Host credential not found", { + hostId: targetHostId, + credentialId: hostData.credentialId, + }); + return res.status(400).json({ + success: false, + error: "Host credential not found", + }); + } + } catch (error) { + authLogger.error("Failed to resolve host credentials", { + hostId: targetHostId, + credentialId: hostData.credentialId, + error: error instanceof Error ? error.message : "Unknown error", + }); + return res.status(500).json({ + success: false, + error: "Failed to resolve host credentials", }); } } diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 1e26b7d7..28c75625 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -9,9 +9,10 @@ import { fileLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; + function isExecutableFile(permissions: string, fileName: string): boolean { const hasExecutePermission = - permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x"; + permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x"; const scriptExtensions = [ ".sh", @@ -25,59 +26,59 @@ function isExecutableFile(permissions: string, fileName: string): boolean { ".fish", ]; const hasScriptExtension = scriptExtensions.some((ext) => - fileName.toLowerCase().endsWith(ext), + fileName.toLowerCase().endsWith(ext), ); const executableExtensions = [".bin", ".exe", ".out"]; const hasExecutableExtension = executableExtensions.some((ext) => - fileName.toLowerCase().endsWith(ext), + fileName.toLowerCase().endsWith(ext), ); const hasNoExtension = !fileName.includes(".") && hasExecutePermission; return ( - hasExecutePermission && - (hasScriptExtension || hasExecutableExtension || hasNoExtension) + hasExecutePermission && + (hasScriptExtension || hasExecutableExtension || hasNoExtension) ); } const app = express(); app.use( - cors({ - origin: (origin, callback) => { - if (!origin) return callback(null, true); + cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); - const allowedOrigins = [ - "http://localhost:5173", - "http://localhost:3000", - "http://127.0.0.1:5173", - "http://127.0.0.1:3000", - ]; + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ]; - if (origin.startsWith("https://")) { - return callback(null, true); - } + if (origin.startsWith("https://")) { + return callback(null, true); + } - if (origin.startsWith("http://")) { - return callback(null, true); - } + if (origin.startsWith("http://")) { + return callback(null, true); + } - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } - callback(new Error("Not allowed by CORS")); - }, - credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowedHeaders: [ - "Content-Type", - "Authorization", - "User-Agent", - "X-Electron-App", - ], - }), + callback(new Error("Not allowed by CORS")); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "User-Agent", + "X-Electron-App", + ], + }), ); app.use(cookieParser()); app.use(express.json({ limit: "1gb" })); @@ -87,6 +88,8 @@ app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); + + interface SSHSession { client: SSHClient; isConnected: boolean; @@ -113,10 +116,10 @@ function scheduleSessionCleanup(sessionId: string) { if (session.timeout) clearTimeout(session.timeout); session.timeout = setTimeout( - () => { - cleanupSession(sessionId); - }, - 30 * 60 * 1000, + () => { + cleanupSession(sessionId); + }, + 30 * 60 * 1000, ); } } @@ -141,6 +144,7 @@ function getMimeType(fileName: string): string { return mimeTypes[ext || ""] || "application/octet-stream"; } + app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const { sessionId, @@ -185,17 +189,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (credentialId && hostId && userId) { try { const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, ); if (credentials.length > 0) { @@ -224,13 +228,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } } else if (credentialId && hostId) { fileLogger.warn( - "Missing userId for credential resolution in file manager", - { - operation: "ssh_credentials", - hostId, - credentialId, - hasUserId: !!userId, - }, + "Missing userId for credential resolution in file manager", + { + operation: "ssh_credentials", + hostId, + credentialId, + hasUserId: !!userId, + }, ); } @@ -280,16 +284,16 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } else if (resolvedCredentials.authType === "key" && resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) { try { if ( - !resolvedCredentials.sshKey.includes("-----BEGIN") || - !resolvedCredentials.sshKey.includes("-----END") + !resolvedCredentials.sshKey.includes("-----BEGIN") || + !resolvedCredentials.sshKey.includes("-----END") ) { throw new Error("Invalid private key format"); } const cleanKey = resolvedCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); config.privateKey = Buffer.from(cleanKey, "utf8"); @@ -314,8 +318,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { hasKey: !!resolvedCredentials.sshKey, }); return res - .status(400) - .json({ error: "Either password or SSH key must be provided" }); + .status(400) + .json({ error: "Either password or SSH key must be provided" }); } let responseSent = false; @@ -355,12 +359,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { client.connect(config); }); + app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { const { sessionId } = req.body; cleanupSession(sessionId); res.json({ status: "success", message: "SSH connection disconnected" }); }); + app.get("/ssh/file_manager/ssh/status", (req, res) => { const sessionId = req.query.sessionId as string; const isConnected = !!sshSessions[sessionId]?.isConnected; @@ -394,6 +400,7 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { }); }); + app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -430,7 +437,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { stream.on("close", (code) => { if (code !== 0) { fileLogger.error( - `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res.status(500).json({ error: `Command failed: ${errorData}` }); } @@ -480,9 +487,9 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { linkTarget, path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, executable: - !isDirectory && !isLink - ? isExecutableFile(permissions, actualName) - : false, + !isDirectory && !isLink + ? isExecutableFile(permissions, actualName) + : false, }); } } @@ -492,6 +499,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { }); }); + app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -534,7 +542,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { stream.on("close", (code) => { if (code !== 0) { fileLogger.error( - `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res.status(500).json({ error: `Command failed: ${errorData}` }); } @@ -545,8 +553,8 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { path: linkPath, target: target, type: fileType.toLowerCase().includes("directory") - ? "directory" - : "file", + ? "directory" + : "file", }); }); @@ -559,6 +567,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { }); }); + app.get("/ssh/file_manager/ssh/readFile", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -582,105 +591,106 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { const escapedPath = filePath.replace(/'/g, "'\"'\"'"); sshConn.client.exec( - `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, - (sizeErr, sizeStream) => { - if (sizeErr) { - fileLogger.error("SSH file size check error:", sizeErr); - return res.status(500).json({ error: sizeErr.message }); - } - - let sizeData = ""; - let sizeErrorData = ""; - - sizeStream.on("data", (chunk: Buffer) => { - sizeData += chunk.toString(); - }); - - sizeStream.stderr.on("data", (chunk: Buffer) => { - sizeErrorData += chunk.toString(); - }); - - sizeStream.on("close", (sizeCode) => { - if (sizeCode !== 0) { - const errorLower = sizeErrorData.toLowerCase(); - const isFileNotFound = - errorLower.includes("no such file or directory") || - errorLower.includes("cannot access") || - errorLower.includes("not found") || - errorLower.includes("resource not found"); - - fileLogger.error(`File size check failed: ${sizeErrorData}`); - return res.status(isFileNotFound ? 404 : 500).json({ - error: `Cannot check file size: ${sizeErrorData}`, - fileNotFound: isFileNotFound, - }); + `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, + (sizeErr, sizeStream) => { + if (sizeErr) { + fileLogger.error("SSH file size check error:", sizeErr); + return res.status(500).json({ error: sizeErr.message }); } - const fileSize = parseInt(sizeData.trim(), 10); + let sizeData = ""; + let sizeErrorData = ""; - if (isNaN(fileSize)) { - fileLogger.error("Invalid file size response:", sizeData); - return res.status(500).json({ error: "Cannot determine file size" }); - } + sizeStream.on("data", (chunk: Buffer) => { + sizeData += chunk.toString(); + }); - if (fileSize > MAX_READ_SIZE) { - fileLogger.warn("File too large for reading", { - operation: "file_read", - sessionId, - filePath, - fileSize, - maxSize: MAX_READ_SIZE, - }); - return res.status(400).json({ - error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`, - fileSize, - maxSize: MAX_READ_SIZE, - tooLarge: true, - }); - } + sizeStream.stderr.on("data", (chunk: Buffer) => { + sizeErrorData += chunk.toString(); + }); - sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { - if (err) { - fileLogger.error("SSH readFile error:", err); - return res.status(500).json({ error: err.message }); + sizeStream.on("close", (sizeCode) => { + if (sizeCode !== 0) { + const errorLower = sizeErrorData.toLowerCase(); + const isFileNotFound = + errorLower.includes("no such file or directory") || + errorLower.includes("cannot access") || + errorLower.includes("not found") || + errorLower.includes("resource not found"); + + fileLogger.error(`File size check failed: ${sizeErrorData}`); + return res.status(isFileNotFound ? 404 : 500).json({ + error: `Cannot check file size: ${sizeErrorData}`, + fileNotFound: isFileNotFound, + }); } - let data = ""; - let errorData = ""; + const fileSize = parseInt(sizeData.trim(), 10); - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); + if (isNaN(fileSize)) { + fileLogger.error("Invalid file size response:", sizeData); + return res.status(500).json({ error: "Cannot determine file size" }); + } - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - }); + if (fileSize > MAX_READ_SIZE) { + fileLogger.warn("File too large for reading", { + operation: "file_read", + sessionId, + filePath, + fileSize, + maxSize: MAX_READ_SIZE, + }); + return res.status(400).json({ + error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`, + fileSize, + maxSize: MAX_READ_SIZE, + tooLarge: true, + }); + } - stream.on("close", (code) => { - if (code !== 0) { - fileLogger.error( - `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - - const isFileNotFound = - errorData.includes("No such file or directory") || - errorData.includes("cannot access") || - errorData.includes("not found"); - - return res.status(isFileNotFound ? 404 : 500).json({ - error: `Command failed: ${errorData}`, - fileNotFound: isFileNotFound, - }); + sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { + if (err) { + fileLogger.error("SSH readFile error:", err); + return res.status(500).json({ error: err.message }); } - res.json({ content: data, path: filePath }); + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + + const isFileNotFound = + errorData.includes("No such file or directory") || + errorData.includes("cannot access") || + errorData.includes("not found"); + + return res.status(isFileNotFound ? 404 : 500).json({ + error: `Command failed: ${errorData}`, + fileNotFound: isFileNotFound, + }); + } + + res.json({ content: data, path: filePath }); + }); }); }); - }); - }, + }, ); }); + app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { const { sessionId, path: filePath, content, hostId, userId } = req.body; const sshConn = sshSessions[sessionId]; @@ -708,7 +718,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { sshConn.client.sftp((err, sftp) => { if (err) { fileLogger.warn( - `SFTP failed, trying fallback method: ${err.message}`, + `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); return; @@ -727,8 +737,8 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { fileLogger.error("Buffer conversion error:", bufferErr); if (!res.headersSent) { return res - .status(500) - .json({ error: "Invalid file content format" }); + .status(500) + .json({ error: "Invalid file content format" }); } return; } @@ -742,7 +752,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( - `SFTP write failed, trying fallback method: ${streamErr.message}`, + `SFTP write failed, trying fallback method: ${streamErr.message}`, ); tryFallbackMethod(); }); @@ -778,14 +788,14 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( - `SFTP write operation failed, trying fallback method: ${writeErr.message}`, + `SFTP write operation failed, trying fallback method: ${writeErr.message}`, ); tryFallbackMethod(); } }); } catch (sftpErr) { fileLogger.warn( - `SFTP connection error, trying fallback method: ${sftpErr.message}`, + `SFTP connection error, trying fallback method: ${sftpErr.message}`, ); tryFallbackMethod(); } @@ -835,7 +845,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { } } else { fileLogger.error( - `Fallback write failed with code ${code}: ${errorData}`, + `Fallback write failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ @@ -850,8 +860,8 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { fileLogger.error("Fallback write stream error:", streamErr); if (!res.headersSent) { res - .status(500) - .json({ error: `Write stream error: ${streamErr.message}` }); + .status(500) + .json({ error: `Write stream error: ${streamErr.message}` }); } }); }); @@ -859,8 +869,8 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { fileLogger.error("Fallback method failed:", fallbackErr); if (!res.headersSent) { res - .status(500) - .json({ error: `All write methods failed: ${fallbackErr.message}` }); + .status(500) + .json({ error: `All write methods failed: ${fallbackErr.message}` }); } } }; @@ -868,6 +878,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { trySFTP(); }); + app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { const { sessionId, @@ -889,27 +900,27 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { if (!filePath || !fileName || content === undefined) { return res - .status(400) - .json({ error: "File path, name, and content are required" }); + .status(400) + .json({ error: "File path, name, and content are required" }); } sshConn.lastActive = Date.now(); const contentSize = - typeof content === "string" - ? Buffer.byteLength(content, "utf8") - : content.length; + typeof content === "string" + ? Buffer.byteLength(content, "utf8") + : content.length; const fullPath = filePath.endsWith("/") - ? filePath + fileName - : filePath + "/" + fileName; + ? filePath + fileName + : filePath + "/" + fileName; const trySFTP = () => { try { sshConn.client.sftp((err, sftp) => { if (err) { fileLogger.warn( - `SFTP failed, trying fallback method: ${err.message}`, + `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); return; @@ -928,8 +939,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { fileLogger.error("Buffer conversion error:", bufferErr); if (!res.headersSent) { return res - .status(500) - .json({ error: "Invalid file content format" }); + .status(500) + .json({ error: "Invalid file content format" }); } return; } @@ -943,14 +954,14 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( - `SFTP write failed, trying fallback method: ${streamErr.message}`, - { - operation: "file_upload", - sessionId, - fileName, - fileSize: contentSize, - error: streamErr.message, - }, + `SFTP write failed, trying fallback method: ${streamErr.message}`, + { + operation: "file_upload", + sessionId, + fileName, + fileSize: contentSize, + error: streamErr.message, + }, ); tryFallbackMethod(); }); @@ -986,14 +997,14 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( - `SFTP write operation failed, trying fallback method: ${writeErr.message}`, + `SFTP write operation failed, trying fallback method: ${writeErr.message}`, ); tryFallbackMethod(); } }); } catch (sftpErr) { fileLogger.warn( - `SFTP connection error, trying fallback method: ${sftpErr.message}`, + `SFTP connection error, trying fallback method: ${sftpErr.message}`, ); tryFallbackMethod(); } @@ -1021,8 +1032,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { fileLogger.error("Fallback upload command failed:", err); if (!res.headersSent) { return res - .status(500) - .json({ error: `Upload failed: ${err.message}` }); + .status(500) + .json({ error: `Upload failed: ${err.message}` }); } return; } @@ -1052,7 +1063,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { } } else { fileLogger.error( - `Fallback upload failed with code ${code}: ${errorData}`, + `Fallback upload failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ @@ -1070,8 +1081,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { fileLogger.error("Fallback upload stream error:", streamErr); if (!res.headersSent) { res - .status(500) - .json({ error: `Upload stream error: ${streamErr.message}` }); + .status(500) + .json({ error: `Upload stream error: ${streamErr.message}` }); } }); }); @@ -1093,8 +1104,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { fileLogger.error("Chunked fallback upload failed:", err); if (!res.headersSent) { return res - .status(500) - .json({ error: `Chunked upload failed: ${err.message}` }); + .status(500) + .json({ error: `Chunked upload failed: ${err.message}` }); } return; } @@ -1124,7 +1135,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { } } else { fileLogger.error( - `Chunked fallback upload failed with code ${code}: ${errorData}`, + `Chunked fallback upload failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ @@ -1140,8 +1151,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { stream.on("error", (streamErr) => { fileLogger.error( - "Chunked fallback upload stream error:", - streamErr, + "Chunked fallback upload stream error:", + streamErr, ); if (!res.headersSent) { res.status(500).json({ @@ -1155,8 +1166,8 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { fileLogger.error("Fallback method failed:", fallbackErr); if (!res.headersSent) { res - .status(500) - .json({ error: `All upload methods failed: ${fallbackErr.message}` }); + .status(500) + .json({ error: `All upload methods failed: ${fallbackErr.message}` }); } } }; @@ -1164,6 +1175,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { trySFTP(); }); + app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { const { sessionId, @@ -1190,8 +1202,8 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { sshConn.lastActive = Date.now(); const fullPath = filePath.endsWith("/") - ? filePath + fileName - : filePath + "/" + fileName; + ? filePath + fileName + : filePath + "/" + fileName; const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; @@ -1240,7 +1252,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { if (code !== 0) { fileLogger.error( - `SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ @@ -1272,6 +1284,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { }); }); + app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { const { sessionId, path: folderPath, folderName, hostId, userId } = req.body; const sshConn = sshSessions[sessionId]; @@ -1291,8 +1304,8 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { sshConn.lastActive = Date.now(); const fullPath = folderPath.endsWith("/") - ? folderPath + folderName - : folderPath + "/" + folderName; + ? folderPath + folderName + : folderPath + "/" + folderName; const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; @@ -1341,7 +1354,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { if (code !== 0) { fileLogger.error( - `SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ @@ -1393,8 +1406,8 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); const deleteCommand = isDirectory - ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` - : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; + ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` + : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(deleteCommand, (err, stream) => { if (err) { @@ -1443,7 +1456,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { if (code !== 0) { fileLogger.error( - `SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ @@ -1489,8 +1502,8 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { if (!oldPath || !newName) { return res - .status(400) - .json({ error: "Old path and new name are required" }); + .status(400) + .json({ error: "Old path and new name are required" }); } sshConn.lastActive = Date.now(); @@ -1550,7 +1563,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { if (code !== 0) { fileLogger.error( - `SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ @@ -1597,8 +1610,8 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { if (!oldPath || !newPath) { return res - .status(400) - .json({ error: "Old path and new path are required" }); + .status(400) + .json({ error: "Old path and new path are required" }); } sshConn.lastActive = Date.now(); @@ -1674,7 +1687,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { if (code !== 0) { fileLogger.error( - `SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + `SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ @@ -1708,6 +1721,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { }); }); + app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { const { sessionId, path: filePath, hostId, userId } = req.body; @@ -1728,8 +1742,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { isConnected: sshConn?.isConnected, }); return res - .status(400) - .json({ error: "SSH session not found or not connected" }); + .status(400) + .json({ error: "SSH session not found or not connected" }); } sshConn.lastActive = Date.now(); @@ -1745,8 +1759,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { if (statErr) { fileLogger.error("File stat failed for download:", statErr); return res - .status(500) - .json({ error: `Cannot access file: ${statErr.message}` }); + .status(500) + .json({ error: `Cannot access file: ${statErr.message}` }); } if (!stats.isFile()) { @@ -1758,8 +1772,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { isDirectory: stats.isDirectory(), }); return res - .status(400) - .json({ error: "Cannot download directories or special files" }); + .status(400) + .json({ error: "Cannot download directories or special files" }); } const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; @@ -1780,8 +1794,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { if (readErr) { fileLogger.error("File read failed for download:", readErr); return res - .status(500) - .json({ error: `Failed to read file: ${readErr.message}` }); + .status(500) + .json({ error: `Failed to read file: ${readErr.message}` }); } const base64Content = data.toString("base64"); @@ -1809,6 +1823,7 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { }); }); + app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; @@ -1819,8 +1834,8 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { const sshConn = sshSessions[sessionId]; if (!sshConn || !sshConn.isConnected) { return res - .status(400) - .json({ error: "SSH session not found or not connected" }); + .status(400) + .json({ error: "SSH session not found or not connected" }); } sshConn.lastActive = Date.now(); @@ -1880,7 +1895,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { if (code !== 0) { const fullErrorInfo = - errorData || stdoutData || "No error message available"; + errorData || stdoutData || "No error message available"; fileLogger.error(`SSH copyItem command failed with code ${code}`, { operation: "file_copy_failed", sessionId, @@ -1911,7 +1926,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { } const copySuccessful = - stdoutData.includes("COPY_SUCCESS") || code === 0; + stdoutData.includes("COPY_SUCCESS") || code === 0; if (copySuccessful) { fileLogger.success("Item copied successfully", { @@ -1970,23 +1985,14 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { }); }); }); +}); - process.on("SIGINT", () => { - Object.keys(sshSessions).forEach(cleanupSession); - process.exit(0); - }); +app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { + const { sessionId, filePath, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; - process.on("SIGTERM", () => { - Object.keys(sshSessions).forEach(cleanupSession); - process.exit(0); - }); - - app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { - const { sessionId, filePath, hostId, userId } = req.body; - const sshConn = sshSessions[sessionId]; - - if (!sshConn || !sshConn.isConnected) { - fileLogger.error( + if (!sshConn || !sshConn.isConnected) { + fileLogger.error( "SSH connection not found or not connected for executeFile", { operation: "execute_file", @@ -1994,93 +2000,108 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { hasConnection: !!sshConn, isConnected: sshConn?.isConnected, }, - ); - return res.status(400).json({ error: "SSH connection not available" }); - } + ); + return res.status(400).json({ error: "SSH connection not available" }); + } - if (!filePath) { - return res.status(400).json({ error: "File path is required" }); - } + if (!filePath) { + return res.status(400).json({ error: "File path is required" }); + } - const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); - const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; + const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; - sshConn.client.exec(checkCommand, (checkErr, checkStream) => { - if (checkErr) { - fileLogger.error("SSH executeFile check error:", checkErr); - return res + sshConn.client.exec(checkCommand, (checkErr, checkStream) => { + if (checkErr) { + fileLogger.error("SSH executeFile check error:", checkErr); + return res .status(500) .json({ error: "Failed to check file executability" }); + } + + let checkResult = ""; + checkStream.on("data", (data) => { + checkResult += data.toString(); + }); + + checkStream.on("close", (code) => { + if (!checkResult.includes("EXECUTABLE")) { + return res.status(400).json({ error: "File is not executable" }); } - let checkResult = ""; - checkStream.on("data", (data) => { - checkResult += data.toString(); - }); + const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; - checkStream.on("close", (code) => { - if (!checkResult.includes("EXECUTABLE")) { - return res.status(400).json({ error: "File is not executable" }); + sshConn.client.exec(executeCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH executeFile error:", err); + return res.status(500).json({ error: "Failed to execute file" }); } - const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; + let output = ""; + let errorOutput = ""; - sshConn.client.exec(executeCommand, (err, stream) => { - if (err) { - fileLogger.error("SSH executeFile error:", err); - return res.status(500).json({ error: "Failed to execute file" }); - } + stream.on("data", (data) => { + output += data.toString(); + }); - let output = ""; - let errorOutput = ""; + stream.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); - stream.on("data", (data) => { - output += data.toString(); - }); - - stream.stderr.on("data", (data) => { - errorOutput += data.toString(); - }); - - stream.on("close", (code) => { - const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); - const actualExitCode = exitCodeMatch + stream.on("close", (code) => { + const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); + const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code; - const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim(); + const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim(); - fileLogger.info("File execution completed", { - operation: "execute_file", - sessionId, - filePath, - exitCode: actualExitCode, - outputLength: cleanOutput.length, - errorLength: errorOutput.length, - }); - - res.json({ - success: true, - exitCode: actualExitCode, - output: cleanOutput, - error: errorOutput, - timestamp: new Date().toISOString(), - }); + fileLogger.info("File execution completed", { + operation: "execute_file", + sessionId, + filePath, + exitCode: actualExitCode, + outputLength: cleanOutput.length, + errorLength: errorOutput.length, }); - stream.on("error", (streamErr) => { - fileLogger.error("SSH executeFile stream error:", streamErr); - if (!res.headersSent) { - res.status(500).json({ error: "Execution stream error" }); - } + res.json({ + success: true, + exitCode: actualExitCode, + output: cleanOutput, + error: errorOutput, + timestamp: new Date().toISOString(), }); }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH executeFile stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: "Execution stream error" }); + } + }); }); }); }); +}); - const PORT = 30004; - app.listen(PORT, async () => { + +process.on("SIGINT", () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); +}); + +process.on("SIGTERM", () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); +}); + + +const PORT = 30004; + + +try { + const server = app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { @@ -2089,4 +2110,16 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { }); } }); -}); + + server.on('error', (err) => { + fileLogger.error("File Manager server error", err, { + operation: "file_manager_server_error", + port: PORT, + }); + }); +} catch (err) { + fileLogger.error("Failed to start File Manager server", err, { + operation: "file_manager_server_start_failed", + port: PORT, + }); +} diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 31e911b1..20580c0e 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -152,7 +152,7 @@ class AuthManager { getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) { return { - httpOnly: true, + httpOnly: false, secure: req.secure || req.headers["x-forwarded-proto"] === "https", sameSite: "strict" as const, maxAge: maxAge, diff --git a/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx b/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx index fe0fdba8..545192c2 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx @@ -105,12 +105,12 @@ export function CredentialsManager({ if (showDeployDialog) { setDropdownOpen(false); setHostSearchQuery(""); + setSelectedHostId(""); setTimeout(() => { - const activeElement = document.activeElement as HTMLElement; - if (activeElement && activeElement.blur) { - activeElement.blur(); + if (document.activeElement && (document.activeElement as HTMLElement).blur) { + (document.activeElement as HTMLElement).blur(); } - }, 100); + }, 50); } }, [showDeployDialog]); @@ -908,14 +908,13 @@ export function CredentialsManager({ value={hostSearchQuery} onChange={(e) => { setHostSearchQuery(e.target.value); - if (e.target.value.trim() !== "") { - setDropdownOpen(true); - } else { - setDropdownOpen(false); - } + }} + onClick={() => { + setDropdownOpen(true); }} className="w-full" autoFocus={false} + tabIndex={0} /> {dropdownOpen && (
diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 6aa6269b..662f7b98 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -493,7 +493,7 @@ export const Terminal = forwardRef(function SSHTerminal( } useEffect(() => { - if (!terminal || !xtermRef.current || !hostConfig) return; + if (!terminal || !xtermRef.current) return; terminal.options = { cursorBlink: true, @@ -598,7 +598,35 @@ export const Terminal = forwardRef(function SSHTerminal( resizeObserver.observe(xtermRef.current); setVisible(true); - setIsConnecting(true); // Show connecting state immediately + + return () => { + isUnmountingRef.current = true; + shouldNotReconnectRef.current = true; + isReconnectingRef.current = false; + setIsConnecting(false); + resizeObserver.disconnect(); + element?.removeEventListener("contextmenu", handleContextMenu); + element?.removeEventListener("keydown", handleMacKeyboard, true); + if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); + if (connectionTimeoutRef.current) + clearTimeout(connectionTimeoutRef.current); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + webSocketRef.current?.close(); + }; + }, [xtermRef, terminal]); + + useEffect(() => { + if (!terminal || !hostConfig || !visible) return; + + if (isConnected || isConnecting) return; + + setIsConnecting(true); const readyFonts = (document as any).fonts?.ready instanceof Promise @@ -607,7 +635,7 @@ export const Terminal = forwardRef(function SSHTerminal( readyFonts.then(() => { setTimeout(() => { - fitAddon.fit(); + fitAddonRef.current?.fit(); if (terminal) scheduleNotify(terminal.cols, terminal.rows); hardRefresh(); @@ -630,28 +658,7 @@ export const Terminal = forwardRef(function SSHTerminal( connectToHost(cols, rows); }, 200); }); - - return () => { - isUnmountingRef.current = true; - shouldNotReconnectRef.current = true; - isReconnectingRef.current = false; - setIsConnecting(false); - resizeObserver.disconnect(); - element?.removeEventListener("contextmenu", handleContextMenu); - element?.removeEventListener("keydown", handleMacKeyboard, true); - if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - if (reconnectTimeoutRef.current) - clearTimeout(reconnectTimeoutRef.current); - if (connectionTimeoutRef.current) - clearTimeout(connectionTimeoutRef.current); - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - webSocketRef.current?.close(); - }; - }, [xtermRef, terminal, hostConfig]); + }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]); useEffect(() => { if (isVisible && fitAddonRef.current) { @@ -686,7 +693,6 @@ export const Terminal = forwardRef(function SSHTerminal( return (
- {/* Terminal */}
(function SSHTerminal( }} /> - {/* Connecting State */} {isConnecting && (