Clean up code
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import {Client as SSHClient} from 'ssh2';
|
||||
import chalk from "chalk";
|
||||
|
||||
const app = express();
|
||||
@@ -38,23 +38,25 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
// --- SSH Operations (per-session, in-memory, with cleanup) ---
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
try { session.client.end(); } catch {}
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
logger.info(`Cleaned up SSH session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,129 +69,111 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
}
|
||||
|
||||
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
|
||||
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
|
||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
logger.warn('Missing SSH connection parameters');
|
||||
return res.status(400).json({ error: 'Missing SSH connection parameters' });
|
||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||
}
|
||||
|
||||
logger.info(`Attempting SSH connection: ${ip}:${port} as ${username} (session: ${sessionId})`);
|
||||
logger.info(`Auth method: ${sshKey ? 'SSH Key' : password ? 'Password' : 'None'}`);
|
||||
logger.info(`Request body keys: ${Object.keys(req.body).join(', ')}`);
|
||||
logger.info(`Password present: ${!!password}, Key present: ${!!sshKey}`);
|
||||
|
||||
|
||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
||||
const client = new SSHClient();
|
||||
const config: any = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
};
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
logger.info('Using SSH key authentication');
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
} else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
} else {
|
||||
return res.status(400).json({error: 'Either password or SSH key must be provided'});
|
||||
}
|
||||
else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
logger.info('Using password authentication');
|
||||
}
|
||||
else {
|
||||
logger.warn('No password or key provided');
|
||||
return res.status(400).json({ error: 'Either password or SSH key must be provided' });
|
||||
}
|
||||
|
||||
// Create a response promise to handle async connection
|
||||
|
||||
let responseSent = false;
|
||||
|
||||
|
||||
client.on('ready', () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
|
||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
|
||||
res.json({ status: 'success', message: 'SSH connection established' });
|
||||
res.json({status: 'success', message: 'SSH connection established'});
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
|
||||
logger.error(`Connection details: ${ip}:${port} as ${username}`);
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
res.status(500).json({status: 'error', message: err.message});
|
||||
});
|
||||
|
||||
|
||||
client.on('close', () => {
|
||||
logger.info(`SSH connection closed for session ${sessionId}`);
|
||||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
|
||||
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const {sessionId} = req.body;
|
||||
cleanupSession(sessionId);
|
||||
res.json({ status: 'success', message: 'SSH connection disconnected' });
|
||||
res.json({status: 'success', message: 'SSH connection disconnected'});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/status', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||
res.json({ status: 'success', connected: isConnected });
|
||||
res.json({status: 'success', connected: isConnected});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = decodeURIComponent((req.query.path as string) || '/');
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for listFiles');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Escape the path properly for shell command
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`);
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
|
||||
const lines = data.split('\n').filter(line => line.trim());
|
||||
const files = [];
|
||||
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
@@ -198,17 +182,16 @@ app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
||||
const name = parts.slice(8).join(' ');
|
||||
const isDirectory = permissions.startsWith('d');
|
||||
const isLink = permissions.startsWith('l');
|
||||
|
||||
// Skip . and .. directories
|
||||
|
||||
if (name === '.' || name === '..') continue;
|
||||
|
||||
files.push({
|
||||
name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
||||
|
||||
files.push({
|
||||
name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
res.json(files);
|
||||
});
|
||||
});
|
||||
@@ -218,226 +201,188 @@ app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const filePath = decodeURIComponent(req.query.path as string);
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for readFile');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for readFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Escape the file path properly
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData}`);
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
res.json({ content: data, path: filePath });
|
||||
|
||||
res.json({content: data, path: filePath});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
|
||||
const { sessionId, path: filePath, content } = req.body;
|
||||
const {sessionId, path: filePath, content} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for writeFile');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
logger.info(`SSH connection status for session ${sessionId}: connected=${sshConn.isConnected}, lastActive=${new Date(sshConn.lastActive).toISOString()}`);
|
||||
|
||||
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for writeFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
|
||||
if (content === undefined) {
|
||||
logger.warn('File content is required for writeFile');
|
||||
return res.status(400).json({ error: 'File content is required' });
|
||||
return res.status(400).json({error: 'File content is required'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Write to a temp file, then move - properly escape paths and content
|
||||
|
||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Use base64 encoding to safely transfer content
|
||||
|
||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||
|
||||
logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`);
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
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' });
|
||||
}
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
// First check file permissions and ownership
|
||||
|
||||
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'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
|
||||
logger.info(`Checking file details: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
logger.error('File check failed:', checkErr);
|
||||
return res.status(500).json({ error: `File check failed: ${checkErr.message}` });
|
||||
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
|
||||
}
|
||||
|
||||
|
||||
let checkResult = '';
|
||||
checkStream.on('data', (chunk: Buffer) => {
|
||||
checkResult += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
checkStream.on('close', (checkCode) => {
|
||||
logger.info(`File check result: ${checkResult.trim()}`);
|
||||
|
||||
// Use a simpler approach: write base64 to temp file, decode and write to target, then clean up
|
||||
// Add explicit exit to ensure the command completes
|
||||
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
logger.info(`Executing write command for: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile error:', err);
|
||||
logger.error('SSH writeFile error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let outputData = '';
|
||||
let errorData = '';
|
||||
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
|
||||
|
||||
// Check for permission denied and fail fast
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied writing to file: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
// Check if we got the success message
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
// Verify the file was actually written by checking its size
|
||||
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
|
||||
logger.info(`Verifying file was written: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
|
||||
if (verifyErr) {
|
||||
logger.warn('File verification failed, but assuming success:');
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let verifyResult = '';
|
||||
verifyStream.on('data', (chunk: Buffer) => {
|
||||
verifyResult += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
verifyStream.on('close', (verifyCode) => {
|
||||
const fileSize = Number(verifyResult.trim());
|
||||
logger.info(`File verification result: size=${fileSize} bytes`);
|
||||
|
||||
|
||||
if (fileSize > 0) {
|
||||
logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
} else {
|
||||
logger.error(`File appears to be empty after write: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'File write operation may have failed - file appears empty' });
|
||||
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData}`);
|
||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If code is 0 but no SUCCESS message, assume it worked anyway
|
||||
// This handles cases where the echo "SUCCESS" didn't work but the file write did
|
||||
logger.info(`File written successfully (code 0, no SUCCESS message): ${filePath}`);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -456,4 +401,5 @@ process.on('SIGTERM', () => {
|
||||
});
|
||||
|
||||
const PORT = 8084;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -41,7 +41,7 @@ const logger = {
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
res.json({status: 'ok'});
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
@@ -49,8 +49,9 @@ app.use('/ssh', sshRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
res.status(500).json({error: 'Internal Server Error'});
|
||||
});
|
||||
|
||||
const PORT = 8081;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
@@ -34,108 +34,296 @@ const logger = {
|
||||
const dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
fs.mkdirSync(dbDir, {recursive: true});
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
// Create tables using Drizzle schema
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password_hash
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
is_admin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_config_editor INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
folder
|
||||
TEXT,
|
||||
tags
|
||||
TEXT,
|
||||
pin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
auth_type
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password
|
||||
TEXT,
|
||||
key
|
||||
TEXT,
|
||||
key_password
|
||||
TEXT,
|
||||
key_type
|
||||
TEXT,
|
||||
enable_terminal
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
enable_tunnel
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
tunnel_connections
|
||||
TEXT,
|
||||
enable_config_editor
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
default_path
|
||||
TEXT,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
updated_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
last_opened
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
pinned_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
// Function to safely add a column if it doesn't exist
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
try {
|
||||
// Try to select the column to see if it exists
|
||||
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get();
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
// Column doesn't exist, add it
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-migrate: Add any missing columns based on current schema
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
|
||||
// Add missing columns to users table
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Add missing columns to ssh_data table
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
@@ -153,7 +341,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
|
||||
// Add missing columns to config_editor tables
|
||||
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
@@ -161,10 +348,8 @@ const migrateSchema = () => {
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
// Run auto-migration
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
@@ -174,4 +359,4 @@ try {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {sql} from 'drizzle-orm';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(), // Unique user ID (nanoid)
|
||||
username: text('username').notNull(), // Username
|
||||
password_hash: text('password_hash').notNull(), // Hashed password
|
||||
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
@@ -14,52 +14,52 @@ export const settings = sqliteTable('settings', {
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'), // Host name
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'), // JSON stringified array
|
||||
pin: integer('pin', { mode: 'boolean' }).notNull().default(false),
|
||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', { length: 8192 }), // Increased for larger keys
|
||||
keyPassword: text('key_password'), // Password for protected keys
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
|
||||
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true),
|
||||
defaultPath: text('default_path'), // Default path for SSH connection
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
|
||||
defaultPath: text('default_path'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorRecent = sqliteTable('config_editor_recent', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // File name
|
||||
path: text('path').notNull(), // File path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // File name
|
||||
path: text('path').notNull(), // File path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // Folder name
|
||||
path: text('path').notNull(), // Folder path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import {db} from '../db/index.js';
|
||||
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
|
||||
import {eq, and, desc} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import multer from 'multer';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -38,6 +38,7 @@ const router = express.Router();
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function isValidPort(val: any): val is number {
|
||||
return typeof val === 'number' && val > 0 && val < 65536;
|
||||
}
|
||||
@@ -48,14 +49,12 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Only allow specific file types for SSH keys
|
||||
if (file.fieldname === 'key') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
@@ -64,12 +63,11 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
@@ -79,11 +77,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if request is from localhost
|
||||
function isLocalhost(req: Request) {
|
||||
const ip = req.ip || req.connection?.remoteAddress;
|
||||
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
@@ -93,7 +90,7 @@ function isLocalhost(req: Request) {
|
||||
router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
|
||||
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
return res.status(403).json({error: 'Forbidden'});
|
||||
}
|
||||
try {
|
||||
const data = await db.select().from(sshData);
|
||||
@@ -110,7 +107,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data (internal)', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,7 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
// POST /ssh/host
|
||||
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
@@ -127,13 +124,13 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
return res.status(400).json({error: 'Invalid JSON data'});
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
return res.status(400).json({error: 'Missing data field'});
|
||||
}
|
||||
|
||||
|
||||
// Add the file data if present
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
@@ -142,12 +139,30 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
|
||||
const {
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
authMethod,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
return res.status(400).json({error: 'Invalid SSH data'});
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
@@ -167,7 +182,6 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
@@ -182,10 +196,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
|
||||
try {
|
||||
await db.insert(sshData).values(sshDataObj);
|
||||
res.json({ message: 'SSH data created' });
|
||||
res.json({message: 'SSH data created'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to save SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to save SSH data' });
|
||||
res.status(500).json({error: 'Failed to save SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,37 +207,51 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
if (req.body.data) {
|
||||
try {
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
return res.status(400).json({error: 'Invalid JSON data'});
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
return res.status(400).json({error: 'Missing data field'});
|
||||
}
|
||||
|
||||
// Add the file data if present
|
||||
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
}
|
||||
} else {
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
const { id } = req.params;
|
||||
|
||||
const {
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
authMethod,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
|
||||
logger.warn('Invalid SSH data input for update');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
return res.status(400).json({error: 'Invalid SSH data'});
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
@@ -242,7 +270,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
@@ -259,10 +286,10 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
await db.update(sshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH data updated' });
|
||||
res.json({message: 'SSH data updated'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to update SSH data' });
|
||||
res.status(500).json({error: 'Failed to update SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,14 +299,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH data fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
||||
const result = data.map((row: any) => ({
|
||||
...row,
|
||||
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
||||
@@ -292,31 +318,31 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH host by ID (requires JWT)
|
||||
// GET /ssh/host/:id
|
||||
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for SSH host fetch');
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
|
||||
|
||||
if (data.length === 0) {
|
||||
return res.status(404).json({ error: 'SSH host not found' });
|
||||
return res.status(404).json({error: 'SSH host not found'});
|
||||
}
|
||||
|
||||
|
||||
const host = data[0];
|
||||
const result = {
|
||||
...host,
|
||||
@@ -327,11 +353,11 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
enableConfigEditor: !!host.enableConfigEditor,
|
||||
};
|
||||
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH host' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH host'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -341,11 +367,11 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH folder fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: sshData.folder })
|
||||
.select({folder: sshData.folder})
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
|
||||
@@ -361,7 +387,7 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH folders', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH folders'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -369,39 +395,37 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
// DELETE /ssh/host/:id
|
||||
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const {id} = req.params;
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid userId or id for SSH host delete');
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
return res.status(400).json({error: 'Invalid userId or id'});
|
||||
}
|
||||
try {
|
||||
const result = await db.delete(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH host deleted' });
|
||||
res.json({message: 'SSH host deleted'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||
res.status(500).json({error: 'Failed to delete SSH host'});
|
||||
}
|
||||
});
|
||||
|
||||
// Config Editor Database Routes
|
||||
|
||||
// Route: Get recent files (requires JWT)
|
||||
// GET /ssh/config_editor/recent
|
||||
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for recent files fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for recent files fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const recentFiles = await db
|
||||
.select()
|
||||
@@ -414,7 +438,7 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
||||
res.json(recentFiles);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch recent files', err);
|
||||
res.status(500).json({ error: 'Failed to fetch recent files' });
|
||||
res.status(500).json({error: 'Failed to fetch recent files'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -422,29 +446,27 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
||||
// POST /ssh/config_editor/recent
|
||||
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding recent file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if file already exists in recent for this host
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorRecent)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update lastOpened timestamp
|
||||
await db
|
||||
.update(configEditorRecent)
|
||||
.set({ lastOpened: new Date().toISOString() })
|
||||
.set({lastOpened: new Date().toISOString()})
|
||||
.where(and(...conditions));
|
||||
} else {
|
||||
// Add new recent file
|
||||
@@ -456,10 +478,10 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
lastOpened: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'File added to recent' });
|
||||
res.json({message: 'File added to recent'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to add recent file', err);
|
||||
res.status(500).json({ error: 'Failed to add recent file' });
|
||||
res.status(500).json({error: 'Failed to add recent file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -467,28 +489,25 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
// DELETE /ssh/config_editor/recent
|
||||
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing recent file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorRecent)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Recent file removed successfully`);
|
||||
res.json({ message: 'File removed from recent' });
|
||||
res.json({message: 'File removed from recent'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove recent file', err);
|
||||
res.status(500).json({ error: 'Failed to remove recent file' });
|
||||
res.status(500).json({error: 'Failed to remove recent file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -497,17 +516,17 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
||||
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for pinned files fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for pinned files fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const pinnedFiles = await db
|
||||
.select()
|
||||
@@ -520,7 +539,7 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
||||
res.json(pinnedFiles);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch pinned files', err);
|
||||
res.status(500).json({ error: 'Failed to fetch pinned files' });
|
||||
res.status(500).json({error: 'Failed to fetch pinned files'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -528,26 +547,24 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
||||
// POST /ssh/config_editor/pinned
|
||||
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding pinned file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if file already exists in pinned for this host
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorPinned)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length === 0) {
|
||||
// Add new pinned file
|
||||
await db.insert(configEditorPinned).values({
|
||||
userId,
|
||||
hostId,
|
||||
@@ -556,10 +573,10 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
pinnedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'File pinned successfully' });
|
||||
res.json({message: 'File pinned successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to pin file', err);
|
||||
res.status(500).json({ error: 'Failed to pin file' });
|
||||
res.status(500).json({error: 'Failed to pin file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -567,28 +584,25 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
// DELETE /ssh/config_editor/pinned
|
||||
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing pinned file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorPinned)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Pinned file removed successfully`);
|
||||
res.json({ message: 'File unpinned successfully' });
|
||||
res.json({message: 'File unpinned successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to unpin file', err);
|
||||
res.status(500).json({ error: 'Failed to unpin file' });
|
||||
res.status(500).json({error: 'Failed to unpin file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -597,17 +611,15 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
||||
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for shortcuts fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for shortcuts fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const shortcuts = await db
|
||||
.select()
|
||||
@@ -620,7 +632,7 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
||||
res.json(shortcuts);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch shortcuts', err);
|
||||
res.status(500).json({ error: 'Failed to fetch shortcuts' });
|
||||
res.status(500).json({error: 'Failed to fetch shortcuts'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -628,26 +640,23 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
||||
// POST /ssh/config_editor/shortcuts
|
||||
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding shortcut');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if shortcut already exists for this host
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorShortcuts)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length === 0) {
|
||||
// Add new shortcut
|
||||
await db.insert(configEditorShortcuts).values({
|
||||
userId,
|
||||
hostId,
|
||||
@@ -656,10 +665,10 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'Shortcut added successfully' });
|
||||
res.json({message: 'Shortcut added successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to add shortcut', err);
|
||||
res.status(500).json({ error: 'Failed to add shortcut' });
|
||||
res.status(500).json({error: 'Failed to add shortcut'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -667,28 +676,24 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
// DELETE /ssh/config_editor/shortcuts
|
||||
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing shortcut');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorShortcuts)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Shortcut removed successfully`);
|
||||
res.json({ message: 'Shortcut removed successfully' });
|
||||
res.json({message: 'Shortcut removed successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove shortcut', err);
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
res.status(500).json({error: 'Failed to remove shortcut'});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { users, settings } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {db} from '../db/index.js';
|
||||
import {users, settings} from '../db/schema.js';
|
||||
import {eq} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {nanoid} from 'nanoid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -51,7 +51,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
@@ -61,7 +61,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +71,14 @@ router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (row && (row as any).value !== 'true') {
|
||||
return res.status(403).json({ error: 'Registration is currently disabled' });
|
||||
return res.status(403).json({error: 'Registration is currently disabled'});
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
const { username, password } = req.body;
|
||||
const {username, password} = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid user creation attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
return res.status(400).json({error: 'Invalid username or password'});
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -87,7 +87,7 @@ router.post('/create', async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn(`Attempt to create duplicate username: ${username}`);
|
||||
return res.status(409).json({ error: 'Username already exists' });
|
||||
return res.status(409).json({error: 'Username already exists'});
|
||||
}
|
||||
let isFirstUser = false;
|
||||
try {
|
||||
@@ -99,22 +99,22 @@ router.post('/create', async (req, res) => {
|
||||
const saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({ id, username, password_hash, is_admin: isFirstUser });
|
||||
await db.insert(users).values({id, username, password_hash, is_admin: isFirstUser});
|
||||
logger.success(`User created: ${username} (is_admin: ${isFirstUser})`);
|
||||
res.json({ message: 'User created', is_admin: isFirstUser });
|
||||
res.json({message: 'User created', is_admin: isFirstUser});
|
||||
} catch (err) {
|
||||
logger.error('Failed to create user', err);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
res.status(500).json({error: 'Failed to create user'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get user JWT by username and password
|
||||
// POST /users/get
|
||||
router.post('/get', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const {username, password} = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid get user attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
return res.status(400).json({error: 'Invalid username or password'});
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
@@ -123,20 +123,20 @@ router.post('/get', async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found: ${username}`);
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
const userRecord = user[0];
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Incorrect password for user: ${username}`);
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
return res.status(401).json({error: 'Incorrect password'});
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
|
||||
res.json({ token });
|
||||
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {expiresIn: '50d'});
|
||||
res.json({token});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get user', err);
|
||||
res.status(500).json({ error: 'Failed to get user' });
|
||||
res.status(500).json({error: 'Failed to get user'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId in JWT for /users/me');
|
||||
return res.status(401).json({ error: 'Invalid userId' });
|
||||
return res.status(401).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
@@ -155,12 +155,12 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
.where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found for /users/me: ${userId}`);
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
return res.status(401).json({error: 'User not found'});
|
||||
}
|
||||
res.json({ username: user[0].username, is_admin: !!user[0].is_admin });
|
||||
res.json({username: user[0].username, is_admin: !!user[0].is_admin});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get username', err);
|
||||
res.status(500).json({ error: 'Failed to get username' });
|
||||
res.status(500).json({error: 'Failed to get username'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,10 +170,10 @@ router.get('/count', async (req, res) => {
|
||||
try {
|
||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
const count = (countResult as any)?.count || 0;
|
||||
res.json({ count });
|
||||
res.json({count});
|
||||
} catch (err) {
|
||||
logger.error('Failed to count users', err);
|
||||
res.status(500).json({ error: 'Failed to count users' });
|
||||
res.status(500).json({error: 'Failed to count users'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -182,10 +182,10 @@ router.get('/count', async (req, res) => {
|
||||
router.get('/db-health', async (req, res) => {
|
||||
try {
|
||||
db.$client.prepare('SELECT 1').get();
|
||||
res.json({ status: 'ok' });
|
||||
res.json({status: 'ok'});
|
||||
} catch (err) {
|
||||
logger.error('DB health check failed', err);
|
||||
res.status(500).json({ error: 'Database not accessible' });
|
||||
res.status(500).json({error: 'Database not accessible'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,10 +194,10 @@ router.get('/db-health', async (req, res) => {
|
||||
router.get('/registration-allowed', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
res.json({ allowed: row ? (row as any).value === 'true' : true });
|
||||
res.json({allowed: row ? (row as any).value === 'true' : true});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to get registration allowed' });
|
||||
res.status(500).json({error: 'Failed to get registration allowed'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,17 +208,17 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
return res.status(403).json({error: 'Not authorized'});
|
||||
}
|
||||
const { allowed } = req.body;
|
||||
const {allowed} = req.body;
|
||||
if (typeof allowed !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Invalid value for allowed' });
|
||||
return res.status(400).json({error: 'Invalid value for allowed'});
|
||||
}
|
||||
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
|
||||
res.json({ allowed });
|
||||
res.json({allowed});
|
||||
} catch (err) {
|
||||
logger.error('Failed to set registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to set registration allowed' });
|
||||
res.status(500).json({error: 'Failed to set registration allowed'});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -40,21 +40,19 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
// State management for host-based tunnels
|
||||
const activeTunnels = new Map<string, Client>(); // tunnelName -> Client
|
||||
const retryCounters = new Map<string, number>(); // tunnelName -> retryCount
|
||||
const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status
|
||||
const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification
|
||||
const manualDisconnects = new Set<string>(); // tunnelNames
|
||||
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
|
||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
||||
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
||||
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||
const activeTunnels = new Map<string, Client>();
|
||||
const retryCounters = new Map<string, number>();
|
||||
const connectionStatus = new Map<string, TunnelStatus>();
|
||||
const tunnelVerifications = new Map<string, VerificationData>();
|
||||
const manualDisconnects = new Set<string>();
|
||||
const verificationTimers = new Map<string, NodeJS.Timeout>();
|
||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
|
||||
const countdownIntervals = new Map<string, NodeJS.Timeout>();
|
||||
const retryExhaustedTunnels = new Set<string>();
|
||||
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>();s
|
||||
|
||||
// Types
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
@@ -159,7 +157,6 @@ const ERROR_TYPES = {
|
||||
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
|
||||
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
|
||||
|
||||
// Helper functions
|
||||
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
|
||||
return;
|
||||
@@ -218,14 +215,11 @@ function classifyError(errorMessage: string): ErrorType {
|
||||
return ERROR_TYPES.UNKNOWN;
|
||||
}
|
||||
|
||||
// Helper to build a unique marker for each tunnel
|
||||
function getTunnelMarker(tunnelName: string) {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
// Cleanup and disconnect functions
|
||||
function cleanupTunnelResources(tunnelName: string): void {
|
||||
// Fire-and-forget remote pkill (do not block local cleanup)
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
||||
if (tunnelConfig) {
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
@@ -235,7 +229,6 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Local cleanup (always run immediately)
|
||||
if (activeTunnelProcesses.has(tunnelName)) {
|
||||
try {
|
||||
const proc = activeTunnelProcesses.get(tunnelName);
|
||||
@@ -398,7 +391,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
const initialNextRetryIn = Math.ceil(retryInterval / 1000);
|
||||
let currentNextRetryIn = initialNextRetryIn;
|
||||
|
||||
// Set initial WAITING status with countdown
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.WAITING,
|
||||
@@ -407,7 +399,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
nextRetryIn: currentNextRetryIn
|
||||
});
|
||||
|
||||
// Update countdown every second
|
||||
const countdownInterval = setInterval(() => {
|
||||
currentNextRetryIn--;
|
||||
if (currentNextRetryIn > 0) {
|
||||
@@ -447,7 +438,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel verification function
|
||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
||||
return;
|
||||
@@ -496,10 +486,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||
|
||||
// With the new verification approach, we're testing connectivity to the endpoint machine
|
||||
// A failure might just mean the service isn't running on that port, not that the tunnel is broken
|
||||
// Only disconnect if it's a critical error (command failed, connection error, or timeout)
|
||||
|
||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -511,19 +498,13 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
} else {
|
||||
// For connection refused or other non-critical errors, assume the tunnel is working
|
||||
// The service might just not be running on the target port
|
||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
||||
cleanupVerification(true); // Treat as successful to prevent disconnect
|
||||
cleanupVerification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attemptVerification() {
|
||||
// Test the actual tunnel by trying to connect to the endpoint port
|
||||
// This verifies that the tunnel is actually working
|
||||
// With -R forwarding, the endpointPort should be listening on the endpoint machine
|
||||
// We need to check if the port is accessible from the source machine to the endpoint machine
|
||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
||||
|
||||
verificationConn.exec(testCmd, (err, stream) => {
|
||||
@@ -535,7 +516,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
@@ -548,17 +529,16 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
if (code === 0) {
|
||||
cleanupVerification(true);
|
||||
} else {
|
||||
// Check if it's a timeout or connection refused
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -571,7 +551,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
}
|
||||
|
||||
verificationConn.on('ready', () => {
|
||||
// Add a small delay to allow the tunnel to fully establish
|
||||
setTimeout(() => {
|
||||
attemptVerification();
|
||||
}, 2000);
|
||||
@@ -633,7 +612,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
// Add key type handling if specified
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
@@ -714,10 +692,9 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
});
|
||||
});
|
||||
}, 30000); // Ping every 30 seconds
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Main SSH tunnel connection function
|
||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
const tunnelName = tunnelConfig.name;
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
@@ -733,7 +710,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
retryCounters.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Only set status to CONNECTING if we're not already in WAITING state
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -835,7 +811,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
let tunnelCmd: string;
|
||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||
// For SSH key authentication, we need to create a temporary key file
|
||||
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}`;
|
||||
} else {
|
||||
@@ -975,7 +950,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
// Validate SSH key format
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -990,7 +964,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
// Add key type handling if specified
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
@@ -1006,14 +979,12 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
// Test basic network connectivity first
|
||||
const testSocket = new net.Socket();
|
||||
testSocket.setTimeout(5000);
|
||||
|
||||
testSocket.on('connect', () => {
|
||||
testSocket.destroy();
|
||||
|
||||
// Only update status to CONNECTING if we're not already in WAITING state
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -1047,7 +1018,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
||||
}
|
||||
|
||||
// Add a helper to kill the tunnel by marker
|
||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
const conn = new Client();
|
||||
@@ -1106,7 +1076,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
conn.on('ready', () => {
|
||||
// Use pkill to kill the tunnel by marker
|
||||
const killCmd = `pkill -f '${tunnelMarker}'`;
|
||||
conn.exec(killCmd, (err, stream) => {
|
||||
if (err) {
|
||||
@@ -1128,7 +1097,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
// Express API endpoints
|
||||
app.get('/ssh/tunnel/status', (req, res) => {
|
||||
res.json(getAllTunnelStatus());
|
||||
});
|
||||
@@ -1153,16 +1121,12 @@ app.post('/ssh/tunnel/connect', (req, res) => {
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
|
||||
// Reset retry state for new connection
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
// Store tunnel config
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
|
||||
// Start connection
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
|
||||
res.json({message: 'Connection request received', tunnelName});
|
||||
@@ -1193,7 +1157,6 @@ app.post('/ssh/tunnel/disconnect', (req, res) => {
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
// Clear manual disconnect flag after a delay
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
@@ -1208,7 +1171,6 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
return res.status(400).json({error: 'Tunnel name required'});
|
||||
}
|
||||
|
||||
// Cancel retry operations
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
@@ -1222,18 +1184,15 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Set status to disconnected
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true
|
||||
});
|
||||
|
||||
// Clean up any existing tunnel resources
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
// Clear manual disconnect flag after a delay
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
@@ -1241,10 +1200,8 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
res.json({message: 'Cancel request received', tunnelName});
|
||||
});
|
||||
|
||||
// Auto-start functionality
|
||||
async function initializeAutoStartTunnels(): Promise<void> {
|
||||
try {
|
||||
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
|
||||
const response = await axios.get('http://localhost:8081/ssh/db/host/internal', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -1255,12 +1212,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
const hosts: SSHHost[] = response.data || [];
|
||||
const autoStartTunnels: TunnelConfig[] = [];
|
||||
|
||||
// Process each host and extract auto-start tunnel connections
|
||||
for (const host of hosts) {
|
||||
if (host.enableTunnel && host.tunnelConnections) {
|
||||
for (const tunnelConnection of host.tunnelConnections) {
|
||||
if (tunnelConnection.autoStart) {
|
||||
// Find the endpoint host
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnelConnection.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
||||
@@ -1303,11 +1258,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||
|
||||
// Start each auto-start tunnel
|
||||
for (const tunnelConfig of autoStartTunnels) {
|
||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||
|
||||
// Start the tunnel with a delay to avoid overwhelming the system
|
||||
setTimeout(() => {
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
}, 1000);
|
||||
@@ -1322,4 +1275,4 @@ app.listen(PORT, () => {
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user