Remove encrpytion, improve logging and merge interfaces.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import {Client as SSHClient} from 'ssh2';
|
||||
import chalk from "chalk";
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import { fileLogger } from '../utils/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -14,31 +17,6 @@ app.use(express.json({limit: '100mb'}));
|
||||
app.use(express.urlencoded({limit: '100mb', extended: true}));
|
||||
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
|
||||
|
||||
const sshIconSymbol = '📁';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
@@ -69,15 +47,52 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
}
|
||||
|
||||
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||
const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body;
|
||||
|
||||
fileLogger.info('File manager SSH connection request received', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType, hasCredentialId: !!credentialId });
|
||||
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port });
|
||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||
}
|
||||
|
||||
if (sshSessions[sessionId]?.isConnected) {
|
||||
fileLogger.info('Cleaning up existing SSH session', { operation: 'file_connect', sessionId });
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
const client = new SSHClient();
|
||||
|
||||
let resolvedCredentials = {password, sshKey, keyPassword, authType};
|
||||
if (credentialId && hostId && userId) {
|
||||
fileLogger.info('Resolving credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId });
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId)
|
||||
));
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
authType: credential.authType
|
||||
};
|
||||
fileLogger.success('Credentials resolved successfully for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, authType: credential.authType });
|
||||
} else {
|
||||
fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId });
|
||||
}
|
||||
} catch (error) {
|
||||
fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
} else {
|
||||
fileLogger.info('Using direct credentials for file manager connection', { operation: 'file_connect', sessionId, hostId, authType });
|
||||
}
|
||||
|
||||
const config: any = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
@@ -121,26 +136,29 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) {
|
||||
fileLogger.info('Configuring SSH key authentication for file manager', { operation: 'file_connect', sessionId, hostId, hasKeyPassword: !!resolvedCredentials.keyPassword });
|
||||
try {
|
||||
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
|
||||
if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
|
||||
const cleanKey = resolvedCredentials.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');
|
||||
|
||||
if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword;
|
||||
|
||||
fileLogger.success('SSH key authentication configured successfully for file manager', { operation: 'file_connect', sessionId, hostId });
|
||||
} catch (keyError) {
|
||||
logger.error('SSH key format error: ' + keyError.message);
|
||||
fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message });
|
||||
return res.status(400).json({error: 'Invalid SSH key format'});
|
||||
}
|
||||
} else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
} else if (resolvedCredentials.password && resolvedCredentials.password.trim()) {
|
||||
fileLogger.info('Configuring password authentication for file manager', { operation: 'file_connect', sessionId, hostId });
|
||||
config.password = resolvedCredentials.password;
|
||||
} else {
|
||||
fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId });
|
||||
return res.status(400).json({error: 'Either password or SSH key must be provided'});
|
||||
}
|
||||
|
||||
@@ -149,6 +167,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||
client.on('ready', () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
fileLogger.success('SSH connection established for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType: resolvedCredentials.authType });
|
||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||
res.json({status: 'success', message: 'SSH connection established'});
|
||||
});
|
||||
@@ -156,7 +175,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||
client.on('error', (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
|
||||
fileLogger.error('SSH connection failed for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, error: err.message });
|
||||
res.status(500).json({status: 'error', message: err.message});
|
||||
});
|
||||
|
||||
@@ -194,12 +213,12 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
fileLogger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
@@ -216,7 +235,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
@@ -264,12 +283,12 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
fileLogger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
@@ -286,7 +305,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
@@ -321,7 +340,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
try {
|
||||
sshConn.client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
@@ -336,7 +355,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
fileBuffer = Buffer.from(content);
|
||||
}
|
||||
} catch (bufferErr) {
|
||||
logger.error('Buffer conversion error:', bufferErr);
|
||||
fileLogger.error('Buffer conversion error:', bufferErr);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: 'Invalid file content format'});
|
||||
}
|
||||
@@ -351,14 +370,14 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
writeStream.on('error', (streamErr) => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
@@ -367,7 +386,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
writeStream.on('close', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
@@ -379,12 +398,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
|
||||
fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
});
|
||||
} catch (sftpErr) {
|
||||
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
@@ -399,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
|
||||
logger.error('Fallback write command failed:', err);
|
||||
fileLogger.error('Fallback write command failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Write failed: ${err.message}`});
|
||||
}
|
||||
@@ -421,12 +440,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||
fileLogger.success(`File written successfully via fallback: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
} else {
|
||||
logger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
||||
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Write failed: ${errorData}`});
|
||||
}
|
||||
@@ -435,7 +454,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
|
||||
logger.error('Fallback write stream error:', streamErr);
|
||||
fileLogger.error('Fallback write stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -443,7 +462,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
|
||||
logger.error('Fallback method failed:', fallbackErr);
|
||||
fileLogger.error('Fallback method failed:', fallbackErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
||||
}
|
||||
@@ -470,17 +489,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
|
||||
|
||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||
|
||||
|
||||
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
sshConn.client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
@@ -496,7 +514,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
} catch (bufferErr) {
|
||||
|
||||
logger.error('Buffer conversion error:', bufferErr);
|
||||
fileLogger.error('Buffer conversion error:', bufferErr);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: 'Invalid file content format'});
|
||||
}
|
||||
@@ -511,7 +529,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
writeStream.on('error', (streamErr) => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
@@ -519,7 +537,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
|
||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
}
|
||||
@@ -529,7 +547,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
|
||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
}
|
||||
@@ -541,12 +559,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
|
||||
fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
});
|
||||
} catch (sftpErr) {
|
||||
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
@@ -570,8 +588,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
|
||||
logger.error('Fallback upload command failed:', err);
|
||||
|
||||
fileLogger.error('Fallback upload command failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
||||
}
|
||||
@@ -590,15 +608,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||
fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
}
|
||||
} else {
|
||||
logger.error(`Fallback upload failed with code ${code}: ${errorData}`);
|
||||
fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Upload failed: ${errorData}`});
|
||||
}
|
||||
@@ -606,8 +624,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
|
||||
logger.error('Fallback upload stream error:', streamErr);
|
||||
|
||||
fileLogger.error('Fallback upload stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -628,8 +646,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
|
||||
logger.error('Chunked fallback upload failed:', err);
|
||||
|
||||
fileLogger.error('Chunked fallback upload failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
||||
}
|
||||
@@ -648,15 +666,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||
fileLogger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
}
|
||||
} else {
|
||||
logger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
||||
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Chunked upload failed: ${errorData}`});
|
||||
}
|
||||
@@ -664,7 +682,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
logger.error('Chunked fallback upload stream error:', streamErr);
|
||||
fileLogger.error('Chunked fallback upload stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -672,7 +690,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
logger.error('Fallback method failed:', fallbackErr);
|
||||
fileLogger.error('Fallback method failed:', fallbackErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
||||
}
|
||||
@@ -707,7 +725,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH createFile error:', err);
|
||||
fileLogger.error('SSH createFile error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
@@ -725,7 +743,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
logger.error(`Permission denied creating file: ${fullPath}`);
|
||||
fileLogger.error(`Permission denied creating file: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`
|
||||
@@ -744,7 +762,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
@@ -757,7 +775,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
logger.error('SSH createFile stream error:', streamErr);
|
||||
fileLogger.error('SSH createFile stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -791,7 +809,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
|
||||
logger.error('SSH createFolder error:', err);
|
||||
fileLogger.error('SSH createFolder error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
@@ -809,7 +827,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
logger.error(`Permission denied creating folder: ${fullPath}`);
|
||||
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`
|
||||
@@ -828,7 +846,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
@@ -841,7 +859,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
logger.error('SSH createFolder stream error:', streamErr);
|
||||
fileLogger.error('SSH createFolder stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -874,7 +892,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
|
||||
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH deleteItem error:', err);
|
||||
fileLogger.error('SSH deleteItem error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
@@ -892,7 +910,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
logger.error(`Permission denied deleting: ${itemPath}`);
|
||||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`
|
||||
@@ -911,7 +929,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
@@ -924,7 +942,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
logger.error('SSH deleteItem stream error:', streamErr);
|
||||
fileLogger.error('SSH deleteItem stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -959,7 +977,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
|
||||
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH renameItem error:', err);
|
||||
fileLogger.error('SSH renameItem error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
@@ -977,7 +995,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
logger.error(`Permission denied renaming: ${oldPath}`);
|
||||
fileLogger.error(`Permission denied renaming: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`
|
||||
@@ -996,7 +1014,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
@@ -1009,7 +1027,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
logger.error('SSH renameItem stream error:', streamErr);
|
||||
fileLogger.error('SSH renameItem stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
@@ -1029,4 +1047,5 @@ process.on('SIGTERM', () => {
|
||||
|
||||
const PORT = 8084;
|
||||
app.listen(PORT, () => {
|
||||
fileLogger.success('File Manager API server started', { operation: 'server_start', port: PORT });
|
||||
});
|
||||
@@ -1,16 +1,40 @@
|
||||
import express from 'express';
|
||||
import chalk from 'chalk';
|
||||
import fetch from 'node-fetch';
|
||||
import net from 'net';
|
||||
import cors from 'cors';
|
||||
import {Client, type ConnectConfig} from 'ssh2';
|
||||
import {sshHostService} from '../services/ssh-host.js';
|
||||
import type {SSHHostWithCredentials} from '../services/ssh-host.js';
|
||||
|
||||
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import { statsLogger } from '../utils/logger.js';
|
||||
|
||||
type HostStatus = 'online' | 'offline';
|
||||
|
||||
interface SSHHostWithCredentials {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
type StatusEntry = {
|
||||
status: HostStatus;
|
||||
lastChecked: string;
|
||||
@@ -33,92 +57,127 @@ app.use((req, res, next) => {
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
const statsIconSymbol = '📡';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#22c55e')(`[${statsIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: {'x-internal-request': '1'}
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const rawHosts = Array.isArray(data) ? data : [];
|
||||
|
||||
// Resolve credentials for each host using the same logic as main SSH connections
|
||||
const hosts = await db.select().from(sshData);
|
||||
|
||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||
for (const rawHost of rawHosts) {
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
// Use the ssh-host service to properly resolve credentials
|
||||
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
|
||||
if (host) {
|
||||
hostsWithCredentials.push(host);
|
||||
const hostWithCreds = await resolveHostCredentials(host);
|
||||
if (hostWithCreds) {
|
||||
hostsWithCredentials.push(hostWithCreds);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to resolve credentials for host ${rawHost.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
statsLogger.warn(`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch hosts from database service', err);
|
||||
statsLogger.error('Failed to fetch hosts from database', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
// Get all users that might own this host
|
||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||
const resp = await fetch(url, {
|
||||
headers: {'x-internal-request': '1'}
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const rawHost = (Array.isArray(data) ? data : []).find((h: any) => h.id === id);
|
||||
|
||||
if (!rawHost) {
|
||||
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use ssh-host service to properly resolve credentials
|
||||
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
|
||||
|
||||
const host = hosts[0];
|
||||
return await resolveHostCredentials(host);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch host ${id}`, err);
|
||||
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
statsLogger.info('Resolving credentials for host', { operation: 'host_credential_resolve', hostId: host.id, hostName: host.name, hasCredentialId: !!host.credentialId });
|
||||
|
||||
const baseHost: any = {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder || '',
|
||||
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath || '/',
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
userId: host.userId
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
statsLogger.info('Fetching credentials from database', { operation: 'host_credential_resolve', hostId: host.id, credentialId: host.credentialId, userId: host.userId });
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId)
|
||||
));
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.authType;
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
}
|
||||
if (credential.keyType) {
|
||||
baseHost.keyType = credential.keyType;
|
||||
}
|
||||
} else {
|
||||
statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn(`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
|
||||
return baseHost;
|
||||
} catch (error) {
|
||||
statsLogger.error(`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function addLegacyCredentials(baseHost: any, host: any): void {
|
||||
baseHost.password = host.password || null;
|
||||
baseHost.key = host.key || null;
|
||||
baseHost.keyPassword = host.keyPassword || null;
|
||||
baseHost.keyType = host.keyType;
|
||||
}
|
||||
|
||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
const base: ConnectConfig = {
|
||||
host: host.ip,
|
||||
@@ -128,7 +187,6 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
algorithms: {}
|
||||
} as ConnectConfig;
|
||||
|
||||
// Use the same authentication logic as main SSH connections
|
||||
if (host.authType === 'password') {
|
||||
if (!host.password) {
|
||||
throw new Error(`No password available for host ${host.ip}`);
|
||||
@@ -138,27 +196,27 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
if (!host.key) {
|
||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
} catch (keyError) {
|
||||
logger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
|
||||
statsLogger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`);
|
||||
}
|
||||
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -316,24 +374,22 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
try {
|
||||
// Get both human-readable and bytes format for accurate calculation
|
||||
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
||||
|
||||
|
||||
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
|
||||
// Calculate our own percentage using bytes for accuracy
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
|
||||
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
||||
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||
}
|
||||
@@ -381,25 +437,30 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
|
||||
}
|
||||
|
||||
async function pollStatusesOnce(): Promise<void> {
|
||||
statsLogger.info('Starting status polling for all hosts', { operation: 'status_poll' });
|
||||
const hosts = await fetchAllHosts();
|
||||
if (hosts.length === 0) {
|
||||
logger.warn('No hosts retrieved for status polling');
|
||||
statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' });
|
||||
return;
|
||||
}
|
||||
|
||||
statsLogger.info('Polling status for hosts', { operation: 'status_poll', hostCount: hosts.length, hostIds: hosts.map(h => h.id) });
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const checks = hosts.map(async (h) => {
|
||||
statsLogger.info('Checking host status', { operation: 'status_poll', hostId: h.id, hostName: h.name, ip: h.ip, port: h.port });
|
||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
hostStatuses.set(h.id, statusEntry);
|
||||
statsLogger.info('Host status check completed', { operation: 'status_poll', hostId: h.id, hostName: h.name, status: isOnline ? 'online' : 'offline' });
|
||||
return isOnline;
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(checks);
|
||||
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
||||
const offlineCount = hosts.length - onlineCount;
|
||||
statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount });
|
||||
}
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
@@ -424,15 +485,15 @@ app.get('/status/:id', async (req, res) => {
|
||||
if (!host) {
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
|
||||
|
||||
hostStatuses.set(id, statusEntry);
|
||||
res.json(statusEntry);
|
||||
} catch (err) {
|
||||
logger.error('Failed to check host status', err);
|
||||
statsLogger.error('Failed to check host status', err);
|
||||
res.status(500).json({error: 'Failed to check host status'});
|
||||
}
|
||||
});
|
||||
@@ -455,7 +516,7 @@ app.get('/metrics/:id', async (req, res) => {
|
||||
const metrics = await collectMetrics(host);
|
||||
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||
} catch (err) {
|
||||
logger.error('Failed to collect metrics', err);
|
||||
statsLogger.error('Failed to collect metrics', err);
|
||||
return res.json({
|
||||
cpu: {percent: null, cores: null, load: null},
|
||||
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||
@@ -467,9 +528,10 @@ app.get('/metrics/:id', async (req, res) => {
|
||||
|
||||
const PORT = 8085;
|
||||
app.listen(PORT, async () => {
|
||||
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT });
|
||||
try {
|
||||
await pollStatusesOnce();
|
||||
} catch (err) {
|
||||
logger.error('Initial poll failed', err);
|
||||
statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' });
|
||||
}
|
||||
});
|
||||
@@ -1,48 +1,27 @@
|
||||
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
||||
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
||||
import chalk from 'chalk';
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import { sshLogger } from '../utils/logger.js';
|
||||
|
||||
const wss = new WebSocketServer({port: 8082});
|
||||
|
||||
sshLogger.success('SSH Terminal WebSocket server started', { operation: 'server_start', port: 8082 });
|
||||
|
||||
|
||||
|
||||
const sshIconSymbol = '🖥️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
|
||||
sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' });
|
||||
|
||||
|
||||
ws.on('close', () => {
|
||||
sshLogger.info('WebSocket connection closed', { operation: 'websocket_disconnect' });
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
@@ -53,7 +32,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
logger.error('Invalid JSON received: ' + msg.toString());
|
||||
sshLogger.error('Invalid JSON received', e, { operation: 'websocket_message', messageLength: msg.toString().length });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
||||
return;
|
||||
}
|
||||
@@ -62,7 +41,11 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
switch (type) {
|
||||
case 'connectToHost':
|
||||
handleConnectToHost(data);
|
||||
sshLogger.info('SSH connection request received', { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, port: data.hostConfig?.port });
|
||||
handleConnectToHost(data).catch(error => {
|
||||
sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')}));
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
@@ -70,6 +53,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
sshLogger.info('SSH disconnect requested', { operation: 'ssh_disconnect' });
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
@@ -90,14 +74,15 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type: ' + type);
|
||||
sshLogger.warn('Unknown message type received', { operation: 'websocket_message', messageType: type });
|
||||
}
|
||||
});
|
||||
|
||||
function handleConnectToHost(data: {
|
||||
async function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -106,25 +91,27 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
};
|
||||
}) {
|
||||
const {cols, rows, hostConfig} = data;
|
||||
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
|
||||
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig;
|
||||
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
logger.error('Invalid username provided');
|
||||
sshLogger.error('Invalid username provided', undefined, { operation: 'ssh_connect', hostId: id, ip });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||
logger.error('Invalid IP provided');
|
||||
sshLogger.error('Invalid IP provided', undefined, { operation: 'ssh_connect', hostId: id, username });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== 'number' || port <= 0) {
|
||||
logger.error('Invalid port provided');
|
||||
sshLogger.error('Invalid port provided', undefined, { operation: 'ssh_connect', hostId: id, ip, username, port });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
||||
return;
|
||||
}
|
||||
@@ -133,14 +120,41 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
logger.error('SSH connection timeout');
|
||||
sshLogger.error('SSH connection timeout', undefined, { operation: 'ssh_connect', hostId: id, ip, port, username });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
let resolvedCredentials = {password, key, keyPassword, keyType, authType};
|
||||
if (credentialId && id) {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, hostConfig.userId || '')
|
||||
));
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authType: credential.authType
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
sshLogger.success('SSH connection established', { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
|
||||
|
||||
|
||||
sshConn!.shell({
|
||||
@@ -149,7 +163,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
term: 'xterm-256color'
|
||||
} as PseudoTtyOptions, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
sshLogger.error('Shell error', err, { operation: 'ssh_shell', hostId: id, ip, port, username });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||
return;
|
||||
}
|
||||
@@ -161,12 +175,12 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
|
||||
sshLogger.info('SSH stream closed', { operation: 'ssh_stream', hostId: id, ip, port, username });
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error('SSH stream error: ' + err.message);
|
||||
sshLogger.error('SSH stream error', err, { operation: 'ssh_stream', hostId: id, ip, port, username });
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
});
|
||||
|
||||
@@ -178,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshConn.on('error', (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.error('SSH connection error: ' + err.message);
|
||||
sshLogger.error('SSH connection error', err, { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
|
||||
|
||||
let errorMessage = 'SSH error: ' + err.message;
|
||||
if (err.message.includes('No matching key exchange algorithm')) {
|
||||
@@ -210,7 +224,6 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
@@ -269,34 +282,34 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
]
|
||||
}
|
||||
};
|
||||
if (authType === 'key' && key) {
|
||||
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) {
|
||||
try {
|
||||
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
|
||||
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
|
||||
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
logger.error('SSH key format error: ' + keyError.message);
|
||||
sshLogger.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');
|
||||
} else if (resolvedCredentials.authType === 'key') {
|
||||
sshLogger.error('SSH key authentication requested but no key provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
||||
return;
|
||||
} else {
|
||||
connectConfig.password = password;
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
@@ -323,7 +336,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing stream: ' + e.message);
|
||||
sshLogger.error('Error closing stream: ' + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
@@ -332,7 +345,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing connection: ' + e.message);
|
||||
sshLogger.error('Error closing connection: ' + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
@@ -344,7 +357,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
try {
|
||||
sshStream.write('\x00');
|
||||
} catch (e: any) {
|
||||
logger.error('SSH keepalive failed: ' + e.message);
|
||||
sshLogger.error('SSH keepalive failed: ' + e.message);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,23 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import {Client} from 'ssh2';
|
||||
import {ChildProcess} from 'child_process';
|
||||
import chalk from 'chalk';
|
||||
import axios from 'axios';
|
||||
import * as net from 'net';
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConfig,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
HostConfig,
|
||||
VerificationData,
|
||||
ConnectionState,
|
||||
ErrorType
|
||||
} from '../../types/index.js';
|
||||
import { CONNECTION_STATES } from '../../types/index.js';
|
||||
import { tunnelLogger } from '../utils/logger.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
@@ -14,31 +28,6 @@ app.use(cors({
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const tunnelIconSymbol = '📡';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${tunnelIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const activeTunnels = new Map<string, Client>();
|
||||
const retryCounters = new Map<string, number>();
|
||||
@@ -53,109 +42,17 @@ const retryExhaustedTunnels = new Set<string>();
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TunnelConfig {
|
||||
name: string;
|
||||
hostName: string;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword?: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey?: string;
|
||||
sourceKeyPassword?: string;
|
||||
sourceKeyType?: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword?: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
endpointKeyType?: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
host: SSHHost;
|
||||
tunnels: TunnelConfig[];
|
||||
}
|
||||
|
||||
interface TunnelStatus {
|
||||
connected: boolean;
|
||||
status: ConnectionState;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
reason?: string;
|
||||
errorType?: ErrorType;
|
||||
manualDisconnect?: boolean;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
interface VerificationData {
|
||||
conn: Client;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
WAITING: "waiting"
|
||||
} as const;
|
||||
|
||||
const ERROR_TYPES = {
|
||||
AUTH: "authentication",
|
||||
NETWORK: "network",
|
||||
PORT: "port_conflict",
|
||||
PERMISSION: "permission",
|
||||
TIMEOUT: "timeout",
|
||||
UNKNOWN: "unknown"
|
||||
AUTH: "AUTHENTICATION_FAILED",
|
||||
NETWORK: "NETWORK_ERROR",
|
||||
PORT: "CONNECTION_FAILED",
|
||||
PERMISSION: "CONNECTION_FAILED",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNKNOWN: "UNKNOWN"
|
||||
} as const;
|
||||
|
||||
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
|
||||
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
|
||||
|
||||
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
|
||||
@@ -178,7 +75,7 @@ function getAllTunnelStatus(): Record<string, TunnelStatus> {
|
||||
}
|
||||
|
||||
function classifyError(errorMessage: string): ErrorType {
|
||||
if (!errorMessage) return ERROR_TYPES.UNKNOWN;
|
||||
if (!errorMessage) return 'UNKNOWN';
|
||||
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
@@ -186,34 +83,34 @@ function classifyError(errorMessage: string): ErrorType {
|
||||
message.includes("connection reset by peer") ||
|
||||
message.includes("connection refused") ||
|
||||
message.includes("broken pipe")) {
|
||||
return ERROR_TYPES.NETWORK;
|
||||
return 'NETWORK_ERROR';
|
||||
}
|
||||
|
||||
if (message.includes("authentication failed") ||
|
||||
message.includes("permission denied") ||
|
||||
message.includes("incorrect password")) {
|
||||
return ERROR_TYPES.AUTH;
|
||||
return 'AUTHENTICATION_FAILED';
|
||||
}
|
||||
|
||||
if (message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")) {
|
||||
return ERROR_TYPES.TIMEOUT;
|
||||
return 'TIMEOUT';
|
||||
}
|
||||
|
||||
if (message.includes("bind: address already in use") ||
|
||||
message.includes("failed for listen port") ||
|
||||
message.includes("port forwarding failed")) {
|
||||
return ERROR_TYPES.PORT;
|
||||
return 'CONNECTION_FAILED';
|
||||
}
|
||||
|
||||
if (message.includes("permission") ||
|
||||
message.includes("access denied")) {
|
||||
return ERROR_TYPES.PERMISSION;
|
||||
return 'CONNECTION_FAILED';
|
||||
}
|
||||
|
||||
return ERROR_TYPES.UNKNOWN;
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function getTunnelMarker(tunnelName: string) {
|
||||
@@ -225,7 +122,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
if (tunnelConfig) {
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`);
|
||||
tunnelLogger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -237,7 +134,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e);
|
||||
tunnelLogger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e);
|
||||
}
|
||||
activeTunnelProcesses.delete(tunnelName);
|
||||
}
|
||||
@@ -249,7 +146,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
conn.end();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e);
|
||||
tunnelLogger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e);
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
}
|
||||
@@ -359,7 +256,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
retryCount = retryCount + 1;
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||
tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||
|
||||
retryExhaustedTunnels.add(tunnelName);
|
||||
activeTunnels.delete(tunnelName);
|
||||
@@ -423,7 +320,9 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
activeTunnels.delete(tunnelName);
|
||||
connectSSHTunnel(tunnelConfig, retryCount);
|
||||
connectSSHTunnel(tunnelConfig, retryCount).catch(error => {
|
||||
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
});
|
||||
}
|
||||
}, retryInterval);
|
||||
|
||||
@@ -457,7 +356,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
||||
clearInterval(verificationTimers.get(pingKey)!);
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||
@@ -475,15 +374,18 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
|
||||
verificationTimers.set(pingKey, pingInterval);
|
||||
}
|
||||
|
||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): Promise<void> {
|
||||
const tunnelName = tunnelConfig.name;
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
|
||||
tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, retryAttempt, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort });
|
||||
|
||||
if (manualDisconnects.has(tunnelName)) {
|
||||
tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -492,10 +394,14 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
if (retryAttempt === 0) {
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
tunnelLogger.info('Reset retry state for tunnel', { operation: 'tunnel_connect', tunnelName });
|
||||
} else {
|
||||
tunnelLogger.warn('Tunnel connection retry attempt', { operation: 'tunnel_connect', tunnelName, retryAttempt });
|
||||
}
|
||||
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
tunnelLogger.info('Broadcasting tunnel connecting status', { operation: 'tunnel_connect', tunnelName, retryAttempt });
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
@@ -504,7 +410,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
}
|
||||
|
||||
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
|
||||
logger.error(`Invalid connection details for '${tunnelName}'`);
|
||||
tunnelLogger.error('Invalid tunnel connection details', { operation: 'tunnel_connect', tunnelName, hasSourceIP: !!tunnelConfig?.sourceIP, hasSourceUsername: !!tunnelConfig?.sourceUsername, hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort });
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
@@ -513,6 +419,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedSourceCredentials = {
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
||||
tunnelLogger.info('Resolving source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, userId: tunnelConfig.sourceUserId });
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId)
|
||||
));
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType
|
||||
};
|
||||
tunnelLogger.success('Source credentials resolved successfully', { operation: 'tunnel_connect', tunnelName, credentialId: credential.id, authType: credential.authType });
|
||||
} else {
|
||||
tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId });
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
} else {
|
||||
tunnelLogger.info('Using direct source credentials from tunnel config', { operation: 'tunnel_connect', tunnelName, authMethod: tunnelConfig.sourceAuthMethod });
|
||||
}
|
||||
|
||||
// Resolve endpoint credentials if tunnel config has endpointCredentialId
|
||||
let resolvedEndpointCredentials = {
|
||||
password: tunnelConfig.endpointPassword,
|
||||
sshKey: tunnelConfig.endpointSSHKey,
|
||||
keyPassword: tunnelConfig.endpointKeyPassword,
|
||||
keyType: tunnelConfig.endpointKeyType,
|
||||
authMethod: tunnelConfig.endpointAuthMethod
|
||||
};
|
||||
|
||||
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.endpointUserId)
|
||||
));
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const conn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
@@ -536,7 +515,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
return;
|
||||
@@ -555,11 +534,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
|
||||
errorType === ERROR_TYPES.PORT ||
|
||||
errorType === ERROR_TYPES.PERMISSION ||
|
||||
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
|
||||
errorType === 'CONNECTION_FAILED' ||
|
||||
manualDisconnects.has(tunnelName);
|
||||
|
||||
|
||||
|
||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||
@@ -596,25 +573,24 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
}
|
||||
|
||||
let tunnelCmd: string;
|
||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||
if (resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.sshKey) {
|
||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
||||
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
||||
} else {
|
||||
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
|
||||
tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
|
||||
}
|
||||
|
||||
conn.exec(tunnelCmd, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
||||
tunnelLogger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
||||
|
||||
conn.end();
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
const errorType = classifyError(err.message);
|
||||
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
|
||||
errorType === ERROR_TYPES.PORT ||
|
||||
errorType === ERROR_TYPES.PERMISSION;
|
||||
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
|
||||
errorType === 'CONNECTION_FAILED';
|
||||
|
||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||
return;
|
||||
@@ -696,7 +672,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
@@ -737,9 +713,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
}
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
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`);
|
||||
if (resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey) {
|
||||
if (!resolvedSourceCredentials.sshKey.includes('-----BEGIN') || !resolvedSourceCredentials.sshKey.includes('-----END')) {
|
||||
tunnelLogger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
@@ -748,16 +724,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const cleanKey = resolvedSourceCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
if (resolvedSourceCredentials.keyPassword) {
|
||||
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
|
||||
}
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
if (resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== 'auto') {
|
||||
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
|
||||
}
|
||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
||||
} else if (resolvedSourceCredentials.authMethod === "key") {
|
||||
tunnelLogger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
@@ -765,7 +741,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
connOptions.password = resolvedSourceCredentials.password;
|
||||
}
|
||||
|
||||
const finalStatus = connectionStatus.get(tunnelName);
|
||||
@@ -832,7 +808,7 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
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) {
|
||||
@@ -898,7 +874,9 @@ app.post('/ssh/tunnel/connect', (req, res) => {
|
||||
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
connectSSHTunnel(tunnelConfig, 0).catch(error => {
|
||||
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
});
|
||||
|
||||
res.json({message: 'Connection request received', tunnelName});
|
||||
});
|
||||
@@ -1027,22 +1005,25 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||
tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||
|
||||
for (const tunnelConfig of autoStartTunnels) {
|
||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||
|
||||
setTimeout(() => {
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
connectSSHTunnel(tunnelConfig, 0).catch(error => {
|
||||
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to initialize auto-start tunnels:', error.message);
|
||||
tunnelLogger.error('Failed to initialize auto-start tunnels:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const PORT = 8083;
|
||||
app.listen(PORT, () => {
|
||||
tunnelLogger.success('SSH Tunnel API server started', { operation: 'server_start', port: PORT });
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
|
||||
Reference in New Issue
Block a user