diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 14c1d8b7..bc5b825a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: [LukeGus] \ No newline at end of file +github: [ LukeGus ] \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 89762628..c5949a83 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,7 +21,7 @@ updates: dependency-type: "production" update-types: - "minor" - + - package-ecosystem: "docker" directory: "/docker" schedule: diff --git a/docker/Dockerfile b/docker/Dockerfile index 4140e1ac..92d774a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,12 +27,10 @@ WORKDIR /app COPY . . -# Set environment variables for native module compilation ENV npm_config_target_platform=linux ENV npm_config_target_arch=x64 ENV npm_config_target_libc=glibc -# Rebuild native modules for the target platform RUN npm rebuild better-sqlite3 --force RUN npm run build:backend diff --git a/electron/main.cjs b/electron/main.cjs index 32f13f71..692a1e81 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, shell, ipcMain } = require('electron'); +const {app, BrowserWindow, shell, ipcMain} = require('electron'); const path = require('path'); const fs = require('fs'); @@ -29,7 +29,7 @@ function createWindow() { minWidth: 800, minHeight: 600, title: 'Termix', - icon: isDev + icon: isDev ? path.join(__dirname, '..', 'public', 'icon.png') : path.join(process.resourcesPath, 'public', 'icon.png'), webPreferences: { @@ -78,9 +78,9 @@ function createWindow() { mainWindow = null; }); - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + mainWindow.webContents.setWindowOpenHandler(({url}) => { shell.openExternal(url); - return { action: 'deny' }; + return {action: 'deny'}; }); } @@ -92,12 +92,11 @@ ipcMain.handle('get-platform', () => { return process.platform; }); -// Server configuration handlers ipcMain.handle('get-server-config', () => { try { const userDataPath = app.getPath('userData'); const configPath = path.join(userDataPath, 'server-config.json'); - + if (fs.existsSync(configPath)) { const configData = fs.readFileSync(configPath, 'utf8'); return JSON.parse(configData); @@ -113,40 +112,36 @@ ipcMain.handle('save-server-config', (event, config) => { try { const userDataPath = app.getPath('userData'); const configPath = path.join(userDataPath, 'server-config.json'); - - // Ensure userData directory exists + if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, { recursive: true }); + fs.mkdirSync(userDataPath, {recursive: true}); } - + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - return { success: true }; + return {success: true}; } catch (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) => { try { - // Use Node.js built-in fetch (available in Node 18+) or fallback to https module let fetch; try { - // Try to use built-in fetch first (Node 18+) fetch = globalThis.fetch || require('node:fetch'); } catch (e) { - // Fallback to https module for older Node versions const https = require('https'); const http = require('http'); - const { URL } = require('url'); - + const {URL} = require('url'); + fetch = (url, options = {}) => { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const client = isHttps ? https : http; - + const req = client.request(url, { method: options.method || 'GET', headers: options.headers || {}, @@ -163,13 +158,13 @@ ipcMain.handle('test-server-connection', async (event, serverUrl) => { }); }); }); - + req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); - + if (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(/\/$/, ''); - - // Test the health endpoint specifically - this is required for a valid Termix server + const healthUrl = `${normalizedServerUrl}/health`; - + try { const response = await fetch(healthUrl, { method: 'GET', timeout: 5000 }); - + if (response.ok) { const data = await response.text(); - - // Reject if response looks like HTML (YouTube, etc.) + if (data.includes('') || data.includes('')) { 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 { const healthData = JSON.parse(data); - // Check if it has the expected Termix health check structure if (healthData && ( - healthData.status === 'ok' || // Termix returns {status: 'ok'} - healthData.status === 'healthy' || - healthData.healthy === true || + healthData.status === 'ok' || + healthData.status === 'healthy' || + healthData.healthy === true || healthData.database === 'connected' )) { - return { success: true, status: response.status, testedUrl: healthUrl }; + return {success: true, status: response.status, testedUrl: healthUrl}; } } catch (parseError) { - // If not JSON, reject - Termix health endpoint should return JSON console.log('Health endpoint did not return valid JSON'); } } } catch (urlError) { console.error('Health check failed:', urlError); } - - // If health check fails, try version endpoint as fallback + try { const versionUrl = `${normalizedServerUrl}/version`; const response = await fetch(versionUrl, { method: 'GET', timeout: 5000 }); - + if (response.ok) { const data = await response.text(); - - // Reject if response looks like HTML (YouTube, etc.) + if (data.includes('') || data.includes('')) { 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 { const versionData = JSON.parse(data); - // Check if it looks like a Termix version response - must be JSON and contain version-specific fields if (versionData && ( - versionData.status === 'up_to_date' || + versionData.status === 'up_to_date' || versionData.status === 'requires_update' || (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) { - // If not JSON, reject - Termix version endpoint should return JSON console.log('Version endpoint did not return valid JSON'); } } } catch (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) { - return { success: false, error: error.message }; + return {success: false, error: error.message}; } }); diff --git a/electron/preload.js b/electron/preload.js index 9ca10696..9c222d73 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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', { - // App info getAppVersion: () => ipcRenderer.invoke('get-app-version'), getPlatform: () => ipcRenderer.invoke('get-platform'), - - // Server configuration + getServerConfig: () => ipcRenderer.invoke('get-server-config'), saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), - - // File dialogs + showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), - - // Update events + onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), - - // Utility + removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), isElectron: true, isDev: process.env.NODE_ENV === 'development', - - // Generic invoke method + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), - + }); -// Also set the legacy IS_ELECTRON flag for backward compatibility window.IS_ELECTRON = true; console.log('electronAPI exposed to window'); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index b07ec3cd..91c4597f 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -9,7 +9,7 @@ import fetch from 'node-fetch'; import fs from 'fs'; import path from 'path'; import 'dotenv/config'; -import { databaseLogger, apiLogger } from '../utils/logger.js'; +import {databaseLogger, apiLogger} from '../utils/logger.js'; const app = express(); app.use(cors({ @@ -107,7 +107,7 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise cached: false }; } 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; } } @@ -127,12 +127,12 @@ app.get('/version', async (req, res) => { const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); localVersion = packageJson.version; } 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) { - 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'); } @@ -148,7 +148,7 @@ app.get('/version', async (req, res) => { const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; 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'); } @@ -170,7 +170,7 @@ app.get('/version', async (req, res) => { res.json(response); } 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'); } }); @@ -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 cacheKey = `releases_rss_${page}_${per_page}`; - // RSS releases requested - const releasesData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, cacheKey @@ -218,11 +216,9 @@ app.get('/releases/rss', async (req, res) => { cache_age: releasesData.cache_age }; - // RSS releases generated successfully - res.json(response); } 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({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' @@ -237,9 +233,9 @@ app.use('/alerts', alertRoutes); app.use('/credentials', credentialsRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { - apiLogger.error('Unhandled error in request', err, { - operation: 'error_handler', - method: req.method, + apiLogger.error('Unhandled error in request', err, { + operation: 'error_handler', + method: req.method, url: req.url, userAgent: req.get('User-Agent') }); @@ -248,8 +244,8 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres const PORT = 8081; app.listen(PORT, () => { - databaseLogger.success(`Database API server started on port ${PORT}`, { - operation: 'server_start', + databaseLogger.success(`Database API server started on port ${PORT}`, { + operation: 'server_start', port: PORT, routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 4b359eaf..e643f668 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,6 +1,5 @@ import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; import {sql} from 'drizzle-orm'; -import { databaseLogger } from '../../utils/logger.js'; export const users = sqliteTable('users', { id: text('id').primaryKey(), @@ -40,12 +39,12 @@ export const sshData = sqliteTable('ssh_data', { tags: text('tags'), pin: integer('pin', {mode: 'boolean'}).notNull().default(false), authType: text('auth_type').notNull(), - // Legacy credential fields - kept for backward compatibility + password: text('password'), key: text('key', {length: 8192}), keyPassword: text('key_password'), keyType: text('key_type'), - // New credential management + credentialId: integer('credential_id').references(() => sshCredentials.id), enableTerminal: integer('enable_terminal', {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`), }); -// SSH Credentials Management Tables export const sshCredentials = sqliteTable('ssh_credentials', { id: integer('id').primaryKey({autoIncrement: true}), userId: text('user_id').notNull().references(() => users.id), diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index c23c57ed..bb95dad7 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -3,8 +3,7 @@ import {db} from '../db/index.js'; import {dismissedAlerts} from '../db/schema.js'; import {eq, and} from 'drizzle-orm'; 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 { @@ -76,7 +75,11 @@ async function fetchAlertsFromGitHub(): Promise { }); 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}`); } @@ -93,7 +96,10 @@ async function fetchAlertsFromGitHub(): Promise { alertCache.set(cacheKey, validAlerts); return validAlerts; } 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 []; } } diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index f68e9aa5..62ad0be4 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -4,7 +4,7 @@ import {sshCredentials, sshCredentialUsage, sshData} from '../db/schema.js'; import {eq, and, desc, sql} from 'drizzle-orm'; import type {Request, Response, NextFunction} from 'express'; import jwt from 'jsonwebtoken'; -import { authLogger } from '../../utils/logger.js'; +import {authLogger} from '../../utils/logger.js'; const router = express.Router(); @@ -55,22 +55,37 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { } = req.body; 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'}); } 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"'}); } try { 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'}); } 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'}); } 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 created = result[0]; - - authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { - operation: 'credential_create_success', - userId, - credentialId: created.id, - name, - authType, - username + + authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { + operation: 'credential_create_success', + userId, + credentialId: created.id, + name, + authType, + username }); - + res.status(201).json(formatCredentialOutput(created)); } 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({ 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))); const credential = updated[0]; - authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { - operation: 'credential_update_success', - userId, - credentialId: parseInt(id), - name: credential.name, - authType: credential.authType, - username: credential.username + authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { + operation: 'credential_update_success', + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username }); res.json(formatCredentialOutput(updated[0])); @@ -366,13 +387,13 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { )); const credential = credentialToDelete[0]; - authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { - operation: 'credential_delete_success', - userId, - credentialId: parseInt(id), - name: credential.name, - authType: credential.authType, - username: credential.username + authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { + operation: 'credential_delete_success', + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username }); 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) )); - // Record credential usage await db.insert(sshCredentialUsage).values({ credentialId: parseInt(credentialId), hostId: parseInt(hostId), userId, }); - // Update credential usage stats await db .update(sshCredentials) .set({ @@ -529,28 +548,28 @@ function formatSSHHostOutput(host: any): any { // PUT /credentials/folders/rename router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { oldName, newName } = req.body; + const {oldName, newName} = req.body; 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) { - 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 { await db.update(sshCredentials) - .set({ folder: newName }) + .set({folder: newName}) .where(and( eq(sshCredentials.userId, userId), eq(sshCredentials.folder, oldName) )); - res.json({ success: true, message: 'Folder renamed successfully' }); + res.json({success: true, message: 'Folder renamed successfully'}); } catch (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'}); } }); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index ec1b5df8..09ff0078 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -5,7 +5,7 @@ import {eq, and, desc} from 'drizzle-orm'; import type {Request, Response, NextFunction} from 'express'; import jwt from 'jsonwebtoken'; import multer from 'multer'; -import { sshLogger } from '../../utils/logger.js'; +import {sshLogger} from '../../utils/logger.js'; const router = express.Router(); @@ -83,11 +83,15 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque try { hostData = JSON.parse(req.body.data); } 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'}); } } 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'}); } @@ -120,12 +124,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque tunnelConnections } = hostData; if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { - sshLogger.warn('Invalid SSH data input validation failed', { - operation: 'host_create', - userId, - hasIp: !!ip, - port, - isValidPort: isValidPort(port) + sshLogger.warn('Invalid SSH data input validation failed', { + operation: 'host_create', + userId, + hasIp: !!ip, + port, + isValidPort: isValidPort(port) }); 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 { const result = await db.insert(sshData).values(sshDataObj).returning(); - + 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'}); } - + const createdHost = result[0]; const baseHost = { ...createdHost, @@ -179,22 +183,29 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections) : [], enableFileManager: !!createdHost.enableFileManager, }; - + const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; - - sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, { - operation: 'host_create_success', - userId, - hostId: createdHost.id, - name, - ip, - port, - authType: effectiveAuthType + + sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, { + operation: 'host_create_success', + userId, + hostId: createdHost.id, + name, + ip, + port, + authType: effectiveAuthType }); - + res.json(resolvedHost); } 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'}); } }); @@ -211,11 +222,20 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re try { hostData = JSON.parse(req.body.data); } 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'}); } } 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'}); } @@ -248,13 +268,13 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re tunnelConnections } = hostData; if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !hostId) { - sshLogger.warn('Invalid SSH data input validation failed for update', { - operation: 'host_update', - hostId: parseInt(hostId), - userId, - hasIp: !!ip, - port, - isValidPort: isValidPort(port) + sshLogger.warn('Invalid SSH data input validation failed for update', { + operation: 'host_update', + hostId: parseInt(hostId), + userId, + hasIp: !!ip, + port, + isValidPort: isValidPort(port) }); 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) .set(sshDataObj) .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - + const updatedHosts = await db .select() .from(sshData) .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - + 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'}); } - + const updatedHost = updatedHosts[0]; const baseHost = { ...updatedHost, @@ -322,22 +346,30 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections) : [], enableFileManager: !!updatedHost.enableFileManager, }; - + const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; - - sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, { - operation: 'host_update_success', - userId, - hostId: parseInt(hostId), - name, - ip, - port, - authType: effectiveAuthType + + sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, { + operation: 'host_update_success', + userId, + hostId: parseInt(hostId), + name, + ip, + port, + authType: effectiveAuthType }); - + res.json(resolvedHost); } 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'}); } }); @@ -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) => { const userId = (req as any).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'}); } try { @@ -355,7 +387,7 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { .select() .from(sshData) .where(eq(sshData.userId, userId)); - + const result = await Promise.all(data.map(async (row: any) => { const baseHost = { ...row, @@ -366,13 +398,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], enableFileManager: !!row.enableFileManager, }; - + return await resolveHostCredentials(baseHost) || baseHost; })); - + res.json(result); } 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'}); } }); @@ -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) => { const hostId = req.params.id; const userId = (req as any).userId; - + 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'}); } 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))); 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'}); } @@ -408,10 +444,14 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], enableFileManager: !!host.enableFileManager, }; - + res.json(await resolveHostCredentials(result) || result); } 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'}); } }); @@ -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) => { const userId = (req as any).userId; const hostId = req.params.id; - + 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'}); } try { @@ -431,28 +475,36 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons .select() .from(sshData) .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - + 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'}); } - + const result = await db.delete(sshData) .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); - + const host = hostToDelete[0]; - sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, { - operation: 'host_delete_success', - userId, - hostId: parseInt(hostId), - name: host.name, - ip: host.ip, - port: host.port + sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, { + operation: 'host_delete_success', + userId, + hostId: parseInt(hostId), + name: host.name, + ip: host.ip, + port: host.port }); - + res.json({message: 'SSH host deleted'}); } 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'}); } }); @@ -492,7 +544,7 @@ router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Re // POST /ssh/file_manager/recent router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn('Invalid data for recent file addition'); @@ -500,7 +552,6 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R } try { - // Check if file already exists const existing = await db .select() .from(fileManagerRecent) @@ -511,13 +562,11 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R )); if (existing.length > 0) { - // Update last opened time await db .update(fileManagerRecent) - .set({ lastOpened: new Date().toISOString() }) + .set({lastOpened: new Date().toISOString()}) .where(eq(fileManagerRecent.id, existing[0].id)); } else { - // Insert new record await db.insert(fileManagerRecent).values({ userId, hostId, @@ -538,7 +587,7 @@ router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: R // DELETE /ssh/file_manager/recent router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { 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 router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn('Invalid data for pinned file addition'); @@ -603,7 +652,6 @@ router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: R } try { - // Check if file already exists const existing = await db .select() .from(fileManagerPinned) @@ -636,7 +684,7 @@ router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: R // DELETE /ssh/file_manager/pinned router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { 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 router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn('Invalid data for shortcut addition'); @@ -701,7 +749,6 @@ router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res } try { - // Check if shortcut already exists const existing = await db .select() .from(fileManagerShortcuts) @@ -734,7 +781,7 @@ router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res // DELETE /ssh/file_manager/shortcuts router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hostId, path, name } = req.body; + const {hostId, path, name} = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn('Invalid data for shortcut deletion'); @@ -792,7 +839,7 @@ async function resolveHostCredentials(host: any): Promise { // PUT /ssh/db/folders/rename router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { oldName, newName } = req.body; + const {oldName, newName} = req.body; if (!isNonEmptyString(userId) || !oldName || !newName) { sshLogger.warn('Invalid data for folder rename'); @@ -804,10 +851,9 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons } try { - // Update all hosts with the old folder name const updatedHosts = await db .update(sshData) - .set({ + .set({ folder: newName, updatedAt: new Date().toISOString() }) @@ -817,10 +863,9 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons )) .returning(); - // Update all credentials with the old folder name const updatedCredentials = await db .update(sshCredentials) - .set({ + .set({ folder: newName, updatedAt: new Date().toISOString() }) @@ -836,7 +881,7 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons updatedCredentials: updatedCredentials.length }); } 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'}); } }); @@ -845,14 +890,14 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons // POST /ssh/bulk-import router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const { hosts } = req.body; + const {hosts} = req.body; 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) { - 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 = { @@ -863,23 +908,20 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) for (let i = 0; i < hosts.length; i++) { const hostData = hosts[i]; - + try { - // Validate required fields if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { results.failed++; results.errors.push(`Host ${i + 1}: Missing required fields (ip, port, username)`); continue; } - // Validate authType if (!['password', 'key', 'credential'].includes(hostData.authType)) { results.failed++; results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`); continue; } - // Validate authentication data based on authType if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { results.failed++; 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; } - // Prepare host data for insertion const sshDataObj: any = { userId: userId, name: hostData.name || `${hostData.username}@${hostData.ip}`, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 07fecfa2..d4b38bac 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,6 +1,13 @@ import express from 'express'; 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 bcrypt from 'bcryptjs'; import {nanoid} from 'nanoid'; @@ -8,7 +15,7 @@ import jwt from 'jsonwebtoken'; import speakeasy from 'speakeasy'; import QRCode from 'qrcode'; 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 { try { @@ -111,7 +118,11 @@ interface JWTPayload { function authenticateJWT(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; 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'}); } const token = authHeader.split(' ')[1]; @@ -119,10 +130,9 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { try { const payload = jwt.verify(token, jwtSecret) as JWTPayload; (req as any).userId = payload.userId; - // JWT authentication successful next(); } 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'}); } } @@ -136,13 +146,17 @@ router.post('/create', async (req, res) => { return res.status(403).json({error: 'Registration is currently disabled'}); } } 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; 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'}); } @@ -152,7 +166,7 @@ router.post('/create', async (req, res) => { .from(users) .where(eq(users.username, username)); 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'}); } @@ -162,7 +176,11 @@ router.post('/create', async (req, res) => { isFirstUser = ((countResult as any)?.count || 0) === 0; } catch (e) { 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); @@ -187,8 +205,17 @@ router.post('/create', async (req, res) => { totp_backup_codes: null, }); - authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { operation: 'user_create', username, isAdmin: isFirstUser, userId: id }); - res.json({message: 'User created', is_admin: isFirstUser, toast: {type: 'success', message: `User created: ${username}`}}); + authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { + 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) { authLogger.error('Failed to create user', err); res.status(500).json({error: 'Failed to create user'}); @@ -240,10 +267,9 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { if (isDisableRequest) { 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'}); } else { - // Enable OIDC by storing the configuration const config = { client_id, 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)); - 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'}); } } 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(); - authLogger.success('OIDC configuration disabled', { operation: 'oidc_disable', userId }); + authLogger.success('OIDC configuration disabled', {operation: 'oidc_disable', userId}); res.json({message: 'OIDC configuration disabled'}); } catch (err) { authLogger.error('Failed to disable OIDC config', err); @@ -533,8 +563,6 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(eq(users.id, id)); - - // OIDC user created - toast notification handled by frontend } else { await db.update(users) .set({username: name}) @@ -544,8 +572,6 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(eq(users.id, user[0].id)); - - // OIDC user logged in - toast notification handled by frontend } const userRecord = user[0]; @@ -589,7 +615,11 @@ router.post('/login', async (req, res) => { const {username, password} = req.body; 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'}); } @@ -600,20 +630,28 @@ router.post('/login', async (req, res) => { .where(eq(users.username, username)); 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'}); } const userRecord = user[0]; 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'}); } const isMatch = await bcrypt.compare(password, userRecord.password_hash); 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'}); } const jwtSecret = process.env.JWT_SECRET || 'secret'; @@ -621,8 +659,6 @@ router.post('/login', async (req, res) => { expiresIn: '50d', }); - // Traditional user logged in - toast notification handled by frontend - if (userRecord.totp_enabled) { const tempToken = jwt.sign( {userId: userRecord.id, pending_totp: true}, @@ -886,7 +922,6 @@ router.post('/complete-reset', async (req, res) => { const expiresAt = new Date(tempTokenData.expiresAt); if (now > expiresAt) { - // Clean up expired token db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); 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)); 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`}); } catch (err) { @@ -1011,7 +1045,6 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => { .where(eq(users.username, 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}`}); } catch (err) { @@ -1073,8 +1106,6 @@ router.post('/totp/verify-login', async (req, res) => { expiresIn: '50d', }); - // TOTP login completed - toast notification handled by frontend - return res.json({ token, is_admin: !!userRecord.is_admin, @@ -1174,7 +1205,6 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => { }) .where(eq(users.id, userId)); - // 2FA enabled - toast notification handled by frontend res.json({ message: 'TOTP enabled successfully', backup_codes: backupCodes @@ -1236,7 +1266,6 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => { }) .where(eq(users.id, userId)); - // 2FA disabled - toast notification handled by frontend res.json({message: 'TOTP disabled successfully'}); } catch (err) { @@ -1353,7 +1382,6 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => { await db.delete(users).where(eq(users.id, targetUserId)); authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`); - // User deleted - toast notification handled by frontend res.json({message: `User ${username} deleted successfully`}); } catch (err) { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index f14246de..fa96b095 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2,9 +2,9 @@ import express from 'express'; import cors from 'cors'; import {Client as SSHClient} from 'ssh2'; 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 { fileLogger } from '../utils/logger.js'; +import {fileLogger} from '../utils/logger.js'; const app = express(); @@ -47,12 +47,28 @@ function scheduleSessionCleanup(sessionId: string) { } app.post('/ssh/file_manager/ssh/connect', async (req, res) => { - const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body; - - // Connection request received - + const { + sessionId, + hostId, + ip, + port, + username, + password, + sshKey, + keyPassword, + authType, + credentialId, + userId + } = req.body; + 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'}); } @@ -81,13 +97,31 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { authType: credential.authType }; } 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) { - 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) { - 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 = { @@ -146,13 +180,22 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; } 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'}); } } else if (resolvedCredentials.password && resolvedCredentials.password.trim()) { config.password = resolvedCredentials.password; } 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'}); } @@ -168,7 +211,15 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { client.on('error', (err) => { if (responseSent) return; 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}); }); @@ -371,7 +422,11 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => { if (hasError || hasFinished) return; hasFinished = true; 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; hasFinished = true; 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); 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; } @@ -430,14 +492,21 @@ app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => { stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); - } + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({ + message: 'File written successfully', + path: filePath, + toast: {type: 'success', message: `File written: ${filePath}`} + }); + } } else { fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`); 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; hasFinished = true; 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; hasFinished = true; 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 (!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 { fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`); - if (!res.headersSent) { - res.status(500).json({error: `Upload failed: ${errorData}`, toast: {type: 'error', message: `Upload failed: ${errorData}`}}); - } + if (!res.headersSent) { + 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 (!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 { fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); - if (!res.headersSent) { - res.status(500).json({error: `Chunked upload failed: ${errorData}`, toast: {type: 'error', message: `Chunked upload failed: ${errorData}`}}); - } + if (!res.headersSent) { + 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) => { if (outputData.includes('SUCCESS')) { 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; } @@ -748,13 +843,20 @@ app.post('/ssh/file_manager/ssh/createFile', async (req, res) => { if (code !== 0) { fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { - return res.status(500).json({error: `Command failed: ${errorData}`, 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; } 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) => { if (outputData.includes('SUCCESS')) { 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; } @@ -832,13 +938,20 @@ app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => { if (code !== 0) { fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { - return res.status(500).json({error: `Command failed: ${errorData}`, 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; } 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) => { if (outputData.includes('SUCCESS')) { 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; } @@ -915,13 +1032,20 @@ app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => { if (code !== 0) { fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { - return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `Delete failed: ${errorData}`}}); + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: {type: 'error', message: `Delete failed: ${errorData}`} + }); } return; } 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) => { if (outputData.includes('SUCCESS')) { 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; } @@ -1000,13 +1129,21 @@ app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => { if (code !== 0) { fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { - return res.status(500).json({error: `Command failed: ${errorData}`, toast: {type: 'error', message: `Rename failed: ${errorData}`}}); + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: {type: 'error', message: `Rename failed: ${errorData}`} + }); } return; } 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; 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}); }); \ No newline at end of file diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 8abda3ea..4990ac8d 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -1,16 +1,12 @@ import express from 'express'; -import fetch from 'node-fetch'; import net from 'net'; import cors from 'cors'; import {Client, type ConnectConfig} from 'ssh2'; import {db} from '../database/db/index.js'; import {sshData, sshCredentials} from '../database/db/schema.js'; import {eq, and} from 'drizzle-orm'; -import { statsLogger } from '../utils/logger.js'; +import {statsLogger} from '../utils/logger.js'; -// Rate limiting removed - not needed for internal application - -// Connection pooling interface PooledConnection { client: Client; lastUsed: number; @@ -37,7 +33,7 @@ class SSHConnectionPool { async getConnection(host: SSHHostWithCredentials): Promise { const hostKey = this.getHostKey(host); const connections = this.connections.get(hostKey) || []; - + const available = connections.find(conn => !conn.inUse); if (available) { available.inUse = true; @@ -112,7 +108,7 @@ class SSHConnectionPool { private cleanup(): void { const now = Date.now(); - const maxAge = 10 * 60 * 1000; // 10 minutes + const maxAge = 10 * 60 * 1000; for (const [hostKey, connections] of this.connections.entries()) { const activeConnections = connections.filter(conn => { @@ -120,7 +116,7 @@ class SSHConnectionPool { try { conn.client.end(); } catch { - + } return false; } @@ -142,7 +138,7 @@ class SSHConnectionPool { try { conn.client.end(); } catch { - + } } } @@ -150,7 +146,6 @@ class SSHConnectionPool { } } -// Request queuing to prevent race conditions class RequestQueue { private queues = new Map Promise>>(); private processing = new Set(); @@ -173,21 +168,21 @@ class RequestQueue { private async processQueue(hostId: number): Promise { if (this.processing.has(hostId)) return; - + this.processing.add(hostId); const queue = this.queues.get(hostId) || []; - + while (queue.length > 0) { const request = queue.shift(); if (request) { try { await request(); } catch (error) { - + } } } - + this.processing.delete(hostId); if (queue.length > 0) { this.processQueue(hostId); @@ -195,7 +190,6 @@ class RequestQueue { } } -// Metrics caching interface CachedMetrics { data: any; timestamp: number; @@ -204,7 +198,7 @@ interface CachedMetrics { class MetricsCache { private cache = new Map(); - private ttl = 30000; // 30 seconds + private ttl = 30000; get(hostId: number): any | null { const cached = this.cache.get(hostId); @@ -231,7 +225,6 @@ class MetricsCache { } } -// Global instances const connectionPool = new SSHConnectionPool(); const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); @@ -268,13 +261,10 @@ type StatusEntry = { lastChecked: string; }; -// Rate limiting middleware removed - -// Input validation middleware function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) { const id = Number(req.params.id); 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(); } @@ -294,7 +284,7 @@ app.use((req, res, next) => { } next(); }); -app.use(express.json({ limit: '1mb' })); // Add request size limit +app.use(express.json({limit: '1mb'})); const hostStatuses: Map = new Map(); @@ -388,7 +378,7 @@ async function resolveHostCredentials(host: any): Promise { - // Check cache first const cached = metricsCache.get(host.id); if (cached) { return cached; @@ -525,16 +514,14 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ let cpuPercent: number | null = null; let cores: number | null = null; let loadTriplet: [number, number, number] | null = null; - + try { - // Execute all commands in parallel for better performance const [stat1, loadAvgOut, coresOut] = await Promise.all([ execCommand(client, 'cat /proc/stat'), execCommand(client, 'cat /proc/loadavg'), execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo') ]); - // Wait for CPU calculation await new Promise(r => setTimeout(r, 500)); 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}, }; - // Cache the result metricsCache.set(host.id, result); return result; }); @@ -667,7 +653,7 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise async function pollStatusesOnce(): Promise { const hosts = await fetchAllHosts(); 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; } @@ -684,7 +670,12 @@ async function pollStatusesOnce(): Promise { const results = await Promise.allSettled(checks); const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length; const offlineCount = hosts.length - onlineCount; - statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount }); + statsLogger.success('Status polling completed', { + operation: 'status_poll', + totalHosts: hosts.length, + onlineCount, + offlineCount + }); } app.get('/status', async (req, res) => { @@ -726,14 +717,13 @@ app.post('/refresh', async (req, res) => { app.get('/metrics/:id', validateHostId, async (req, res) => { const id = Number(req.params.id); - + try { const host = await fetchHostById(id); if (!host) { return res.status(404).json({error: 'Host not found'}); } - // Check if host is online first const isOnline = await tcpPing(host.ip, host.port, 5000); if (!isOnline) { return res.status(503).json({ @@ -749,8 +739,7 @@ app.get('/metrics/:id', validateHostId, async (req, res) => { res.json({...metrics, lastChecked: new Date().toISOString()}); } catch (err) { statsLogger.error('Failed to collect metrics', err); - - // Return proper error response instead of empty data + if (err instanceof Error && err.message.includes('timeout')) { return res.status(504).json({ error: 'Metrics collection timeout', @@ -760,7 +749,7 @@ app.get('/metrics/:id', validateHostId, async (req, res) => { lastChecked: new Date().toISOString() }); } - + return res.status(500).json({ error: 'Failed to collect metrics', cpu: {percent: null, cores: null, load: null}, @@ -771,7 +760,6 @@ app.get('/metrics/:id', validateHostId, async (req, res) => { } }); -// Graceful shutdown process.on('SIGINT', () => { statsLogger.info('Received SIGINT, shutting down gracefully'); connectionPool.destroy(); @@ -786,10 +774,10 @@ process.on('SIGTERM', () => { const PORT = 8085; 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 { await pollStatusesOnce(); } catch (err) { - statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' }); + statsLogger.error('Initial poll failed', err, {operation: 'initial_poll'}); } }); \ No newline at end of file diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index de0be5c4..51ca5d81 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,35 +1,32 @@ import {WebSocketServer, WebSocket, type RawData} from 'ws'; import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; 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 { sshLogger } from '../utils/logger.js'; +import {sshLogger} from '../utils/logger.js'; 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) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; - ws.on('close', () => { cleanupSSH(); }); ws.on('message', (msg: RawData) => { - - let parsed: any; try { parsed = JSON.parse(msg.toString()); } 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'})); return; } @@ -39,8 +36,15 @@ wss.on('connection', (ws: WebSocket) => { switch (type) { case 'connectToHost': handleConnectToHost(data).catch(error => { - sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip }); - ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')})); + sshLogger.error('Failed to connect to host', error, { + operation: 'ssh_connect', + hostId: data.hostConfig?.id, + ip: data.hostConfig?.ip + }); + ws.send(JSON.stringify({ + type: 'error', + message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error') + })); }); break; @@ -69,7 +73,7 @@ wss.on('connection', (ws: WebSocket) => { break; 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; 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'})); return; } 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'})); return; } 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'})); return; } @@ -115,7 +125,13 @@ wss.on('connection', (ws: WebSocket) => { const connectionTimeout = setTimeout(() => { 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'})); cleanupSSH(connectionTimeout); } @@ -142,13 +158,28 @@ wss.on('connection', (ws: WebSocket) => { authType: credential.authType }; } 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) { - 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) { - 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', () => { @@ -161,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => { term: 'xterm-256color' } as PseudoTtyOptions, (err, stream) => { 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})); return; } @@ -177,7 +208,7 @@ wss.on('connection', (ws: WebSocket) => { }); 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})); }); @@ -189,7 +220,14 @@ wss.on('connection', (ws: WebSocket) => { sshConn.on('error', (err: Error) => { 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; if (err.message.includes('No matching key exchange algorithm')) { @@ -219,7 +257,6 @@ wss.on('connection', (ws: WebSocket) => { cleanupSSH(connectionTimeout); }); - const connectConfig: any = { host: ip, port, @@ -359,6 +396,4 @@ wss.on('connection', (ws: WebSocket) => { } }, 60000); } - - }); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 2bb4323f..817ba5dc 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -3,22 +3,18 @@ import cors from 'cors'; import {Client} from 'ssh2'; import {ChildProcess} from 'child_process'; import axios from 'axios'; -import * as net from 'net'; import {db} from '../database/db/index.js'; -import {sshData, sshCredentials} from '../database/db/schema.js'; +import {sshCredentials} from '../database/db/schema.js'; import {eq, and} from 'drizzle-orm'; -import type { - SSHHost, - TunnelConfig, - TunnelConnection, - TunnelStatus, - HostConfig, +import type { + SSHHost, + TunnelConfig, + TunnelStatus, VerificationData, - ConnectionState, ErrorType } from '../../types/index.js'; -import { CONNECTION_STATES } from '../../types/index.js'; -import { tunnelLogger } from '../utils/logger.js'; +import {CONNECTION_STATES} from '../../types/index.js'; +import {tunnelLogger} from '../utils/logger.js'; const app = express(); app.use(cors({ @@ -42,18 +38,6 @@ const retryExhaustedTunnels = new Set(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); - - -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 { if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) { return; @@ -338,19 +322,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, } } -function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): 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 { +function setupPingInterval(tunnelName: string): void { const pingKey = `${tunnelName}_ping`; if (verificationTimers.has(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) { - 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, { connected: false, status: CONNECTION_STATES.FAILED, @@ -440,14 +418,22 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P authMethod: credential.authType }; } 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) { - 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 = { password: tunnelConfig.endpointPassword, sshKey: tunnelConfig.endpointSSHKey, @@ -476,13 +462,22 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P authMethod: credential.authType }; } 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) { tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } 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(); @@ -597,7 +592,7 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P connected: true, status: CONNECTION_STATES.CONNECTED }); - setupPingInterval(tunnelName, tunnelConfig); + setupPingInterval(tunnelName); } }, 2000); @@ -1016,7 +1011,7 @@ async function initializeAutoStartTunnels(): Promise { const PORT = 8083; 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(() => { initializeAutoStartTunnels(); }, 2000); diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 73ef9992..fb60b639 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -100,7 +100,6 @@ class Logger { console.log(this.formatMessage('success', message, context)); } - // Convenience methods for common operations auth(message: string, context?: LogContext): void { this.info(`AUTH: ${message}`, { ...context, operation: 'auth' }); } @@ -144,18 +143,6 @@ class Logger { retry(message: string, context?: LogContext): void { 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'); diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index abf301b3..f48c3f36 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -196,7 +196,6 @@ export const Terminal = forwardRef(function SSHTerminal( function connectToHost(cols: number, rows: number) { const isDev = process.env.NODE_ENV === 'development' && (window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); - const wsUrl = isDev ? 'ws://localhost:8082'