diff --git a/docker/nginx.conf b/docker/nginx.conf index fe530ac4..1ffb8fa5 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -85,7 +85,6 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - # File manager recent, pinned, shortcuts (handled by SSH service) location /ssh/file_manager/recent { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; @@ -113,7 +112,6 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - # SSH file manager operations (handled by file manager service) location /ssh/file_manager/ssh/ { proxy_pass http://127.0.0.1:8084; proxy_http_version 1.1; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 5f24f1f8..ee8bcc9e 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -48,7 +48,6 @@ interface SSHSession { } const sshSessions: Record = {}; -const SESSION_TIMEOUT_MS = 10 * 60 * 1000; function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; @@ -66,25 +65,26 @@ function scheduleSessionCleanup(sessionId: string) { const session = sshSessions[sessionId]; if (session) { if (session.timeout) clearTimeout(session.timeout); - session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS); } } -app.post('/ssh/file_manager/ssh/connect', (req, res) => { +app.post('/ssh/file_manager/ssh/connect', async (req, res) => { const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body; if (!sessionId || !ip || !username || !port) { return res.status(400).json({error: 'Missing SSH connection parameters'}); } - if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId); + if (sshSessions[sessionId]?.isConnected) { + cleanupSession(sessionId); + } const client = new SSHClient(); const config: any = { host: ip, port: port || 22, username, - readyTimeout: 20000, - keepaliveInterval: 10000, - keepaliveCountMax: 3, + readyTimeout: 0, + keepaliveInterval: 30000, + keepaliveCountMax: 0, algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -122,8 +122,22 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => { }; if (sshKey && sshKey.trim()) { - config.privateKey = sshKey; - if (keyPassword) config.passphrase = keyPassword; + try { + if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) { + throw new Error('Invalid private key format'); + } + + const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + config.privateKey = Buffer.from(cleanKey, 'utf8'); + + if (keyPassword) config.passphrase = keyPassword; + + logger.info('SSH key authentication configured successfully for file manager'); + } catch (keyError) { + logger.error('SSH key format error: ' + keyError.message); + return res.status(400).json({error: 'Invalid SSH key format'}); + } } else if (password && password.trim()) { config.password = password; } else { @@ -136,7 +150,6 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => { if (responseSent) return; responseSent = true; sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()}; - scheduleSessionCleanup(sessionId); res.json({status: 'success', message: 'SSH connection established'}); }); @@ -181,7 +194,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { @@ -251,7 +264,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { @@ -303,14 +316,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); - - const commandTimeout = setTimeout(() => { - logger.error(`SSH writeFile command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 60000); const trySFTP = () => { try { @@ -331,7 +336,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { fileBuffer = Buffer.from(content); } } catch (bufferErr) { - clearTimeout(commandTimeout); logger.error('Buffer conversion error:', bufferErr); if (!res.headersSent) { return res.status(500).json({error: 'Invalid file content format'}); @@ -354,7 +358,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { writeStream.on('finish', () => { if (hasError || hasFinished) return; hasFinished = true; - clearTimeout(commandTimeout); logger.success(`File written successfully via SFTP: ${filePath}`); if (!res.headersSent) { res.json({message: 'File written successfully', path: filePath}); @@ -364,7 +367,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { writeStream.on('close', () => { if (hasError || hasFinished) return; hasFinished = true; - clearTimeout(commandTimeout); logger.success(`File written successfully via SFTP: ${filePath}`); if (!res.headersSent) { res.json({message: 'File written successfully', path: filePath}); @@ -396,7 +398,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); + logger.error('Fallback write command failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Write failed: ${err.message}`}); @@ -416,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); + if (outputData.includes('SUCCESS')) { logger.success(`File written successfully via fallback: ${filePath}`); @@ -432,7 +434,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); + logger.error('Fallback write stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Write stream error: ${streamErr.message}`}); @@ -440,7 +442,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { }); }); } catch (fallbackErr) { - clearTimeout(commandTimeout); + logger.error('Fallback method failed:', fallbackErr); if (!res.headersSent) { res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`}); @@ -468,16 +470,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); + const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; - const commandTimeout = setTimeout(() => { - logger.error(`SSH uploadFile command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 60000); + const trySFTP = () => { try { @@ -498,7 +495,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { fileBuffer = Buffer.from(content); } } catch (bufferErr) { - clearTimeout(commandTimeout); + logger.error('Buffer conversion error:', bufferErr); if (!res.headersSent) { return res.status(500).json({error: 'Invalid file content format'}); @@ -521,7 +518,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { writeStream.on('finish', () => { if (hasError || hasFinished) return; hasFinished = true; - clearTimeout(commandTimeout); + logger.success(`File uploaded successfully via SFTP: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); @@ -531,7 +528,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { writeStream.on('close', () => { if (hasError || hasFinished) return; hasFinished = true; - clearTimeout(commandTimeout); + logger.success(`File uploaded successfully via SFTP: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); @@ -573,7 +570,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); + logger.error('Fallback upload command failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Upload failed: ${err.message}`}); @@ -593,7 +590,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); + if (outputData.includes('SUCCESS')) { logger.success(`File uploaded successfully via fallback: ${fullPath}`); @@ -609,7 +606,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); + logger.error('Fallback upload stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Upload stream error: ${streamErr.message}`}); @@ -631,7 +628,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); + logger.error('Chunked fallback upload failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Chunked upload failed: ${err.message}`}); @@ -651,7 +648,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); + if (outputData.includes('SUCCESS')) { logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); @@ -667,7 +664,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); logger.error('Chunked fallback upload stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`}); @@ -676,7 +672,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); } } catch (fallbackErr) { - clearTimeout(commandTimeout); logger.error('Fallback method failed:', fallbackErr); if (!res.headersSent) { res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`}); @@ -704,23 +699,14 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - const commandTimeout = setTimeout(() => { - logger.error(`SSH createFile command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 15000); - const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(createCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); logger.error('SSH createFile error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); @@ -739,7 +725,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - clearTimeout(commandTimeout); logger.error(`Permission denied creating file: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ @@ -751,8 +736,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); - if (outputData.includes('SUCCESS')) { if (!res.headersSent) { res.json({message: 'File created successfully', path: fullPath}); @@ -774,7 +757,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); logger.error('SSH createFile stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); @@ -800,23 +782,15 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName; const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); - const commandTimeout = setTimeout(() => { - logger.error(`SSH createFolder command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 15000); - const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(createCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); + logger.error('SSH createFolder error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); @@ -835,7 +809,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - clearTimeout(commandTimeout); logger.error(`Permission denied creating folder: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ @@ -847,8 +820,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); - if (outputData.includes('SUCCESS')) { if (!res.headersSent) { res.json({message: 'Folder created successfully', path: fullPath}); @@ -870,7 +841,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); logger.error('SSH createFolder stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); @@ -896,24 +866,14 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); - const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); - const commandTimeout = setTimeout(() => { - logger.error(`SSH deleteItem command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 15000); - const deleteCommand = isDirectory ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(deleteCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); logger.error('SSH deleteItem error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); @@ -932,7 +892,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - clearTimeout(commandTimeout); logger.error(`Permission denied deleting: ${itemPath}`); if (!res.headersSent) { return res.status(403).json({ @@ -944,8 +903,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); - if (outputData.includes('SUCCESS')) { if (!res.headersSent) { res.json({message: 'Item deleted successfully', path: itemPath}); @@ -967,7 +924,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); logger.error('SSH deleteItem stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); @@ -993,25 +949,16 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { } sshConn.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1); const newPath = oldDir + newName; const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); - const commandTimeout = setTimeout(() => { - logger.error(`SSH renameItem command timed out for session: ${sessionId}`); - if (!res.headersSent) { - res.status(500).json({error: 'SSH command timed out'}); - } - }, 15000); - const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(renameCommand, (err, stream) => { if (err) { - clearTimeout(commandTimeout); logger.error('SSH renameItem error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); @@ -1030,7 +977,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - clearTimeout(commandTimeout); logger.error(`Permission denied renaming: ${oldPath}`); if (!res.headersSent) { return res.status(403).json({ @@ -1042,8 +988,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { }); stream.on('close', (code) => { - clearTimeout(commandTimeout); - if (outputData.includes('SUCCESS')) { if (!res.headersSent) { res.json({message: 'Item renamed successfully', oldPath, newPath}); @@ -1065,7 +1009,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { }); stream.on('error', (streamErr) => { - clearTimeout(commandTimeout); logger.error('SSH renameItem stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 7f79dee3..e4cca4ad 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -115,10 +115,29 @@ function buildSshConfig(host: HostRecord): ConnectConfig { (base as any).password = host.password || ''; } else if (host.authType === 'key') { if (host.key) { - (base as any).privateKey = Buffer.from(host.key, 'utf8'); - } - if (host.keyPassword) { - (base as any).passphrase = host.keyPassword; + try { + if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) { + throw new Error('Invalid private key format'); + } + + const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + (base as any).privateKey = Buffer.from(cleanKey, 'utf8'); + + if (host.keyPassword) { + (base as any).passphrase = host.keyPassword; + } + + logger.info(`SSH key authentication configured for host ${host.ip}`); + } catch (keyError) { + logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`); + if (host.password) { + (base as any).password = host.password; + logger.info(`Falling back to password authentication for host ${host.ip}`); + } else { + throw new Error(`Invalid SSH key format for host ${host.ip}`); + } + } } } return base; @@ -413,10 +432,4 @@ app.listen(PORT, async () => { } catch (err) { logger.error('Initial poll failed', err); } -}); - -// Disable automatic background polling to prevent log flooding -// setInterval(() => { -// pollStatusesOnce().catch(err => logger.error('Background poll failed', err)); -// }, 60_000); - +}); \ No newline at end of file diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 4646528c..e962df4e 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -294,12 +294,28 @@ wss.on('connection', (ws: WebSocket) => { } }; if (authType === 'key' && key) { - connectConfig.privateKey = key; - if (keyPassword) { - connectConfig.passphrase = keyPassword; - } - if (keyType && keyType !== 'auto') { - connectConfig.privateKeyType = keyType; + try { + if (!key.includes('-----BEGIN') || !key.includes('-----END')) { + throw new Error('Invalid private key format'); + } + + const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + connectConfig.privateKey = Buffer.from(cleanKey, 'utf8'); + + if (keyPassword) { + connectConfig.passphrase = keyPassword; + } + + if (keyType && keyType !== 'auto') { + connectConfig.privateKeyType = keyType; + } + + logger.info('SSH key authentication configured successfully'); + } catch (keyError) { + logger.error('SSH key format error: ' + keyError.message); + ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'})); + return; } } else if (authType === 'key') { logger.error('SSH key authentication requested but no key provided'); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 6530c921..d27e41b1 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -438,264 +438,13 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, } function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void { - if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) { - return; - } - - if (tunnelVerifications.has(tunnelName)) { - return; - } - - const conn = activeTunnels.get(tunnelName); - if (!conn) return; - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.VERIFYING + connected: true, + status: CONNECTION_STATES.CONNECTED }); - - const verificationConn = new Client(); - tunnelVerifications.set(tunnelName, { - conn: verificationConn, - timeout: setTimeout(() => { - logger.error(`Verification timeout for '${tunnelName}'`); - cleanupVerification(false, "Verification timeout"); - }, 10000) - }); - - function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") { - const verification = tunnelVerifications.get(tunnelName); - if (verification) { - clearTimeout(verification.timeout); - try { - verification.conn.end(); - } catch (e) { - } - tunnelVerifications.delete(tunnelName); - } - - if (isSuccessful) { - broadcastTunnelStatus(tunnelName, { - connected: true, - status: CONNECTION_STATES.CONNECTED - }); - - if (!isPeriodic) { - setupPingInterval(tunnelName, tunnelConfig); - } - } else { - logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`); - - if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) { - if (!manualDisconnects.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: failureReason - }); - } - activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - } else { - logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`); - cleanupVerification(true); - } - } - } - - function attemptVerification() { - const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`; - - verificationConn.exec(testCmd, (err, stream) => { - if (err) { - logger.error(`Verification command failed for '${tunnelName}': ${err.message}`); - cleanupVerification(false, `Verification command failed: ${err.message}`); - return; - } - - let output = ''; - let errorOutput = ''; - - stream.on('data', (data: Buffer) => { - output += data.toString(); - }); - - stream.stderr?.on('data', (data: Buffer) => { - errorOutput += data.toString(); - }); - - stream.on('close', (code: number) => { - if (code === 0) { - cleanupVerification(true); - } else { - const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out'); - const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host'); - - let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`; - if (isTimeout) { - failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`; - } else if (isConnectionRefused) { - failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`; - } - - cleanupVerification(false, failureReason); - } - }); - - stream.on('error', (err: Error) => { - logger.error(`Verification stream error for '${tunnelName}': ${err.message}`); - cleanupVerification(false, `Verification stream error: ${err.message}`); - }); - }); - } - - verificationConn.on('ready', () => { - setTimeout(() => { - attemptVerification(); - }, 2000); - }); - - verificationConn.on('error', (err: Error) => { - cleanupVerification(false, `Verification connection error: ${err.message}`); - }); - - verificationConn.on('close', () => { - if (tunnelVerifications.has(tunnelName)) { - cleanupVerification(false, "Verification connection closed"); - } - }); - - const connOptions: any = { - host: tunnelConfig.sourceIP, - port: tunnelConfig.sourceSSHPort, - username: tunnelConfig.sourceUsername, - readyTimeout: 10000, - 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', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - } - }; - - if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - connOptions.privateKey = tunnelConfig.sourceSSHKey; - if (tunnelConfig.sourceKeyPassword) { - connOptions.passphrase = tunnelConfig.sourceKeyPassword; - } - if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { - connOptions.privateKeyType = tunnelConfig.sourceKeyType; - } - } else if (tunnelConfig.sourceAuthMethod === "key") { - logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: "SSH key authentication requested but no key provided" - }); - return; - } else { - connOptions.password = tunnelConfig.sourcePassword; - } - - verificationConn.connect(connOptions); } function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void { - const pingInterval = setInterval(() => { - if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) { - clearInterval(pingInterval); - return; - } - - const conn = activeTunnels.get(tunnelName); - if (!conn) { - clearInterval(pingInterval); - return; - } - - conn.exec('echo "ping"', (err, stream) => { - if (err) { - clearInterval(pingInterval); - - if (!manualDisconnects.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.UNSTABLE, - reason: "Ping failed" - }); - } - - activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - return; - } - - stream.on('close', (code: number) => { - if (code !== 0) { - clearInterval(pingInterval); - - if (!manualDisconnects.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.UNSTABLE, - reason: "Ping command failed" - }); - } - - activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - } - }); - - stream.on('error', (err: Error) => { - clearInterval(pingInterval); - - if (!manualDisconnects.has(tunnelName)) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.UNSTABLE, - reason: "Ping stream error" - }); - } - - activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); - }); - }); - }, 60000); } function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { @@ -751,7 +500,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } } - }, 15000); + }, 60000); conn.on("error", (err) => { clearTimeout(connectionTimeout); @@ -910,9 +659,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 10000, + keepaliveInterval: 60000, + keepaliveCountMax: 0, + readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, algorithms: { @@ -952,8 +701,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { }; if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) { - logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`); + if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) { + logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, @@ -962,7 +711,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { return; } - connOptions.privateKey = tunnelConfig.sourceSSHKey; + const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); if (tunnelConfig.sourceKeyPassword) { connOptions.passphrase = tunnelConfig.sourceKeyPassword; } @@ -981,43 +731,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { connOptions.password = tunnelConfig.sourcePassword; } - const testSocket = new net.Socket(); - testSocket.setTimeout(5000); - - testSocket.on('connect', () => { - testSocket.destroy(); - - const currentStatus = connectionStatus.get(tunnelName); - if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.CONNECTING, - retryCount: retryAttempt > 0 ? retryAttempt : undefined - }); - } - - conn.connect(connOptions); - }); - - testSocket.on('timeout', () => { - testSocket.destroy(); + const currentStatus = connectionStatus.get(tunnelName); + if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { broadcastTunnelStatus(tunnelName, { connected: false, - status: CONNECTION_STATES.FAILED, - reason: "Network connectivity test failed - server not reachable" + status: CONNECTION_STATES.CONNECTING, + retryCount: retryAttempt > 0 ? retryAttempt : undefined }); - }); + } - testSocket.on('error', (err: any) => { - testSocket.destroy(); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: `Network connectivity test failed - ${err.message}` - }); - }); - - testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP); + conn.connect(connOptions); } function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) { @@ -1027,9 +750,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 10000, + keepaliveInterval: 60000, + keepaliveCountMax: 0, + readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, algorithms: { @@ -1068,7 +791,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string } }; if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - connOptions.privateKey = tunnelConfig.sourceSSHKey; + if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) { + callback(new Error('Invalid SSH key format')); + return; + } + + const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); if (tunnelConfig.sourceKeyPassword) { connOptions.passphrase = tunnelConfig.sourceKeyPassword; } diff --git a/src/ui/Navigation/AppView.tsx b/src/ui/Navigation/AppView.tsx index 006bc269..bd5dc865 100644 --- a/src/ui/Navigation/AppView.tsx +++ b/src/ui/Navigation/AppView.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useRef, useState} from "react"; -import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx"; +import {Terminal} from "@/ui/apps/Terminal/Terminal.tsx"; import {Server as ServerView} from "@/ui/apps/Server/Server.tsx"; import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; @@ -108,12 +108,13 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; if (allSplitScreenTab.length === 0 && mainTab) { + const isFileManagerTab = mainTab.type === 'file_manager'; styles[mainTab.id] = { position: 'absolute', - top: 2, - left: 2, - right: 2, - bottom: 2, + top: isFileManagerTab ? 0 : 2, + left: isFileManagerTab ? 0 : 2, + right: isFileManagerTab ? 0 : 2, + bottom: isFileManagerTab ? 0 : 2, zIndex: 20, display: 'block', pointerEvents: 'auto', @@ -154,9 +155,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl const effectiveVisible = isVisible && ready; return (
-
+
{t.type === 'terminal' ? ( - tab.id === currentTab); + const isFileManager = currentTabData?.type === 'file_manager'; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const bottomMarginPx = 8; @@ -533,7 +537,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden" style={{ position: 'relative', - background: '#18181b', + background: isFileManager ? '#09090b' : '#18181b', marginLeft: leftMarginPx, marginRight: 17, marginTop: topMarginPx, diff --git a/src/ui/Navigation/Tabs/TabContext.tsx b/src/ui/Navigation/Tabs/TabContext.tsx index 22a568ba..5d8104bc 100644 --- a/src/ui/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Navigation/Tabs/TabContext.tsx @@ -50,7 +50,7 @@ export function TabProvider({children}: TabProviderProps) { const usedNumbers = new Set(); let rootUsed = false; tabs.forEach(t => { - if (t.type !== tabType || !t.title) return; + if (!t.title) return; if (t.title === root) { rootUsed = true; return; diff --git a/src/ui/apps/File Manager/FileManager.tsx b/src/ui/apps/File Manager/FileManager.tsx index 94ce24a8..ddb397b9 100644 --- a/src/ui/apps/File Manager/FileManager.tsx +++ b/src/ui/apps/File Manager/FileManager.tsx @@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button.tsx'; import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx"; import {cn} from '@/lib/utils.ts'; import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; +import {Separator} from '@/components/ui/separator.tsx'; import {toast} from 'sonner'; import { getFileManagerRecent, @@ -489,7 +490,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} if (!currentHost) { return ( -
+
{ @@ -525,7 +526,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} } return ( -
+
{ @@ -570,6 +571,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} > +
-
-
- - Current Path: - {currentPath} +
+
+ +
+ Current Path: + {currentPath} +
{showUpload && ( - -
-
-

- - Upload File + +
+
+

+ + Upload File

-

- Maximum file size: 100MB (JSON) / 200MB (Binary) +

+ Max: 100MB (JSON) / 200MB (Binary)

-
-
+
+
{uploadFile ? ( -
- -

{uploadFile.name}

-

+

+ +

{uploadFile.name}

+

{(uploadFile.size / 1024).toFixed(2)} KB

) : ( -
- -

Click to select a file

+
+ +

Click to select a file

@@ -308,11 +338,11 @@ export function FileManagerOperations({ accept="*/*" /> -
+
@@ -320,6 +350,7 @@ export function FileManagerOperations({ variant="outline" onClick={() => setShowUpload(false)} disabled={isLoading} + className="w-full text-sm h-9" > Cancel @@ -329,23 +360,25 @@ export function FileManagerOperations({ )} {showCreateFile && ( - -
-

- - Create New File -

+ +
+
+

+ + Create New File +

+
-
+
-
+
@@ -371,6 +404,7 @@ export function FileManagerOperations({ variant="outline" onClick={() => setShowCreateFile(false)} disabled={isLoading} + className="w-full text-sm h-9" > Cancel @@ -380,23 +414,25 @@ export function FileManagerOperations({ )} {showCreateFolder && ( - -
-

- - Create New Folder -

+ +
+
+

+ + Create New Folder +

+
-
+
-
+
@@ -422,6 +458,7 @@ export function FileManagerOperations({ variant="outline" onClick={() => setShowCreateFolder(false)} disabled={isLoading} + className="w-full text-sm h-9" > Cancel @@ -431,27 +468,29 @@ export function FileManagerOperations({ )} {showDelete && ( - -
-

- - Delete Item -

+ +
+
+

+ + Delete Item +

+
-
+
-
- - Warning: This action cannot be undone +
+ + Warning: This action cannot be undone
@@ -462,30 +501,30 @@ export function FileManagerOperations({ setDeletePath(e.target.value)} - placeholder="Enter full path to item (e.g., /path/to/file.txt)" - className="bg-[#23232a] border-2 border-[#434345] text-white" + placeholder="Enter full path to item" + className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" />
-
+
setDeleteIsDirectory(e.target.checked)} - className="rounded border-[#434345] bg-[#23232a]" + className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0" /> -
-
+
@@ -493,6 +532,7 @@ export function FileManagerOperations({ variant="outline" onClick={() => setShowDelete(false)} disabled={isLoading} + className="w-full text-sm h-9" > Cancel @@ -502,23 +542,25 @@ export function FileManagerOperations({ )} {showRename && ( - -
-

- - Rename Item -

+ +
+
+

+ + Rename Item +

+
-
+
@@ -539,29 +581,29 @@ export function FileManagerOperations({ value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Enter new name" - className="bg-[#23232a] border-2 border-[#434345] text-white" + className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" onKeyDown={(e) => e.key === 'Enter' && handleRename()} />
-
+
setRenameIsDirectory(e.target.checked)} - className="rounded border-[#434345] bg-[#23232a]" + className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0" /> -
-
+
@@ -569,6 +611,7 @@ export function FileManagerOperations({ variant="outline" onClick={() => setShowRename(false)} disabled={isLoading} + className="w-full text-sm h-9" > Cancel diff --git a/src/ui/apps/File Manager/FileManagerTabList.tsx b/src/ui/apps/File Manager/FileManagerTabList.tsx index b43c1cdc..e46a7e22 100644 --- a/src/ui/apps/File Manager/FileManagerTabList.tsx +++ b/src/ui/apps/File Manager/FileManagerTabList.tsx @@ -21,7 +21,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH diff --git a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/apps/Host Manager/HostManagerHostViewer.tsx index 93ee0341..aa4e65ca 100644 --- a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/apps/Host Manager/HostManagerHostViewer.tsx @@ -677,7 +677,7 @@ EXAMPLE STRUCTURE: {host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 6).map((tag, index) => ( - {tag} diff --git a/src/ui/apps/Server/Server.tsx b/src/ui/apps/Server/Server.tsx index 3af2a663..4235eb2f 100644 --- a/src/ui/apps/Server/Server.tsx +++ b/src/ui/apps/Server/Server.tsx @@ -25,7 +25,7 @@ export function Server({ embedded = false }: ServerProps): React.ReactElement { const {state: sidebarState} = useSidebar(); - const {addTab} = useTabs() as any; + const {addTab, tabs} = useTabs() as any; const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); const [metrics, setMetrics] = React.useState(null); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); @@ -116,6 +116,15 @@ export function Server({ const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8; const bottomMarginPx = 8; + // Check if a file manager tab for this host is already open + const isFileManagerAlreadyOpen = React.useMemo(() => { + if (!currentHostConfig) return false; + return tabs.some((tab: any) => + tab.type === 'file_manager' && + tab.hostConfig?.id === currentHostConfig.id + ); + }, [tabs, currentHostConfig]); + const wrapperStyle: React.CSSProperties = embedded ? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'} : { @@ -169,8 +178,10 @@ export function Server({