Clean up backend files

This commit is contained in:
LukeGus
2025-09-12 00:34:53 -05:00
parent 01da97e86d
commit 4fdda82a30
17 changed files with 648 additions and 433 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [LukeGus] github: [ LukeGus ]

View File

@@ -21,7 +21,7 @@ updates:
dependency-type: "production" dependency-type: "production"
update-types: update-types:
- "minor" - "minor"
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/docker" directory: "/docker"
schedule: schedule:

View File

@@ -27,12 +27,10 @@ WORKDIR /app
COPY . . COPY . .
# Set environment variables for native module compilation
ENV npm_config_target_platform=linux ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64 ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc ENV npm_config_target_libc=glibc
# Rebuild native modules for the target platform
RUN npm rebuild better-sqlite3 --force RUN npm rebuild better-sqlite3 --force
RUN npm run build:backend RUN npm run build:backend

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, shell, ipcMain } = require('electron'); const {app, BrowserWindow, shell, ipcMain} = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -29,7 +29,7 @@ function createWindow() {
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
title: 'Termix', title: 'Termix',
icon: isDev icon: isDev
? path.join(__dirname, '..', 'public', 'icon.png') ? path.join(__dirname, '..', 'public', 'icon.png')
: path.join(process.resourcesPath, 'public', 'icon.png'), : path.join(process.resourcesPath, 'public', 'icon.png'),
webPreferences: { webPreferences: {
@@ -78,9 +78,9 @@ function createWindow() {
mainWindow = null; mainWindow = null;
}); });
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({url}) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return {action: 'deny'};
}); });
} }
@@ -92,12 +92,11 @@ ipcMain.handle('get-platform', () => {
return process.platform; return process.platform;
}); });
// Server configuration handlers
ipcMain.handle('get-server-config', () => { ipcMain.handle('get-server-config', () => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, 'server-config.json');
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, 'utf8'); const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData); return JSON.parse(configData);
@@ -113,40 +112,36 @@ ipcMain.handle('save-server-config', (event, config) => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, 'server-config.json');
// Ensure userData directory exists
if (!fs.existsSync(userDataPath)) { if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true }); fs.mkdirSync(userDataPath, {recursive: true});
} }
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true }; return {success: true};
} catch (error) { } catch (error) {
console.error('Error saving server config:', error); console.error('Error saving server config:', error);
return { success: false, error: error.message }; return {success: false, error: error.message};
} }
}); });
ipcMain.handle('test-server-connection', async (event, serverUrl) => { ipcMain.handle('test-server-connection', async (event, serverUrl) => {
try { try {
// Use Node.js built-in fetch (available in Node 18+) or fallback to https module
let fetch; let fetch;
try { try {
// Try to use built-in fetch first (Node 18+)
fetch = globalThis.fetch || require('node:fetch'); fetch = globalThis.fetch || require('node:fetch');
} catch (e) { } catch (e) {
// Fallback to https module for older Node versions
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
const { URL } = require('url'); const {URL} = require('url');
fetch = (url, options = {}) => { fetch = (url, options = {}) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const urlObj = new URL(url); const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:'; const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http; const client = isHttps ? https : http;
const req = client.request(url, { const req = client.request(url, {
method: options.method || 'GET', method: options.method || 'GET',
headers: options.headers || {}, headers: options.headers || {},
@@ -163,13 +158,13 @@ ipcMain.handle('test-server-connection', async (event, serverUrl) => {
}); });
}); });
}); });
req.on('error', reject); req.on('error', reject);
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error('Request timeout')); reject(new Error('Request timeout'));
}); });
if (options.body) { if (options.body) {
req.write(options.body); req.write(options.body);
} }
@@ -177,88 +172,92 @@ ipcMain.handle('test-server-connection', async (event, serverUrl) => {
}); });
}; };
} }
// Normalize the server URL (remove trailing slash)
const normalizedServerUrl = serverUrl.replace(/\/$/, ''); const normalizedServerUrl = serverUrl.replace(/\/$/, '');
// Test the health endpoint specifically - this is required for a valid Termix server
const healthUrl = `${normalizedServerUrl}/health`; const healthUrl = `${normalizedServerUrl}/health`;
try { try {
const response = await fetch(healthUrl, { const response = await fetch(healthUrl, {
method: 'GET', method: 'GET',
timeout: 5000 timeout: 5000
}); });
if (response.ok) { if (response.ok) {
const data = await response.text(); const data = await response.text();
// Reject if response looks like HTML (YouTube, etc.)
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) { if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) {
console.log('Health endpoint returned HTML instead of JSON - not a Termix server'); console.log('Health endpoint returned HTML instead of JSON - not a Termix server');
return { success: false, error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' }; return {
success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.'
};
} }
// A valid Termix health check should return JSON with specific structure
try { try {
const healthData = JSON.parse(data); const healthData = JSON.parse(data);
// Check if it has the expected Termix health check structure
if (healthData && ( if (healthData && (
healthData.status === 'ok' || // Termix returns {status: 'ok'} healthData.status === 'ok' ||
healthData.status === 'healthy' || healthData.status === 'healthy' ||
healthData.healthy === true || healthData.healthy === true ||
healthData.database === 'connected' healthData.database === 'connected'
)) { )) {
return { success: true, status: response.status, testedUrl: healthUrl }; return {success: true, status: response.status, testedUrl: healthUrl};
} }
} catch (parseError) { } catch (parseError) {
// If not JSON, reject - Termix health endpoint should return JSON
console.log('Health endpoint did not return valid JSON'); console.log('Health endpoint did not return valid JSON');
} }
} }
} catch (urlError) { } catch (urlError) {
console.error('Health check failed:', urlError); console.error('Health check failed:', urlError);
} }
// If health check fails, try version endpoint as fallback
try { try {
const versionUrl = `${normalizedServerUrl}/version`; const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, { const response = await fetch(versionUrl, {
method: 'GET', method: 'GET',
timeout: 5000 timeout: 5000
}); });
if (response.ok) { if (response.ok) {
const data = await response.text(); const data = await response.text();
// Reject if response looks like HTML (YouTube, etc.)
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) { if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) {
console.log('Version endpoint returned HTML instead of JSON - not a Termix server'); console.log('Version endpoint returned HTML instead of JSON - not a Termix server');
return { success: false, error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' }; return {
success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.'
};
} }
try { try {
const versionData = JSON.parse(data); const versionData = JSON.parse(data);
// Check if it looks like a Termix version response - must be JSON and contain version-specific fields
if (versionData && ( if (versionData && (
versionData.status === 'up_to_date' || versionData.status === 'up_to_date' ||
versionData.status === 'requires_update' || versionData.status === 'requires_update' ||
(versionData.localVersion && versionData.version && versionData.latest_release) (versionData.localVersion && versionData.version && versionData.latest_release)
)) { )) {
return { success: true, status: response.status, testedUrl: versionUrl, warning: 'Health endpoint not available, but server appears to be running' }; return {
success: true,
status: response.status,
testedUrl: versionUrl,
warning: 'Health endpoint not available, but server appears to be running'
};
} }
} catch (parseError) { } catch (parseError) {
// If not JSON, reject - Termix version endpoint should return JSON
console.log('Version endpoint did not return valid JSON'); console.log('Version endpoint did not return valid JSON');
} }
} }
} catch (versionError) { } catch (versionError) {
console.error('Version check failed:', versionError); console.error('Version check failed:', versionError);
} }
return { success: false, error: 'Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.' }; return {
success: false,
error: 'Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.'
};
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return {success: false, error: error.message};
} }
}); });

View File

@@ -1,38 +1,27 @@
const { contextBridge, ipcRenderer } = require('electron'); const {contextBridge, ipcRenderer} = require('electron');
console.log('Preload script loaded');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// App info
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => ipcRenderer.invoke('get-platform'), getPlatform: () => ipcRenderer.invoke('get-platform'),
// Server configuration
getServerConfig: () => ipcRenderer.invoke('get-server-config'), getServerConfig: () => ipcRenderer.invoke('get-server-config'),
saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config),
testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl),
// File dialogs
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options),
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options),
// Update events
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback),
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback),
// Utility
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true, isElectron: true,
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === 'development',
// Generic invoke method
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
}); });
// Also set the legacy IS_ELECTRON flag for backward compatibility
window.IS_ELECTRON = true; window.IS_ELECTRON = true;
console.log('electronAPI exposed to window'); console.log('electronAPI exposed to window');

View File

@@ -9,7 +9,7 @@ import fetch from 'node-fetch';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import 'dotenv/config'; import 'dotenv/config';
import { databaseLogger, apiLogger } from '../utils/logger.js'; import {databaseLogger, apiLogger} from '../utils/logger.js';
const app = express(); const app = express();
app.use(cors({ app.use(cors({
@@ -107,7 +107,7 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
cached: false cached: false
}; };
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, { operation: 'github_api', endpoint }); databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint});
throw error; throw error;
} }
} }
@@ -127,12 +127,12 @@ app.get('/version', async (req, res) => {
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
localVersion = packageJson.version; localVersion = packageJson.version;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to read version from package.json', error, { operation: 'version_check' }); databaseLogger.error('Failed to read version from package.json', error, {operation: 'version_check'});
} }
} }
if (!localVersion) { if (!localVersion) {
databaseLogger.error('No version information available', undefined, { operation: 'version_check' }); databaseLogger.error('No version information available', undefined, {operation: 'version_check'});
return res.status(404).send('Local Version Not Set'); return res.status(404).send('Local Version Not Set');
} }
@@ -148,7 +148,7 @@ app.get('/version', async (req, res) => {
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) { if (!remoteVersion) {
databaseLogger.warn('Remote version not found in GitHub response', { operation: 'version_check', rawTag }); databaseLogger.warn('Remote version not found in GitHub response', {operation: 'version_check', rawTag});
return res.status(401).send('Remote Version Not Found'); return res.status(401).send('Remote Version Not Found');
} }
@@ -170,7 +170,7 @@ app.get('/version', async (req, res) => {
res.json(response); res.json(response);
} catch (err) { } catch (err) {
databaseLogger.error('Version check failed', err, { operation: 'version_check' }); databaseLogger.error('Version check failed', err, {operation: 'version_check'});
res.status(500).send('Fetch Error'); res.status(500).send('Fetch Error');
} }
}); });
@@ -181,8 +181,6 @@ app.get('/releases/rss', async (req, res) => {
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100); const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`; const cacheKey = `releases_rss_${page}_${per_page}`;
// RSS releases requested
const releasesData = await fetchGitHubAPI( const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey cacheKey
@@ -218,11 +216,9 @@ app.get('/releases/rss', async (req, res) => {
cache_age: releasesData.cache_age cache_age: releasesData.cache_age
}; };
// RSS releases generated successfully
res.json(response); res.json(response);
} catch (error) { } catch (error) {
databaseLogger.error('Failed to generate RSS format', error, { operation: 'rss_releases' }); databaseLogger.error('Failed to generate RSS format', error, {operation: 'rss_releases'});
res.status(500).json({ res.status(500).json({
error: 'Failed to generate RSS format', error: 'Failed to generate RSS format',
details: error instanceof Error ? error.message : 'Unknown error' details: error instanceof Error ? error.message : 'Unknown error'
@@ -237,9 +233,9 @@ app.use('/alerts', alertRoutes);
app.use('/credentials', credentialsRoutes); app.use('/credentials', credentialsRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
apiLogger.error('Unhandled error in request', err, { apiLogger.error('Unhandled error in request', err, {
operation: 'error_handler', operation: 'error_handler',
method: req.method, method: req.method,
url: req.url, url: req.url,
userAgent: req.get('User-Agent') userAgent: req.get('User-Agent')
}); });
@@ -248,8 +244,8 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
const PORT = 8081; const PORT = 8081;
app.listen(PORT, () => { app.listen(PORT, () => {
databaseLogger.success(`Database API server started on port ${PORT}`, { databaseLogger.success(`Database API server started on port ${PORT}`, {
operation: 'server_start', operation: 'server_start',
port: PORT, port: PORT,
routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss']
}); });

View File

@@ -1,6 +1,5 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm'; import {sql} from 'drizzle-orm';
import { databaseLogger } from '../../utils/logger.js';
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -40,12 +39,12 @@ export const sshData = sqliteTable('ssh_data', {
tags: text('tags'), tags: text('tags'),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false), pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(), authType: text('auth_type').notNull(),
// Legacy credential fields - kept for backward compatibility
password: text('password'), password: text('password'),
key: text('key', {length: 8192}), key: text('key', {length: 8192}),
keyPassword: text('key_password'), keyPassword: text('key_password'),
keyType: text('key_type'), keyType: text('key_type'),
// New credential management
credentialId: integer('credential_id').references(() => sshCredentials.id), credentialId: integer('credential_id').references(() => sshCredentials.id),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
@@ -90,7 +89,6 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', {
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}); });
// SSH Credentials Management Tables
export const sshCredentials = sqliteTable('ssh_credentials', { export const sshCredentials = sqliteTable('ssh_credentials', {
id: integer('id').primaryKey({autoIncrement: true}), id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id), userId: text('user_id').notNull().references(() => users.id),

View File

@@ -3,8 +3,7 @@ import {db} from '../db/index.js';
import {dismissedAlerts} from '../db/schema.js'; import {dismissedAlerts} from '../db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import type {Request, Response, NextFunction} from 'express'; import {authLogger} from '../../utils/logger.js';
import { authLogger } from '../../utils/logger.js';
interface CacheEntry { interface CacheEntry {
@@ -76,7 +75,11 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
}); });
if (!response.ok) { if (!response.ok) {
authLogger.warn('GitHub API returned error status', { operation: 'alerts_fetch', status: response.status, statusText: response.statusText }); authLogger.warn('GitHub API returned error status', {
operation: 'alerts_fetch',
status: response.status,
statusText: response.statusText
});
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`); throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
} }
@@ -93,7 +96,10 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
alertCache.set(cacheKey, validAlerts); alertCache.set(cacheKey, validAlerts);
return validAlerts; return validAlerts;
} catch (error) { } catch (error) {
authLogger.error('Failed to fetch alerts from GitHub', { operation: 'alerts_fetch', error: error instanceof Error ? error.message : 'Unknown error' }); authLogger.error('Failed to fetch alerts from GitHub', {
operation: 'alerts_fetch',
error: error instanceof Error ? error.message : 'Unknown error'
});
return []; return [];
} }
} }

View File

@@ -4,7 +4,7 @@ import {sshCredentials, sshCredentialUsage, sshData} from '../db/schema.js';
import {eq, and, desc, sql} from 'drizzle-orm'; import {eq, and, desc, sql} from 'drizzle-orm';
import type {Request, Response, NextFunction} from 'express'; import type {Request, Response, NextFunction} from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { authLogger } from '../../utils/logger.js'; import {authLogger} from '../../utils/logger.js';
const router = express.Router(); const router = express.Router();
@@ -55,22 +55,37 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
} = req.body; } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
authLogger.warn('Invalid credential creation data validation failed', { operation: 'credential_create', userId, hasName: !!name, hasUsername: !!username }); authLogger.warn('Invalid credential creation data validation failed', {
operation: 'credential_create',
userId,
hasName: !!name,
hasUsername: !!username
});
return res.status(400).json({error: 'Name and username are required'}); return res.status(400).json({error: 'Name and username are required'});
} }
if (!['password', 'key'].includes(authType)) { if (!['password', 'key'].includes(authType)) {
authLogger.warn('Invalid auth type provided', { operation: 'credential_create', userId, name, authType }); authLogger.warn('Invalid auth type provided', {operation: 'credential_create', userId, name, authType});
return res.status(400).json({error: 'Auth type must be "password" or "key"'}); return res.status(400).json({error: 'Auth type must be "password" or "key"'});
} }
try { try {
if (authType === 'password' && !password) { if (authType === 'password' && !password) {
authLogger.warn('Password required for password authentication', { operation: 'credential_create', userId, name, authType }); authLogger.warn('Password required for password authentication', {
operation: 'credential_create',
userId,
name,
authType
});
return res.status(400).json({error: 'Password is required for password authentication'}); return res.status(400).json({error: 'Password is required for password authentication'});
} }
if (authType === 'key' && !key) { if (authType === 'key' && !key) {
authLogger.warn('SSH key required for key authentication', { operation: 'credential_create', userId, name, authType }); authLogger.warn('SSH key required for key authentication', {
operation: 'credential_create',
userId,
name,
authType
});
return res.status(400).json({error: 'SSH key is required for key authentication'}); return res.status(400).json({error: 'SSH key is required for key authentication'});
} }
const plainPassword = (authType === 'password' && password) ? password : null; const plainPassword = (authType === 'password' && password) ? password : null;
@@ -95,19 +110,25 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
const result = await db.insert(sshCredentials).values(credentialData).returning(); const result = await db.insert(sshCredentials).values(credentialData).returning();
const created = result[0]; const created = result[0];
authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, {
operation: 'credential_create_success', operation: 'credential_create_success',
userId, userId,
credentialId: created.id, credentialId: created.id,
name, name,
authType, authType,
username username
}); });
res.status(201).json(formatCredentialOutput(created)); res.status(201).json(formatCredentialOutput(created));
} catch (err) { } catch (err) {
authLogger.error('Failed to create credential in database', err, { operation: 'credential_create', userId, name, authType, username }); authLogger.error('Failed to create credential in database', err, {
operation: 'credential_create',
userId,
name,
authType,
username
});
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to create credential' error: err instanceof Error ? err.message : 'Failed to create credential'
}); });
@@ -285,13 +306,13 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(sshCredentials.id, parseInt(id))); .where(eq(sshCredentials.id, parseInt(id)));
const credential = updated[0]; const credential = updated[0];
authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, {
operation: 'credential_update_success', operation: 'credential_update_success',
userId, userId,
credentialId: parseInt(id), credentialId: parseInt(id),
name: credential.name, name: credential.name,
authType: credential.authType, authType: credential.authType,
username: credential.username username: credential.username
}); });
res.json(formatCredentialOutput(updated[0])); res.json(formatCredentialOutput(updated[0]));
@@ -366,13 +387,13 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
)); ));
const credential = credentialToDelete[0]; const credential = credentialToDelete[0];
authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, {
operation: 'credential_delete_success', operation: 'credential_delete_success',
userId, userId,
credentialId: parseInt(id), credentialId: parseInt(id),
name: credential.name, name: credential.name,
authType: credential.authType, authType: credential.authType,
username: credential.username username: credential.username
}); });
res.json({message: 'Credential deleted successfully'}); res.json({message: 'Credential deleted successfully'});
@@ -427,14 +448,12 @@ router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request,
eq(sshData.userId, userId) eq(sshData.userId, userId)
)); ));
// Record credential usage
await db.insert(sshCredentialUsage).values({ await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId), credentialId: parseInt(credentialId),
hostId: parseInt(hostId), hostId: parseInt(hostId),
userId, userId,
}); });
// Update credential usage stats
await db await db
.update(sshCredentials) .update(sshCredentials)
.set({ .set({
@@ -529,28 +548,28 @@ function formatSSHHostOutput(host: any): any {
// PUT /credentials/folders/rename // PUT /credentials/folders/rename
router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { oldName, newName } = req.body; const {oldName, newName} = req.body;
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
return res.status(400).json({ error: 'Both oldName and newName are required' }); return res.status(400).json({error: 'Both oldName and newName are required'});
} }
if (oldName === newName) { if (oldName === newName) {
return res.status(400).json({ error: 'Old name and new name cannot be the same' }); return res.status(400).json({error: 'Old name and new name cannot be the same'});
} }
try { try {
await db.update(sshCredentials) await db.update(sshCredentials)
.set({ folder: newName }) .set({folder: newName})
.where(and( .where(and(
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName) eq(sshCredentials.folder, oldName)
)); ));
res.json({ success: true, message: 'Folder renamed successfully' }); res.json({success: true, message: 'Folder renamed successfully'});
} catch (error) { } catch (error) {
authLogger.error('Error renaming credential folder:', error); authLogger.error('Error renaming credential folder:', error);
res.status(500).json({ error: 'Failed to rename folder' }); res.status(500).json({error: 'Failed to rename folder'});
} }
}); });

View File

@@ -5,7 +5,7 @@ import {eq, and, desc} from 'drizzle-orm';
import type {Request, Response, NextFunction} from 'express'; import type {Request, Response, NextFunction} from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import multer from 'multer'; import multer from 'multer';
import { sshLogger } from '../../utils/logger.js'; import {sshLogger} from '../../utils/logger.js';
const router = express.Router(); const router = express.Router();
@@ -83,11 +83,15 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
} catch (err) { } catch (err) {
sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_create', userId, error: err }); sshLogger.warn('Invalid JSON data in multipart request', {
operation: 'host_create',
userId,
error: err
});
return res.status(400).json({error: 'Invalid JSON data'}); return res.status(400).json({error: 'Invalid JSON data'});
} }
} else { } else {
sshLogger.warn('Missing data field in multipart request', { operation: 'host_create', userId }); sshLogger.warn('Missing data field in multipart request', {operation: 'host_create', userId});
return res.status(400).json({error: 'Missing data field'}); return res.status(400).json({error: 'Missing data field'});
} }
@@ -120,12 +124,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
tunnelConnections tunnelConnections
} = hostData; } = hostData;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
sshLogger.warn('Invalid SSH data input validation failed', { sshLogger.warn('Invalid SSH data input validation failed', {
operation: 'host_create', operation: 'host_create',
userId, userId,
hasIp: !!ip, hasIp: !!ip,
port, port,
isValidPort: isValidPort(port) isValidPort: isValidPort(port)
}); });
return res.status(400).json({error: 'Invalid SSH data'}); return res.status(400).json({error: 'Invalid SSH data'});
} }
@@ -163,12 +167,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
try { try {
const result = await db.insert(sshData).values(sshDataObj).returning(); const result = await db.insert(sshData).values(sshDataObj).returning();
if (result.length === 0) { if (result.length === 0) {
sshLogger.warn('No host returned after creation', { operation: 'host_create', userId, name, ip, port }); sshLogger.warn('No host returned after creation', {operation: 'host_create', userId, name, ip, port});
return res.status(500).json({error: 'Failed to create host'}); return res.status(500).json({error: 'Failed to create host'});
} }
const createdHost = result[0]; const createdHost = result[0];
const baseHost = { const baseHost = {
...createdHost, ...createdHost,
@@ -179,22 +183,29 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections) : [], tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections) : [],
enableFileManager: !!createdHost.enableFileManager, enableFileManager: !!createdHost.enableFileManager,
}; };
const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; const resolvedHost = await resolveHostCredentials(baseHost) || baseHost;
sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, { sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, {
operation: 'host_create_success', operation: 'host_create_success',
userId, userId,
hostId: createdHost.id, hostId: createdHost.id,
name, name,
ip, ip,
port, port,
authType: effectiveAuthType authType: effectiveAuthType
}); });
res.json(resolvedHost); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error('Failed to save SSH host to database', err, { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType }); sshLogger.error('Failed to save SSH host to database', err, {
operation: 'host_create',
userId,
name,
ip,
port,
authType: effectiveAuthType
});
res.status(500).json({error: 'Failed to save SSH data'}); res.status(500).json({error: 'Failed to save SSH data'});
} }
}); });
@@ -211,11 +222,20 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
} catch (err) { } catch (err) {
sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, error: err }); sshLogger.warn('Invalid JSON data in multipart request', {
operation: 'host_update',
hostId: parseInt(hostId),
userId,
error: err
});
return res.status(400).json({error: 'Invalid JSON data'}); return res.status(400).json({error: 'Invalid JSON data'});
} }
} else { } else {
sshLogger.warn('Missing data field in multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId }); sshLogger.warn('Missing data field in multipart request', {
operation: 'host_update',
hostId: parseInt(hostId),
userId
});
return res.status(400).json({error: 'Missing data field'}); return res.status(400).json({error: 'Missing data field'});
} }
@@ -248,13 +268,13 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
tunnelConnections tunnelConnections
} = hostData; } = hostData;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !hostId) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !hostId) {
sshLogger.warn('Invalid SSH data input validation failed for update', { sshLogger.warn('Invalid SSH data input validation failed for update', {
operation: 'host_update', operation: 'host_update',
hostId: parseInt(hostId), hostId: parseInt(hostId),
userId, userId,
hasIp: !!ip, hasIp: !!ip,
port, port,
isValidPort: isValidPort(port) isValidPort: isValidPort(port)
}); });
return res.status(400).json({error: 'Invalid SSH data'}); return res.status(400).json({error: 'Invalid SSH data'});
} }
@@ -301,17 +321,21 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
await db.update(sshData) await db.update(sshData)
.set(sshDataObj) .set(sshDataObj)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
const updatedHosts = await db const updatedHosts = await db
.select() .select()
.from(sshData) .from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
if (updatedHosts.length === 0) { if (updatedHosts.length === 0) {
sshLogger.warn('Updated host not found after update', { operation: 'host_update', hostId: parseInt(hostId), userId }); sshLogger.warn('Updated host not found after update', {
operation: 'host_update',
hostId: parseInt(hostId),
userId
});
return res.status(404).json({error: 'Host not found after update'}); return res.status(404).json({error: 'Host not found after update'});
} }
const updatedHost = updatedHosts[0]; const updatedHost = updatedHosts[0];
const baseHost = { const baseHost = {
...updatedHost, ...updatedHost,
@@ -322,22 +346,30 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections) : [], tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections) : [],
enableFileManager: !!updatedHost.enableFileManager, enableFileManager: !!updatedHost.enableFileManager,
}; };
const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; const resolvedHost = await resolveHostCredentials(baseHost) || baseHost;
sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, { sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, {
operation: 'host_update_success', operation: 'host_update_success',
userId, userId,
hostId: parseInt(hostId), hostId: parseInt(hostId),
name, name,
ip, ip,
port, port,
authType: effectiveAuthType authType: effectiveAuthType
}); });
res.json(resolvedHost); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error('Failed to update SSH host in database', err, { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType }); sshLogger.error('Failed to update SSH host in database', err, {
operation: 'host_update',
hostId: parseInt(hostId),
userId,
name,
ip,
port,
authType: effectiveAuthType
});
res.status(500).json({error: 'Failed to update SSH data'}); res.status(500).json({error: 'Failed to update SSH data'});
} }
}); });
@@ -347,7 +379,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
sshLogger.warn('Invalid userId for SSH data fetch', { operation: 'host_fetch', userId }); sshLogger.warn('Invalid userId for SSH data fetch', {operation: 'host_fetch', userId});
return res.status(400).json({error: 'Invalid userId'}); return res.status(400).json({error: 'Invalid userId'});
} }
try { try {
@@ -355,7 +387,7 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
.select() .select()
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .where(eq(sshData.userId, userId));
const result = await Promise.all(data.map(async (row: any) => { const result = await Promise.all(data.map(async (row: any) => {
const baseHost = { const baseHost = {
...row, ...row,
@@ -366,13 +398,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableFileManager: !!row.enableFileManager, enableFileManager: !!row.enableFileManager,
}; };
return await resolveHostCredentials(baseHost) || baseHost; return await resolveHostCredentials(baseHost) || baseHost;
})); }));
res.json(result); res.json(result);
} catch (err) { } catch (err) {
sshLogger.error('Failed to fetch SSH hosts from database', err, { operation: 'host_fetch', userId }); sshLogger.error('Failed to fetch SSH hosts from database', err, {operation: 'host_fetch', userId});
res.status(500).json({error: 'Failed to fetch SSH data'}); res.status(500).json({error: 'Failed to fetch SSH data'});
} }
}); });
@@ -382,9 +414,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const hostId = req.params.id; const hostId = req.params.id;
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !hostId) { if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn('Invalid userId or hostId for SSH host fetch by ID', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); sshLogger.warn('Invalid userId or hostId for SSH host fetch by ID', {
operation: 'host_fetch_by_id',
hostId: parseInt(hostId),
userId
});
return res.status(400).json({error: 'Invalid userId or hostId'}); return res.status(400).json({error: 'Invalid userId or hostId'});
} }
try { try {
@@ -394,7 +430,7 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
if (data.length === 0) { if (data.length === 0) {
sshLogger.warn('SSH host not found', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); sshLogger.warn('SSH host not found', {operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId});
return res.status(404).json({error: 'SSH host not found'}); return res.status(404).json({error: 'SSH host not found'});
} }
@@ -408,10 +444,14 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableFileManager: !!host.enableFileManager, enableFileManager: !!host.enableFileManager,
}; };
res.json(await resolveHostCredentials(result) || result); res.json(await resolveHostCredentials(result) || result);
} catch (err) { } catch (err) {
sshLogger.error('Failed to fetch SSH host by ID from database', err, { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); sshLogger.error('Failed to fetch SSH host by ID from database', err, {
operation: 'host_fetch_by_id',
hostId: parseInt(hostId),
userId
});
res.status(500).json({error: 'Failed to fetch SSH host'}); res.status(500).json({error: 'Failed to fetch SSH host'});
} }
}); });
@@ -421,9 +461,13 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const hostId = req.params.id; const hostId = req.params.id;
if (!isNonEmptyString(userId) || !hostId) { if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn('Invalid userId or hostId for SSH host delete', { operation: 'host_delete', hostId: parseInt(hostId), userId }); sshLogger.warn('Invalid userId or hostId for SSH host delete', {
operation: 'host_delete',
hostId: parseInt(hostId),
userId
});
return res.status(400).json({error: 'Invalid userId or id'}); return res.status(400).json({error: 'Invalid userId or id'});
} }
try { try {
@@ -431,28 +475,36 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
.select() .select()
.from(sshData) .from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
if (hostToDelete.length === 0) { if (hostToDelete.length === 0) {
sshLogger.warn('SSH host not found for deletion', { operation: 'host_delete', hostId: parseInt(hostId), userId }); sshLogger.warn('SSH host not found for deletion', {
operation: 'host_delete',
hostId: parseInt(hostId),
userId
});
return res.status(404).json({error: 'SSH host not found'}); return res.status(404).json({error: 'SSH host not found'});
} }
const result = await db.delete(sshData) const result = await db.delete(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
const host = hostToDelete[0]; const host = hostToDelete[0];
sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, { sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, {
operation: 'host_delete_success', operation: 'host_delete_success',
userId, userId,
hostId: parseInt(hostId), hostId: parseInt(hostId),
name: host.name, name: host.name,
ip: host.ip, ip: host.ip,
port: host.port port: host.port
}); });
res.json({message: 'SSH host deleted'}); res.json({message: 'SSH host deleted'});
} catch (err) { } catch (err) {
sshLogger.error('Failed to delete SSH host from database', err, { operation: 'host_delete', hostId: parseInt(hostId), userId }); sshLogger.error('Failed to delete SSH host from database', err, {
operation: 'host_delete',
hostId: parseInt(hostId),
userId
});
res.status(500).json({error: 'Failed to delete SSH host'}); res.status(500).json({error: 'Failed to delete SSH host'});
} }
}); });
@@ -492,7 +544,7 @@ router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Re
// POST /ssh/file_manager/recent // POST /ssh/file_manager/recent
router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for recent file addition'); sshLogger.warn('Invalid data for recent file addition');
@@ -500,7 +552,6 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R
} }
try { try {
// Check if file already exists
const existing = await db const existing = await db
.select() .select()
.from(fileManagerRecent) .from(fileManagerRecent)
@@ -511,13 +562,11 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R
)); ));
if (existing.length > 0) { if (existing.length > 0) {
// Update last opened time
await db await db
.update(fileManagerRecent) .update(fileManagerRecent)
.set({ lastOpened: new Date().toISOString() }) .set({lastOpened: new Date().toISOString()})
.where(eq(fileManagerRecent.id, existing[0].id)); .where(eq(fileManagerRecent.id, existing[0].id));
} else { } else {
// Insert new record
await db.insert(fileManagerRecent).values({ await db.insert(fileManagerRecent).values({
userId, userId,
hostId, hostId,
@@ -538,7 +587,7 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R
// DELETE /ssh/file_manager/recent // DELETE /ssh/file_manager/recent
router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for recent file deletion'); sshLogger.warn('Invalid data for recent file deletion');
@@ -595,7 +644,7 @@ router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Re
// POST /ssh/file_manager/pinned // POST /ssh/file_manager/pinned
router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for pinned file addition'); sshLogger.warn('Invalid data for pinned file addition');
@@ -603,7 +652,6 @@ router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: R
} }
try { try {
// Check if file already exists
const existing = await db const existing = await db
.select() .select()
.from(fileManagerPinned) .from(fileManagerPinned)
@@ -636,7 +684,7 @@ router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: R
// DELETE /ssh/file_manager/pinned // DELETE /ssh/file_manager/pinned
router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for pinned file deletion'); sshLogger.warn('Invalid data for pinned file deletion');
@@ -693,7 +741,7 @@ router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res:
// POST /ssh/file_manager/shortcuts // POST /ssh/file_manager/shortcuts
router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for shortcut addition'); sshLogger.warn('Invalid data for shortcut addition');
@@ -701,7 +749,6 @@ router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res
} }
try { try {
// Check if shortcut already exists
const existing = await db const existing = await db
.select() .select()
.from(fileManagerShortcuts) .from(fileManagerShortcuts)
@@ -734,7 +781,7 @@ router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res
// DELETE /ssh/file_manager/shortcuts // DELETE /ssh/file_manager/shortcuts
router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hostId, path, name } = req.body; const {hostId, path, name} = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) { if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn('Invalid data for shortcut deletion'); sshLogger.warn('Invalid data for shortcut deletion');
@@ -792,7 +839,7 @@ async function resolveHostCredentials(host: any): Promise<any> {
// PUT /ssh/db/folders/rename // PUT /ssh/db/folders/rename
router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { oldName, newName } = req.body; const {oldName, newName} = req.body;
if (!isNonEmptyString(userId) || !oldName || !newName) { if (!isNonEmptyString(userId) || !oldName || !newName) {
sshLogger.warn('Invalid data for folder rename'); sshLogger.warn('Invalid data for folder rename');
@@ -804,10 +851,9 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons
} }
try { try {
// Update all hosts with the old folder name
const updatedHosts = await db const updatedHosts = await db
.update(sshData) .update(sshData)
.set({ .set({
folder: newName, folder: newName,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}) })
@@ -817,10 +863,9 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons
)) ))
.returning(); .returning();
// Update all credentials with the old folder name
const updatedCredentials = await db const updatedCredentials = await db
.update(sshCredentials) .update(sshCredentials)
.set({ .set({
folder: newName, folder: newName,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}) })
@@ -836,7 +881,7 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons
updatedCredentials: updatedCredentials.length updatedCredentials: updatedCredentials.length
}); });
} catch (err) { } catch (err) {
sshLogger.error('Failed to rename folder', err, { operation: 'folder_rename', userId, oldName, newName }); sshLogger.error('Failed to rename folder', err, {operation: 'folder_rename', userId, oldName, newName});
res.status(500).json({error: 'Failed to rename folder'}); res.status(500).json({error: 'Failed to rename folder'});
} }
}); });
@@ -845,14 +890,14 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons
// POST /ssh/bulk-import // POST /ssh/bulk-import
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hosts } = req.body; const {hosts} = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) { if (!Array.isArray(hosts) || hosts.length === 0) {
return res.status(400).json({ error: 'Hosts array is required and must not be empty' }); return res.status(400).json({error: 'Hosts array is required and must not be empty'});
} }
if (hosts.length > 100) { if (hosts.length > 100) {
return res.status(400).json({ error: 'Maximum 100 hosts allowed per import' }); return res.status(400).json({error: 'Maximum 100 hosts allowed per import'});
} }
const results = { const results = {
@@ -863,23 +908,20 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response)
for (let i = 0; i < hosts.length; i++) { for (let i = 0; i < hosts.length; i++) {
const hostData = hosts[i]; const hostData = hosts[i];
try { try {
// Validate required fields
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
results.failed++; results.failed++;
results.errors.push(`Host ${i + 1}: Missing required fields (ip, port, username)`); results.errors.push(`Host ${i + 1}: Missing required fields (ip, port, username)`);
continue; continue;
} }
// Validate authType
if (!['password', 'key', 'credential'].includes(hostData.authType)) { if (!['password', 'key', 'credential'].includes(hostData.authType)) {
results.failed++; results.failed++;
results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`); results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`);
continue; continue;
} }
// Validate authentication data based on authType
if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) {
results.failed++; results.failed++;
results.errors.push(`Host ${i + 1}: Password required for password authentication`); results.errors.push(`Host ${i + 1}: Password required for password authentication`);
@@ -898,7 +940,6 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response)
continue; continue;
} }
// Prepare host data for insertion
const sshDataObj: any = { const sshDataObj: any = {
userId: userId, userId: userId,
name: hostData.name || `${hostData.username}@${hostData.ip}`, name: hostData.name || `${hostData.username}@${hostData.ip}`,

View File

@@ -1,6 +1,13 @@
import express from 'express'; import express from 'express';
import {db} from '../db/index.js'; import {db} from '../db/index.js';
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js'; import {
users,
sshData,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts
} from '../db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import {nanoid} from 'nanoid'; import {nanoid} from 'nanoid';
@@ -8,7 +15,7 @@ import jwt from 'jsonwebtoken';
import speakeasy from 'speakeasy'; import speakeasy from 'speakeasy';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import type {Request, Response, NextFunction} from 'express'; import type {Request, Response, NextFunction} from 'express';
import { authLogger, apiLogger } from '../../utils/logger.js'; import {authLogger, apiLogger} from '../../utils/logger.js';
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> { async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
try { try {
@@ -111,7 +118,11 @@ interface JWTPayload {
function authenticateJWT(req: Request, res: Response, next: NextFunction) { function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
authLogger.warn('Missing or invalid Authorization header', { operation: 'auth', method: req.method, url: req.url }); authLogger.warn('Missing or invalid Authorization header', {
operation: 'auth',
method: req.method,
url: req.url
});
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 token = authHeader.split(' ')[1];
@@ -119,10 +130,9 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
try { try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload; const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId; (req as any).userId = payload.userId;
// JWT authentication successful
next(); next();
} catch (err) { } catch (err) {
authLogger.warn('Invalid or expired token', { operation: 'auth', method: req.method, url: req.url, error: err }); authLogger.warn('Invalid or expired token', {operation: 'auth', method: req.method, url: req.url, error: err});
return res.status(401).json({error: 'Invalid or expired token'}); return res.status(401).json({error: 'Invalid or expired token'});
} }
} }
@@ -136,13 +146,17 @@ router.post('/create', async (req, res) => {
return res.status(403).json({error: 'Registration is currently disabled'}); return res.status(403).json({error: 'Registration is currently disabled'});
} }
} catch (e) { } catch (e) {
authLogger.warn('Failed to check registration status', { operation: 'registration_check', error: e }); authLogger.warn('Failed to check registration status', {operation: 'registration_check', error: e});
} }
const {username, password} = req.body; const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) { if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
authLogger.warn('Invalid user creation attempt - missing username or password', { operation: 'user_create', hasUsername: !!username, hasPassword: !!password }); authLogger.warn('Invalid user creation attempt - missing username or password', {
operation: 'user_create',
hasUsername: !!username,
hasPassword: !!password
});
return res.status(400).json({error: 'Username and password are required'}); return res.status(400).json({error: 'Username and password are required'});
} }
@@ -152,7 +166,7 @@ router.post('/create', async (req, res) => {
.from(users) .from(users)
.where(eq(users.username, username)); .where(eq(users.username, username));
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
authLogger.warn(`Attempt to create duplicate username: ${username}`, { operation: 'user_create', username }); authLogger.warn(`Attempt to create duplicate username: ${username}`, {operation: 'user_create', username});
return res.status(409).json({error: 'Username already exists'}); return res.status(409).json({error: 'Username already exists'});
} }
@@ -162,7 +176,11 @@ router.post('/create', async (req, res) => {
isFirstUser = ((countResult as any)?.count || 0) === 0; isFirstUser = ((countResult as any)?.count || 0) === 0;
} catch (e) { } catch (e) {
isFirstUser = true; isFirstUser = true;
authLogger.warn('Failed to check user count, assuming first user', { operation: 'user_create', username, error: e }); authLogger.warn('Failed to check user count, assuming first user', {
operation: 'user_create',
username,
error: e
});
} }
const saltRounds = parseInt(process.env.SALT || '10', 10); const saltRounds = parseInt(process.env.SALT || '10', 10);
@@ -187,8 +205,17 @@ router.post('/create', async (req, res) => {
totp_backup_codes: null, totp_backup_codes: null,
}); });
authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { operation: 'user_create', username, isAdmin: isFirstUser, userId: id }); authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, {
res.json({message: 'User created', is_admin: isFirstUser, toast: {type: 'success', message: `User created: ${username}`}}); operation: 'user_create',
username,
isAdmin: isFirstUser,
userId: id
});
res.json({
message: 'User created',
is_admin: isFirstUser,
toast: {type: 'success', message: `User created: ${username}`}
});
} catch (err) { } catch (err) {
authLogger.error('Failed to create user', err); authLogger.error('Failed to create user', err);
res.status(500).json({error: 'Failed to create user'}); res.status(500).json({error: 'Failed to create user'});
@@ -240,10 +267,9 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
if (isDisableRequest) { if (isDisableRequest) {
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId }); authLogger.info('OIDC configuration disabled', {operation: 'oidc_disable', userId});
res.json({message: 'OIDC configuration disabled'}); res.json({message: 'OIDC configuration disabled'});
} else { } else {
// Enable OIDC by storing the configuration
const config = { const config = {
client_id, client_id,
client_secret, client_secret,
@@ -257,7 +283,11 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
}; };
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config)); db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config));
authLogger.info('OIDC configuration updated', { operation: 'oidc_update', userId, hasUserinfoUrl: !!userinfo_url }); authLogger.info('OIDC configuration updated', {
operation: 'oidc_update',
userId,
hasUserinfoUrl: !!userinfo_url
});
res.json({message: 'OIDC configuration updated'}); res.json({message: 'OIDC configuration updated'});
} }
} catch (err) { } catch (err) {
@@ -277,7 +307,7 @@ router.delete('/oidc-config', authenticateJWT, async (req, res) => {
} }
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
authLogger.success('OIDC configuration disabled', { operation: 'oidc_disable', userId }); authLogger.success('OIDC configuration disabled', {operation: 'oidc_disable', userId});
res.json({message: 'OIDC configuration disabled'}); res.json({message: 'OIDC configuration disabled'});
} catch (err) { } catch (err) {
authLogger.error('Failed to disable OIDC config', err); authLogger.error('Failed to disable OIDC config', err);
@@ -533,8 +563,6 @@ router.get('/oidc/callback', async (req, res) => {
.select() .select()
.from(users) .from(users)
.where(eq(users.id, id)); .where(eq(users.id, id));
// OIDC user created - toast notification handled by frontend
} else { } else {
await db.update(users) await db.update(users)
.set({username: name}) .set({username: name})
@@ -544,8 +572,6 @@ router.get('/oidc/callback', async (req, res) => {
.select() .select()
.from(users) .from(users)
.where(eq(users.id, user[0].id)); .where(eq(users.id, user[0].id));
// OIDC user logged in - toast notification handled by frontend
} }
const userRecord = user[0]; const userRecord = user[0];
@@ -589,7 +615,11 @@ router.post('/login', async (req, res) => {
const {username, password} = req.body; const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) { if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
authLogger.warn('Invalid traditional login attempt', { operation: 'user_login', hasUsername: !!username, hasPassword: !!password }); authLogger.warn('Invalid traditional login attempt', {
operation: 'user_login',
hasUsername: !!username,
hasPassword: !!password
});
return res.status(400).json({error: 'Invalid username or password'}); return res.status(400).json({error: 'Invalid username or password'});
} }
@@ -600,20 +630,28 @@ router.post('/login', async (req, res) => {
.where(eq(users.username, username)); .where(eq(users.username, username));
if (!user || user.length === 0) { if (!user || user.length === 0) {
authLogger.warn(`User not found: ${username}`, { operation: 'user_login', username }); authLogger.warn(`User not found: ${username}`, {operation: 'user_login', username});
return res.status(404).json({error: 'User not found'}); return res.status(404).json({error: 'User not found'});
} }
const userRecord = user[0]; const userRecord = user[0];
if (userRecord.is_oidc) { if (userRecord.is_oidc) {
authLogger.warn('OIDC user attempted traditional login', { operation: 'user_login', username, userId: userRecord.id }); authLogger.warn('OIDC user attempted traditional login', {
operation: 'user_login',
username,
userId: userRecord.id
});
return res.status(403).json({error: 'This user uses external authentication'}); return res.status(403).json({error: 'This user uses external authentication'});
} }
const isMatch = await bcrypt.compare(password, userRecord.password_hash); const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) { if (!isMatch) {
authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id }); authLogger.warn(`Incorrect password for user: ${username}`, {
operation: 'user_login',
username,
userId: userRecord.id
});
return res.status(401).json({error: 'Incorrect password'}); return res.status(401).json({error: 'Incorrect password'});
} }
const jwtSecret = process.env.JWT_SECRET || 'secret'; const jwtSecret = process.env.JWT_SECRET || 'secret';
@@ -621,8 +659,6 @@ router.post('/login', async (req, res) => {
expiresIn: '50d', expiresIn: '50d',
}); });
// Traditional user logged in - toast notification handled by frontend
if (userRecord.totp_enabled) { if (userRecord.totp_enabled) {
const tempToken = jwt.sign( const tempToken = jwt.sign(
{userId: userRecord.id, pending_totp: true}, {userId: userRecord.id, pending_totp: true},
@@ -886,7 +922,6 @@ router.post('/complete-reset', async (req, res) => {
const expiresAt = new Date(tempTokenData.expiresAt); const expiresAt = new Date(tempTokenData.expiresAt);
if (now > expiresAt) { if (now > expiresAt) {
// Clean up expired token
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`);
return res.status(400).json({error: 'Temporary token has expired'}); return res.status(400).json({error: 'Temporary token has expired'});
} }
@@ -968,7 +1003,6 @@ router.post('/make-admin', authenticateJWT, async (req, res) => {
.where(eq(users.username, username)); .where(eq(users.username, username));
authLogger.success(`User ${username} made admin by ${adminUser[0].username}`); authLogger.success(`User ${username} made admin by ${adminUser[0].username}`);
// User made admin - toast notification handled by frontend
res.json({message: `User ${username} is now an admin`}); res.json({message: `User ${username} is now an admin`});
} catch (err) { } catch (err) {
@@ -1011,7 +1045,6 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => {
.where(eq(users.username, username)); .where(eq(users.username, username));
authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`);
// Admin status removed - toast notification handled by frontend
res.json({message: `Admin status removed from ${username}`}); res.json({message: `Admin status removed from ${username}`});
} catch (err) { } catch (err) {
@@ -1073,8 +1106,6 @@ router.post('/totp/verify-login', async (req, res) => {
expiresIn: '50d', expiresIn: '50d',
}); });
// TOTP login completed - toast notification handled by frontend
return res.json({ return res.json({
token, token,
is_admin: !!userRecord.is_admin, is_admin: !!userRecord.is_admin,
@@ -1174,7 +1205,6 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
// 2FA enabled - toast notification handled by frontend
res.json({ res.json({
message: 'TOTP enabled successfully', message: 'TOTP enabled successfully',
backup_codes: backupCodes backup_codes: backupCodes
@@ -1236,7 +1266,6 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
// 2FA disabled - toast notification handled by frontend
res.json({message: 'TOTP disabled successfully'}); res.json({message: 'TOTP disabled successfully'});
} catch (err) { } catch (err) {
@@ -1353,7 +1382,6 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
await db.delete(users).where(eq(users.id, targetUserId)); await db.delete(users).where(eq(users.id, targetUserId));
authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`); authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
// User deleted - toast notification handled by frontend
res.json({message: `User ${username} deleted successfully`}); res.json({message: `User ${username} deleted successfully`});
} catch (err) { } catch (err) {

View File

@@ -2,9 +2,9 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import {Client as SSHClient} from 'ssh2'; import {Client as SSHClient} from 'ssh2';
import {db} from '../database/db/index.js'; import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js'; import {sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import { fileLogger } from '../utils/logger.js'; import {fileLogger} from '../utils/logger.js';
const app = express(); const app = express();
@@ -47,12 +47,28 @@ function scheduleSessionCleanup(sessionId: string) {
} }
app.post('/ssh/file_manager/ssh/connect', async (req, res) => { app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body; const {
sessionId,
// Connection request received hostId,
ip,
port,
username,
password,
sshKey,
keyPassword,
authType,
credentialId,
userId
} = req.body;
if (!sessionId || !ip || !username || !port) { if (!sessionId || !ip || !username || !port) {
fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port }); fileLogger.warn('Missing SSH connection parameters for file manager', {
operation: 'file_connect',
sessionId,
hasIp: !!ip,
hasUsername: !!username,
hasPort: !!port
});
return res.status(400).json({error: 'Missing SSH connection parameters'}); return res.status(400).json({error: 'Missing SSH connection parameters'});
} }
@@ -81,13 +97,31 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
authType: credential.authType authType: credential.authType
}; };
} else { } else {
fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId }); fileLogger.warn('No credentials found in database for file manager', {
operation: 'file_connect',
sessionId,
hostId,
credentialId,
userId
});
} }
} catch (error) { } catch (error) {
fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); fileLogger.warn('Failed to resolve credentials from database for file manager', {
operation: 'file_connect',
sessionId,
hostId,
credentialId,
error: error instanceof Error ? error.message : 'Unknown error'
});
} }
} else if (credentialId && hostId) { } else if (credentialId && hostId) {
fileLogger.warn('Missing userId for credential resolution in file manager', { operation: 'file_connect', sessionId, hostId, credentialId, hasUserId: !!userId }); fileLogger.warn('Missing userId for credential resolution in file manager', {
operation: 'file_connect',
sessionId,
hostId,
credentialId,
hasUserId: !!userId
});
} }
const config: any = { const config: any = {
@@ -146,13 +180,22 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword;
} catch (keyError) { } catch (keyError) {
fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message }); fileLogger.error('SSH key format error for file manager', {
operation: 'file_connect',
sessionId,
hostId,
error: keyError.message
});
return res.status(400).json({error: 'Invalid SSH key format'}); return res.status(400).json({error: 'Invalid SSH key format'});
} }
} else if (resolvedCredentials.password && resolvedCredentials.password.trim()) { } else if (resolvedCredentials.password && resolvedCredentials.password.trim()) {
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
} else { } else {
fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId }); fileLogger.warn('No authentication method provided for file manager', {
operation: 'file_connect',
sessionId,
hostId
});
return res.status(400).json({error: 'Either password or SSH key must be provided'}); return res.status(400).json({error: 'Either password or SSH key must be provided'});
} }
@@ -168,7 +211,15 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
client.on('error', (err) => { client.on('error', (err) => {
if (responseSent) return; if (responseSent) return;
responseSent = true; responseSent = true;
fileLogger.error('SSH connection failed for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, error: err.message }); fileLogger.error('SSH connection failed for file manager', {
operation: 'file_connect',
sessionId,
hostId,
ip,
port,
username,
error: err.message
});
res.status(500).json({status: 'error', message: err.message}); res.status(500).json({status: 'error', message: err.message});
}); });
@@ -371,7 +422,11 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); res.json({
message: 'File written successfully',
path: filePath,
toast: {type: 'success', message: `File written: ${filePath}`}
});
} }
}); });
@@ -379,7 +434,11 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); res.json({
message: 'File written successfully',
path: filePath,
toast: {type: 'success', message: `File written: ${filePath}`}
});
} }
}); });
@@ -411,7 +470,10 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => {
fileLogger.error('Fallback write command failed:', err); fileLogger.error('Fallback write command failed:', err);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: `Write failed: ${err.message}`, toast: {type: 'error', message: `Write failed: ${err.message}`}}); return res.status(500).json({
error: `Write failed: ${err.message}`,
toast: {type: 'error', message: `Write failed: ${err.message}`}
});
} }
return; return;
} }
@@ -430,14 +492,21 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); res.json({
} message: 'File written successfully',
path: filePath,
toast: {type: 'success', message: `File written: ${filePath}`}
});
}
} else { } else {
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`); fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: `Write failed: ${errorData}`, toast: {type: 'error', message: `Write failed: ${errorData}`}}); res.status(500).json({
error: `Write failed: ${errorData}`,
toast: {type: 'error', message: `Write failed: ${errorData}`}
});
} }
} }
}); });
@@ -527,7 +596,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); res.json({
message: 'File uploaded successfully',
path: fullPath,
toast: {type: 'success', message: `File uploaded: ${fullPath}`}
});
} }
}); });
@@ -535,7 +608,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); res.json({
message: 'File uploaded successfully',
path: fullPath,
toast: {type: 'success', message: `File uploaded: ${fullPath}`}
});
} }
}); });
@@ -598,13 +675,20 @@ app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); res.json({
message: 'File uploaded successfully',
path: fullPath,
toast: {type: 'success', message: `File uploaded: ${fullPath}`}
});
} }
} else { } else {
fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`); fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: `Upload failed: ${errorData}`, toast: {type: 'error', message: `Upload failed: ${errorData}`}}); res.status(500).json({
} error: `Upload failed: ${errorData}`,
toast: {type: 'error', message: `Upload failed: ${errorData}`}
});
}
} }
}); });
@@ -655,13 +739,20 @@ app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); res.json({
message: 'File uploaded successfully',
path: fullPath,
toast: {type: 'success', message: `File uploaded: ${fullPath}`}
});
} }
} else { } else {
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: `Chunked upload failed: ${errorData}`, toast: {type: 'error', message: `Chunked upload failed: ${errorData}`}}); res.status(500).json({
} error: `Chunked upload failed: ${errorData}`,
toast: {type: 'error', message: `Chunked upload failed: ${errorData}`}
});
}
} }
}); });
@@ -740,7 +831,11 @@ app.post('/ssh/file_manager/ssh/createFile', async (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File created successfully', path: fullPath, toast: {type: 'success', message: `File created: ${fullPath}`}}); res.json({
message: 'File created successfully',
path: fullPath,
toast: {type: 'success', message: `File created: ${fullPath}`}
});
} }
return; return;
} }
@@ -748,13 +843,20 @@ app.post('/ssh/file_manager/ssh/createFile', async (req, res) => {
if (code !== 0) { if (code !== 0) {
fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `File creation failed: ${errorData}`}}); return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {type: 'error', message: `File creation failed: ${errorData}`}
});
} }
return; return;
} }
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File created successfully', path: fullPath, toast: {type: 'success', message: `File created: ${fullPath}`}}); res.json({
message: 'File created successfully',
path: fullPath,
toast: {type: 'success', message: `File created: ${fullPath}`}
});
} }
}); });
@@ -824,7 +926,11 @@ app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Folder created successfully', path: fullPath, toast: {type: 'success', message: `Folder created: ${fullPath}`}}); res.json({
message: 'Folder created successfully',
path: fullPath,
toast: {type: 'success', message: `Folder created: ${fullPath}`}
});
} }
return; return;
} }
@@ -832,13 +938,20 @@ app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => {
if (code !== 0) { if (code !== 0) {
fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `Folder creation failed: ${errorData}`}}); return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {type: 'error', message: `Folder creation failed: ${errorData}`}
});
} }
return; return;
} }
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Folder created successfully', path: fullPath, toast: {type: 'success', message: `Folder created: ${fullPath}`}}); res.json({
message: 'Folder created successfully',
path: fullPath,
toast: {type: 'success', message: `Folder created: ${fullPath}`}
});
} }
}); });
@@ -907,7 +1020,11 @@ app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Item deleted successfully', path: itemPath, toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}}); res.json({
message: 'Item deleted successfully',
path: itemPath,
toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}
});
} }
return; return;
} }
@@ -915,13 +1032,20 @@ app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => {
if (code !== 0) { if (code !== 0) {
fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `Delete failed: ${errorData}`}}); return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {type: 'error', message: `Delete failed: ${errorData}`}
});
} }
return; return;
} }
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Item deleted successfully', path: itemPath, toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}}); res.json({
message: 'Item deleted successfully',
path: itemPath,
toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}
});
} }
}); });
@@ -992,7 +1116,12 @@ app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Item renamed successfully', oldPath, newPath, toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}}); res.json({
message: 'Item renamed successfully',
oldPath,
newPath,
toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}
});
} }
return; return;
} }
@@ -1000,13 +1129,21 @@ app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => {
if (code !== 0) { if (code !== 0) {
fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `Rename failed: ${errorData}`}}); return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {type: 'error', message: `Rename failed: ${errorData}`}
});
} }
return; return;
} }
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'Item renamed successfully', oldPath, newPath, toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}}); res.json({
message: 'Item renamed successfully',
oldPath,
newPath,
toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}
});
} }
}); });
@@ -1031,5 +1168,5 @@ process.on('SIGTERM', () => {
const PORT = 8084; const PORT = 8084;
app.listen(PORT, () => { app.listen(PORT, () => {
fileLogger.success('File Manager API server started', { operation: 'server_start', port: PORT }); fileLogger.success('File Manager API server started', {operation: 'server_start', port: PORT});
}); });

View File

@@ -1,16 +1,12 @@
import express from 'express'; import express from 'express';
import fetch from 'node-fetch';
import net from 'net'; import net from 'net';
import cors from 'cors'; import cors from 'cors';
import {Client, type ConnectConfig} from 'ssh2'; import {Client, type ConnectConfig} from 'ssh2';
import {db} from '../database/db/index.js'; import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js'; import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import { statsLogger } from '../utils/logger.js'; import {statsLogger} from '../utils/logger.js';
// Rate limiting removed - not needed for internal application
// Connection pooling
interface PooledConnection { interface PooledConnection {
client: Client; client: Client;
lastUsed: number; lastUsed: number;
@@ -37,7 +33,7 @@ class SSHConnectionPool {
async getConnection(host: SSHHostWithCredentials): Promise<Client> { async getConnection(host: SSHHostWithCredentials): Promise<Client> {
const hostKey = this.getHostKey(host); const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || []; const connections = this.connections.get(hostKey) || [];
const available = connections.find(conn => !conn.inUse); const available = connections.find(conn => !conn.inUse);
if (available) { if (available) {
available.inUse = true; available.inUse = true;
@@ -112,7 +108,7 @@ class SSHConnectionPool {
private cleanup(): void { private cleanup(): void {
const now = Date.now(); const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10 minutes const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) { for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter(conn => { const activeConnections = connections.filter(conn => {
@@ -120,7 +116,7 @@ class SSHConnectionPool {
try { try {
conn.client.end(); conn.client.end();
} catch { } catch {
} }
return false; return false;
} }
@@ -142,7 +138,7 @@ class SSHConnectionPool {
try { try {
conn.client.end(); conn.client.end();
} catch { } catch {
} }
} }
} }
@@ -150,7 +146,6 @@ class SSHConnectionPool {
} }
} }
// Request queuing to prevent race conditions
class RequestQueue { class RequestQueue {
private queues = new Map<number, Array<() => Promise<any>>>(); private queues = new Map<number, Array<() => Promise<any>>>();
private processing = new Set<number>(); private processing = new Set<number>();
@@ -173,21 +168,21 @@ class RequestQueue {
private async processQueue(hostId: number): Promise<void> { private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return; if (this.processing.has(hostId)) return;
this.processing.add(hostId); this.processing.add(hostId);
const queue = this.queues.get(hostId) || []; const queue = this.queues.get(hostId) || [];
while (queue.length > 0) { while (queue.length > 0) {
const request = queue.shift(); const request = queue.shift();
if (request) { if (request) {
try { try {
await request(); await request();
} catch (error) { } catch (error) {
} }
} }
} }
this.processing.delete(hostId); this.processing.delete(hostId);
if (queue.length > 0) { if (queue.length > 0) {
this.processQueue(hostId); this.processQueue(hostId);
@@ -195,7 +190,6 @@ class RequestQueue {
} }
} }
// Metrics caching
interface CachedMetrics { interface CachedMetrics {
data: any; data: any;
timestamp: number; timestamp: number;
@@ -204,7 +198,7 @@ interface CachedMetrics {
class MetricsCache { class MetricsCache {
private cache = new Map<number, CachedMetrics>(); private cache = new Map<number, CachedMetrics>();
private ttl = 30000; // 30 seconds private ttl = 30000;
get(hostId: number): any | null { get(hostId: number): any | null {
const cached = this.cache.get(hostId); const cached = this.cache.get(hostId);
@@ -231,7 +225,6 @@ class MetricsCache {
} }
} }
// Global instances
const connectionPool = new SSHConnectionPool(); const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue(); const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache(); const metricsCache = new MetricsCache();
@@ -268,13 +261,10 @@ type StatusEntry = {
lastChecked: string; lastChecked: string;
}; };
// Rate limiting middleware removed
// Input validation middleware
function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) { function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id || !Number.isInteger(id) || id <= 0) { if (!id || !Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid host ID' }); return res.status(400).json({error: 'Invalid host ID'});
} }
next(); next();
} }
@@ -294,7 +284,7 @@ app.use((req, res, next) => {
} }
next(); next();
}); });
app.use(express.json({ limit: '1mb' })); // Add request size limit app.use(express.json({limit: '1mb'}));
const hostStatuses: Map<number, StatusEntry> = new Map(); const hostStatuses: Map<number, StatusEntry> = new Map();
@@ -388,7 +378,7 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
if (credential.keyType) { if (credential.keyType) {
baseHost.keyType = credential.keyType; baseHost.keyType = credential.keyType;
} }
} else { } else {
statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
@@ -514,7 +504,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }; memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null }; disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
}> { }> {
// Check cache first
const cached = metricsCache.get(host.id); const cached = metricsCache.get(host.id);
if (cached) { if (cached) {
return cached; return cached;
@@ -525,16 +514,14 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let cpuPercent: number | null = null; let cpuPercent: number | null = null;
let cores: number | null = null; let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null; let loadTriplet: [number, number, number] | null = null;
try { try {
// Execute all commands in parallel for better performance
const [stat1, loadAvgOut, coresOut] = await Promise.all([ const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, 'cat /proc/stat'), execCommand(client, 'cat /proc/stat'),
execCommand(client, 'cat /proc/loadavg'), execCommand(client, 'cat /proc/loadavg'),
execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo') execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo')
]); ]);
// Wait for CPU calculation
await new Promise(r => setTimeout(r, 500)); await new Promise(r => setTimeout(r, 500));
const stat2 = await execCommand(client, 'cat /proc/stat'); const stat2 = await execCommand(client, 'cat /proc/stat');
@@ -633,7 +620,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman}, disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
}; };
// Cache the result
metricsCache.set(host.id, result); metricsCache.set(host.id, result);
return result; return result;
}); });
@@ -667,7 +653,7 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
async function pollStatusesOnce(): Promise<void> { async function pollStatusesOnce(): Promise<void> {
const hosts = await fetchAllHosts(); const hosts = await fetchAllHosts();
if (hosts.length === 0) { if (hosts.length === 0) {
statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' }); statsLogger.warn('No hosts retrieved for status polling', {operation: 'status_poll'});
return; return;
} }
@@ -684,7 +670,12 @@ async function pollStatusesOnce(): Promise<void> {
const results = await Promise.allSettled(checks); const results = await Promise.allSettled(checks);
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length; const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
const offlineCount = hosts.length - onlineCount; const offlineCount = hosts.length - onlineCount;
statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount }); statsLogger.success('Status polling completed', {
operation: 'status_poll',
totalHosts: hosts.length,
onlineCount,
offlineCount
});
} }
app.get('/status', async (req, res) => { app.get('/status', async (req, res) => {
@@ -726,14 +717,13 @@ app.post('/refresh', async (req, res) => {
app.get('/metrics/:id', validateHostId, async (req, res) => { app.get('/metrics/:id', validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id);
if (!host) { if (!host) {
return res.status(404).json({error: 'Host not found'}); return res.status(404).json({error: 'Host not found'});
} }
// Check if host is online first
const isOnline = await tcpPing(host.ip, host.port, 5000); const isOnline = await tcpPing(host.ip, host.port, 5000);
if (!isOnline) { if (!isOnline) {
return res.status(503).json({ return res.status(503).json({
@@ -749,8 +739,7 @@ app.get('/metrics/:id', validateHostId, async (req, res) => {
res.json({...metrics, lastChecked: new Date().toISOString()}); res.json({...metrics, lastChecked: new Date().toISOString()});
} catch (err) { } catch (err) {
statsLogger.error('Failed to collect metrics', err); statsLogger.error('Failed to collect metrics', err);
// Return proper error response instead of empty data
if (err instanceof Error && err.message.includes('timeout')) { if (err instanceof Error && err.message.includes('timeout')) {
return res.status(504).json({ return res.status(504).json({
error: 'Metrics collection timeout', error: 'Metrics collection timeout',
@@ -760,7 +749,7 @@ app.get('/metrics/:id', validateHostId, async (req, res) => {
lastChecked: new Date().toISOString() lastChecked: new Date().toISOString()
}); });
} }
return res.status(500).json({ return res.status(500).json({
error: 'Failed to collect metrics', error: 'Failed to collect metrics',
cpu: {percent: null, cores: null, load: null}, cpu: {percent: null, cores: null, load: null},
@@ -771,7 +760,6 @@ app.get('/metrics/:id', validateHostId, async (req, res) => {
} }
}); });
// Graceful shutdown
process.on('SIGINT', () => { process.on('SIGINT', () => {
statsLogger.info('Received SIGINT, shutting down gracefully'); statsLogger.info('Received SIGINT, shutting down gracefully');
connectionPool.destroy(); connectionPool.destroy();
@@ -786,10 +774,10 @@ process.on('SIGTERM', () => {
const PORT = 8085; const PORT = 8085;
app.listen(PORT, async () => { app.listen(PORT, async () => {
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT }); statsLogger.success('Server Stats API server started', {operation: 'server_start', port: PORT});
try { try {
await pollStatusesOnce(); await pollStatusesOnce();
} catch (err) { } catch (err) {
statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' }); statsLogger.error('Initial poll failed', err, {operation: 'initial_poll'});
} }
}); });

View File

@@ -1,35 +1,32 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws'; import {WebSocketServer, WebSocket, type RawData} from 'ws';
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
import {db} from '../database/db/index.js'; import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js'; import {sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import { sshLogger } from '../utils/logger.js'; import {sshLogger} from '../utils/logger.js';
const wss = new WebSocketServer({port: 8082}); const wss = new WebSocketServer({port: 8082});
sshLogger.success('SSH Terminal WebSocket server started', { operation: 'server_start', port: 8082 }); sshLogger.success('SSH Terminal WebSocket server started', {operation: 'server_start', port: 8082});
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null; let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null; let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null; let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => { ws.on('close', () => {
cleanupSSH(); cleanupSSH();
}); });
ws.on('message', (msg: RawData) => { ws.on('message', (msg: RawData) => {
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(msg.toString()); parsed = JSON.parse(msg.toString());
} catch (e) { } catch (e) {
sshLogger.error('Invalid JSON received', e, { operation: 'websocket_message', messageLength: msg.toString().length }); sshLogger.error('Invalid JSON received', e, {
operation: 'websocket_message',
messageLength: msg.toString().length
});
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
return; return;
} }
@@ -39,8 +36,15 @@ wss.on('connection', (ws: WebSocket) => {
switch (type) { switch (type) {
case 'connectToHost': case 'connectToHost':
handleConnectToHost(data).catch(error => { handleConnectToHost(data).catch(error => {
sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip }); sshLogger.error('Failed to connect to host', error, {
ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')})); operation: 'ssh_connect',
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip
});
ws.send(JSON.stringify({
type: 'error',
message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')
}));
}); });
break; break;
@@ -69,7 +73,7 @@ wss.on('connection', (ws: WebSocket) => {
break; break;
default: default:
sshLogger.warn('Unknown message type received', { operation: 'websocket_message', messageType: type }); sshLogger.warn('Unknown message type received', {operation: 'websocket_message', messageType: type});
} }
}); });
@@ -94,19 +98,25 @@ wss.on('connection', (ws: WebSocket) => {
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig; const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') { if (!username || typeof username !== 'string' || username.trim() === '') {
sshLogger.error('Invalid username provided', undefined, { operation: 'ssh_connect', hostId: id, ip }); sshLogger.error('Invalid username provided', undefined, {operation: 'ssh_connect', hostId: id, ip});
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
return; return;
} }
if (!ip || typeof ip !== 'string' || ip.trim() === '') { if (!ip || typeof ip !== 'string' || ip.trim() === '') {
sshLogger.error('Invalid IP provided', undefined, { operation: 'ssh_connect', hostId: id, username }); sshLogger.error('Invalid IP provided', undefined, {operation: 'ssh_connect', hostId: id, username});
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
return; return;
} }
if (!port || typeof port !== 'number' || port <= 0) { if (!port || typeof port !== 'number' || port <= 0) {
sshLogger.error('Invalid port provided', undefined, { operation: 'ssh_connect', hostId: id, ip, username, port }); sshLogger.error('Invalid port provided', undefined, {
operation: 'ssh_connect',
hostId: id,
ip,
username,
port
});
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'})); ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
return; return;
} }
@@ -115,7 +125,13 @@ wss.on('connection', (ws: WebSocket) => {
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
if (sshConn) { if (sshConn) {
sshLogger.error('SSH connection timeout', undefined, { operation: 'ssh_connect', hostId: id, ip, port, username }); sshLogger.error('SSH connection timeout', undefined, {
operation: 'ssh_connect',
hostId: id,
ip,
port,
username
});
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
} }
@@ -142,13 +158,28 @@ wss.on('connection', (ws: WebSocket) => {
authType: credential.authType authType: credential.authType
}; };
} else { } else {
sshLogger.warn(`No credentials found for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, userId: hostConfig.userId }); sshLogger.warn(`No credentials found for host ${id}`, {
operation: 'ssh_credentials',
hostId: id,
credentialId,
userId: hostConfig.userId
});
} }
} catch (error) { } catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: 'ssh_credentials',
hostId: id,
credentialId,
error: error instanceof Error ? error.message : 'Unknown error'
});
} }
} else if (credentialId && id) { } else if (credentialId && id) {
sshLogger.warn('Missing userId for credential resolution in terminal', { operation: 'ssh_credentials', hostId: id, credentialId, hasUserId: !!hostConfig.userId }); sshLogger.warn('Missing userId for credential resolution in terminal', {
operation: 'ssh_credentials',
hostId: id,
credentialId,
hasUserId: !!hostConfig.userId
});
} }
sshConn.on('ready', () => { sshConn.on('ready', () => {
@@ -161,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
term: 'xterm-256color' term: 'xterm-256color'
} as PseudoTtyOptions, (err, stream) => { } as PseudoTtyOptions, (err, stream) => {
if (err) { if (err) {
sshLogger.error('Shell error', err, { operation: 'ssh_shell', hostId: id, ip, port, username }); sshLogger.error('Shell error', err, {operation: 'ssh_shell', hostId: id, ip, port, username});
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
return; return;
} }
@@ -177,7 +208,7 @@ wss.on('connection', (ws: WebSocket) => {
}); });
stream.on('error', (err: Error) => { stream.on('error', (err: Error) => {
sshLogger.error('SSH stream error', err, { operation: 'ssh_stream', hostId: id, ip, port, username }); sshLogger.error('SSH stream error', err, {operation: 'ssh_stream', hostId: id, ip, port, username});
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
}); });
@@ -189,7 +220,14 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('error', (err: Error) => { sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
sshLogger.error('SSH connection error', err, { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType }); sshLogger.error('SSH connection error', err, {
operation: 'ssh_connect',
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType
});
let errorMessage = 'SSH error: ' + err.message; let errorMessage = 'SSH error: ' + err.message;
if (err.message.includes('No matching key exchange algorithm')) { if (err.message.includes('No matching key exchange algorithm')) {
@@ -219,7 +257,6 @@ wss.on('connection', (ws: WebSocket) => {
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
}); });
const connectConfig: any = { const connectConfig: any = {
host: ip, host: ip,
port, port,
@@ -359,6 +396,4 @@ wss.on('connection', (ws: WebSocket) => {
} }
}, 60000); }, 60000);
} }
}); });

View File

@@ -3,22 +3,18 @@ import cors from 'cors';
import {Client} from 'ssh2'; import {Client} from 'ssh2';
import {ChildProcess} from 'child_process'; import {ChildProcess} from 'child_process';
import axios from 'axios'; import axios from 'axios';
import * as net from 'net';
import {db} from '../database/db/index.js'; import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js'; import {sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm'; import {eq, and} from 'drizzle-orm';
import type { import type {
SSHHost, SSHHost,
TunnelConfig, TunnelConfig,
TunnelConnection, TunnelStatus,
TunnelStatus,
HostConfig,
VerificationData, VerificationData,
ConnectionState,
ErrorType ErrorType
} from '../../types/index.js'; } from '../../types/index.js';
import { CONNECTION_STATES } from '../../types/index.js'; import {CONNECTION_STATES} from '../../types/index.js';
import { tunnelLogger } from '../utils/logger.js'; import {tunnelLogger} from '../utils/logger.js';
const app = express(); const app = express();
app.use(cors({ app.use(cors({
@@ -42,18 +38,6 @@ const retryExhaustedTunnels = new Set<string>();
const tunnelConfigs = new Map<string, TunnelConfig>(); const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>(); const activeTunnelProcesses = new Map<string, ChildProcess>();
const ERROR_TYPES = {
AUTH: "AUTHENTICATION_FAILED",
NETWORK: "NETWORK_ERROR",
PORT: "CONNECTION_FAILED",
PERMISSION: "CONNECTION_FAILED",
TIMEOUT: "TIMEOUT",
UNKNOWN: "UNKNOWN"
} as const;
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) { if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
return; return;
@@ -338,19 +322,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
} }
} }
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void { function setupPingInterval(tunnelName: string): void {
if (isPeriodic) {
if (!activeTunnels.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
reason: 'Tunnel connection lost'
});
}
}
}
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
const pingKey = `${tunnelName}_ping`; const pingKey = `${tunnelName}_ping`;
if (verificationTimers.has(pingKey)) { if (verificationTimers.has(pingKey)) {
clearInterval(verificationTimers.get(pingKey)!); clearInterval(verificationTimers.get(pingKey)!);
@@ -403,7 +375,13 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
} }
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) { if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
tunnelLogger.error('Invalid tunnel connection details', { operation: 'tunnel_connect', tunnelName, hasSourceIP: !!tunnelConfig?.sourceIP, hasSourceUsername: !!tunnelConfig?.sourceUsername, hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort }); tunnelLogger.error('Invalid tunnel connection details', {
operation: 'tunnel_connect',
tunnelName,
hasSourceIP: !!tunnelConfig?.sourceIP,
hasSourceUsername: !!tunnelConfig?.sourceUsername,
hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort
});
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.FAILED, status: CONNECTION_STATES.FAILED,
@@ -440,14 +418,22 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
authMethod: credential.authType authMethod: credential.authType
}; };
} else { } else {
tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId }); tunnelLogger.warn('No source credentials found in database', {
operation: 'tunnel_connect',
tunnelName,
credentialId: tunnelConfig.sourceCredentialId
});
} }
} catch (error) { } catch (error) {
tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' }); tunnelLogger.warn('Failed to resolve source credentials from database', {
operation: 'tunnel_connect',
tunnelName,
credentialId: tunnelConfig.sourceCredentialId,
error: error instanceof Error ? error.message : 'Unknown error'
});
} }
} }
// Resolve endpoint credentials if tunnel config has endpointCredentialId
let resolvedEndpointCredentials = { let resolvedEndpointCredentials = {
password: tunnelConfig.endpointPassword, password: tunnelConfig.endpointPassword,
sshKey: tunnelConfig.endpointSSHKey, sshKey: tunnelConfig.endpointSSHKey,
@@ -476,13 +462,22 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
authMethod: credential.authType authMethod: credential.authType
}; };
} else { } else {
tunnelLogger.warn('No endpoint credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.endpointCredentialId }); tunnelLogger.warn('No endpoint credentials found in database', {
operation: 'tunnel_connect',
tunnelName,
credentialId: tunnelConfig.endpointCredentialId
});
} }
} catch (error) { } catch (error) {
tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} else if (tunnelConfig.endpointCredentialId) { } else if (tunnelConfig.endpointCredentialId) {
tunnelLogger.warn('Missing userId for endpoint credential resolution', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.endpointCredentialId, hasUserId: !!tunnelConfig.endpointUserId }); tunnelLogger.warn('Missing userId for endpoint credential resolution', {
operation: 'tunnel_connect',
tunnelName,
credentialId: tunnelConfig.endpointCredentialId,
hasUserId: !!tunnelConfig.endpointUserId
});
} }
const conn = new Client(); const conn = new Client();
@@ -597,7 +592,7 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
connected: true, connected: true,
status: CONNECTION_STATES.CONNECTED status: CONNECTION_STATES.CONNECTED
}); });
setupPingInterval(tunnelName, tunnelConfig); setupPingInterval(tunnelName);
} }
}, 2000); }, 2000);
@@ -1016,7 +1011,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
const PORT = 8083; const PORT = 8083;
app.listen(PORT, () => { app.listen(PORT, () => {
tunnelLogger.success('SSH Tunnel API server started', { operation: 'server_start', port: PORT }); tunnelLogger.success('SSH Tunnel API server started', {operation: 'server_start', port: PORT});
setTimeout(() => { setTimeout(() => {
initializeAutoStartTunnels(); initializeAutoStartTunnels();
}, 2000); }, 2000);

View File

@@ -100,7 +100,6 @@ class Logger {
console.log(this.formatMessage('success', message, context)); console.log(this.formatMessage('success', message, context));
} }
// Convenience methods for common operations
auth(message: string, context?: LogContext): void { auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' }); this.info(`AUTH: ${message}`, { ...context, operation: 'auth' });
} }
@@ -144,18 +143,6 @@ class Logger {
retry(message: string, context?: LogContext): void { retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' }); this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' });
} }
cleanup(message: string, context?: LogContext): void {
this.info(`CLEANUP: ${message}`, { ...context, operation: 'cleanup' });
}
metrics(message: string, context?: LogContext): void {
this.info(`METRICS: ${message}`, { ...context, operation: 'metrics' });
}
security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, { ...context, operation: 'security' });
}
} }
export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1'); export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1');

View File

@@ -196,7 +196,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
function connectToHost(cols: number, rows: number) { function connectToHost(cols: number, rows: number) {
const isDev = process.env.NODE_ENV === 'development' && const isDev = process.env.NODE_ENV === 'development' &&
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); (window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
const wsUrl = isDev const wsUrl = isDev
? 'ws://localhost:8082' ? 'ws://localhost:8082'