Clean up code

This commit is contained in:
LukeGus
2025-07-28 14:56:43 -05:00
parent bc4c2dc7e6
commit 30bcdd440e
37 changed files with 2428 additions and 2661 deletions

View File

@@ -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, () => {
});

View File

@@ -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, () => {
});

View File

@@ -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});

View File

@@ -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`),
});

View File

@@ -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'});
}
});

View File

@@ -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'});
}
});

View File

@@ -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);
});
});