Improve File Manger UI scaling, fix file manager disconnect, disable more than one file manager at a time.
This commit is contained in:
@@ -48,7 +48,6 @@ interface SSHSession {
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
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}`});
|
||||
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user