diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ce060c7f..43260916 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -310,7 +310,9 @@ "allowNewAccountRegistration": "Allow new account registration", "missingRequiredFields": "Missing required fields: {{fields}}", "oidcConfigurationUpdated": "OIDC configuration updated successfully!", + "oidcConfigurationDisabled": "OIDC configuration disabled successfully!", "failedToUpdateOidcConfig": "Failed to update OIDC configuration", + "failedToDisableOidcConfig": "Failed to disable OIDC configuration", "enterUsernameToMakeAdmin": "Enter username to make admin", "userIsNowAdmin": "User {{username}} is now an admin", "failedToMakeUserAdmin": "Failed to make user admin", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index d2bb6cdb..41b22c95 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -309,7 +309,9 @@ "allowNewAccountRegistration": "允许新账户注册", "missingRequiredFields": "缺少必填字段:{{fields}}", "oidcConfigurationUpdated": "OIDC 配置更新成功!", + "oidcConfigurationDisabled": "OIDC 配置禁用成功!", "failedToUpdateOidcConfig": "更新 OIDC 配置失败", + "failedToDisableOidcConfig": "禁用 OIDC 配置失败", "enterUsernameToMakeAdmin": "输入用户名以设为管理员", "userIsNowAdmin": "用户 {{username}} 现在是管理员", "failedToMakeUserAdmin": "设为管理员失败", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 40466490..4e820f46 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -4,12 +4,12 @@ import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; import alertRoutes from './routes/alerts.js'; import credentialsRoutes from './routes/credentials.js'; -import chalk from 'chalk'; import cors from 'cors'; import fetch from 'node-fetch'; import fs from 'fs'; import path from 'path'; import 'dotenv/config'; +import { databaseLogger, apiLogger } from '../utils/logger.js'; const app = express(); app.use(cors({ @@ -18,32 +18,6 @@ app.use(cors({ allowedHeaders: ['Content-Type', 'Authorization'] })); -const dbIconSymbol = '🗄️'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; - interface CacheEntry { data: any; timestamp: number; @@ -61,19 +35,23 @@ class GitHubCache { timestamp: now, expiresAt: now + this.CACHE_DURATION }); + databaseLogger.debug(`Cache entry set`, { operation: 'cache_set', key, expiresIn: this.CACHE_DURATION }); } get(key: string): any | null { const entry = this.cache.get(key); if (!entry) { + databaseLogger.debug(`Cache miss`, { operation: 'cache_get', key }); return null; } if (Date.now() > entry.expiresAt) { this.cache.delete(key); + databaseLogger.debug(`Cache entry expired`, { operation: 'cache_get', key, expired: true }); return null; } + databaseLogger.debug(`Cache hit`, { operation: 'cache_get', key, age: Date.now() - entry.timestamp }); return entry.data; } } @@ -105,6 +83,7 @@ interface GitHubRelease { async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise { const cachedData = githubCache.get(cacheKey); if (cachedData) { + databaseLogger.debug(`Using cached GitHub API data`, { operation: 'github_api', endpoint, cached: true }); return { data: cachedData, cached: true, @@ -113,6 +92,7 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise } try { + databaseLogger.info(`Fetching from GitHub API`, { operation: 'github_api', endpoint }); const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { headers: { 'Accept': 'application/vnd.github+json', @@ -126,15 +106,15 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise } const data = await response.json(); - githubCache.set(cacheKey, data); + databaseLogger.success(`GitHub API data fetched successfully`, { operation: 'github_api', endpoint, dataSize: JSON.stringify(data).length }); return { data: data, cached: false }; } catch (error) { - logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error); + databaseLogger.error(`Failed to fetch from GitHub API`, error, { operation: 'github_api', endpoint }); throw error; } } @@ -142,10 +122,12 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise app.use(bodyParser.json()); app.get('/health', (req, res) => { + apiLogger.info(`Health check requested`, { operation: 'health_check' }); res.json({status: 'ok'}); }); app.get('/version', async (req, res) => { + apiLogger.info(`Version check requested`, { operation: 'version_check' }); let localVersion = process.env.VERSION; if (!localVersion) { @@ -153,13 +135,14 @@ app.get('/version', async (req, res) => { const packagePath = path.resolve(process.cwd(), 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); localVersion = packageJson.version; + databaseLogger.debug(`Version read from package.json`, { operation: 'version_check', localVersion }); } catch (error) { - logger.error('Failed to read version from package.json:', error); + databaseLogger.error('Failed to read version from package.json', error, { operation: 'version_check' }); } } if (!localVersion) { - logger.error('No version information available'); + databaseLogger.error('No version information available', undefined, { operation: 'version_check' }); return res.status(404).send('Local Version Not Set'); } @@ -175,11 +158,21 @@ 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 }); return res.status(401).send('Remote Version Not Found'); } + const isUpToDate = localVersion === remoteVersion; + databaseLogger.info(`Version comparison completed`, { + operation: 'version_check', + localVersion, + remoteVersion, + isUpToDate, + cached: releaseData.cached + }); + const response = { - status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update', + status: isUpToDate ? 'up_to_date' : 'requires_update', localVersion: localVersion, version: remoteVersion, latest_release: { @@ -194,7 +187,7 @@ app.get('/version', async (req, res) => { res.json(response); } catch (err) { - logger.error('Version check failed', err); + databaseLogger.error('Version check failed', err, { operation: 'version_check' }); res.status(500).send('Fetch Error'); } }); @@ -205,6 +198,8 @@ 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}`; + apiLogger.info(`RSS releases requested`, { operation: 'rss_releases', page, per_page }); + const releasesData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, cacheKey @@ -240,9 +235,17 @@ app.get('/releases/rss', async (req, res) => { cache_age: releasesData.cache_age }; + databaseLogger.success(`RSS releases generated successfully`, { + operation: 'rss_releases', + itemCount: rssItems.length, + page, + per_page, + cached: releasesData.cached + }); + res.json(response); } catch (error) { - logger.error('Failed to generate RSS format', error) + 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' @@ -257,10 +260,20 @@ app.use('/alerts', alertRoutes); app.use('/credentials', credentialsRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.error('Unhandled error:', err); + apiLogger.error('Unhandled error in request', err, { + operation: 'error_handler', + method: req.method, + url: req.url, + userAgent: req.get('User-Agent') + }); res.status(500).json({error: 'Internal Server Error'}); }); const PORT = 8081; app.listen(PORT, () => { + databaseLogger.success(`Database API server started on port ${PORT}`, { + operation: 'server_start', + port: PORT, + routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] + }); }); \ No newline at end of file diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 4e08b3db..b24d7d02 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1,381 +1,140 @@ import {drizzle} from 'drizzle-orm/better-sqlite3'; import Database from 'better-sqlite3'; import * as schema from './schema.js'; -import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; -import { MigrationManager } from '../migrations/migrator.js'; - -const dbIconSymbol = '🗄️'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; +import { databaseLogger } from '../../utils/logger.js'; const dataDir = process.env.DATA_DIR || './db/data'; const dbDir = path.resolve(dataDir); if (!fs.existsSync(dbDir)) { + databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir }); fs.mkdirSync(dbDir, {recursive: true}); } const dbPath = path.join(dataDir, 'db.sqlite'); +databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath }); const sqlite = new Database(dbPath); sqlite.exec(` - CREATE TABLE IF NOT EXISTS users - ( - id - TEXT - PRIMARY - KEY, - username - TEXT - NOT - NULL, - password_hash - TEXT - NOT - NULL, - is_admin - INTEGER - NOT - NULL - DEFAULT - 0, - - is_oidc - INTEGER - NOT - NULL - DEFAULT - 0, - client_id - TEXT - NOT - NULL, - client_secret - TEXT - NOT - NULL, - issuer_url - TEXT - NOT - NULL, - authorization_url - TEXT - NOT - NULL, - token_url - TEXT - NOT - NULL, - redirect_uri - TEXT, - identifier_path - TEXT - NOT - NULL, - name_path - TEXT - NOT - NULL, - scopes - TEXT - NOT - NULL + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + issuer_url TEXT NOT NULL, + authorization_url TEXT NOT NULL, + token_url TEXT NOT NULL, + redirect_uri TEXT, + identifier_path TEXT NOT NULL, + name_path TEXT NOT NULL, + scopes TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS settings - ( - key - TEXT - PRIMARY - KEY, - value - TEXT - NOT - NULL + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS ssh_data - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - user_id - TEXT - NOT - NULL, - name - TEXT, - ip - TEXT - NOT - NULL, - port - INTEGER - NOT - NULL, - username - TEXT - NOT - NULL, - folder - TEXT, - tags - TEXT, - pin - INTEGER - NOT - NULL - DEFAULT - 0, - auth_type - TEXT - NOT - NULL, - password - TEXT, - key - TEXT, - key_password - TEXT, - key_type - TEXT, - enable_terminal - INTEGER - NOT - NULL - DEFAULT - 1, - enable_tunnel - INTEGER - NOT - NULL - DEFAULT - 1, - tunnel_connections - TEXT, - enable_file_manager - INTEGER - NOT - NULL - DEFAULT - 1, - default_path - TEXT, - created_at - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - updated_at - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - FOREIGN - KEY - ( - user_id - ) REFERENCES users - ( - id - ) - ); + CREATE TABLE IF NOT EXISTS ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + default_path TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); - CREATE TABLE IF NOT EXISTS file_manager_recent - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - user_id - TEXT - NOT - NULL, - host_id - INTEGER - NOT - NULL, - name - TEXT - NOT - NULL, - path - TEXT - NOT - NULL, - last_opened - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - FOREIGN - KEY - ( - user_id - ) REFERENCES users - ( - id - ), - FOREIGN KEY - ( - host_id - ) REFERENCES ssh_data - ( - id - ) - ); + CREATE TABLE IF NOT EXISTS file_manager_recent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); - CREATE TABLE IF NOT EXISTS file_manager_pinned - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - user_id - TEXT - NOT - NULL, - host_id - INTEGER - NOT - NULL, - name - TEXT - NOT - NULL, - path - TEXT - NOT - NULL, - pinned_at - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - FOREIGN - KEY - ( - user_id - ) REFERENCES users - ( - id - ), - FOREIGN KEY - ( - host_id - ) REFERENCES ssh_data - ( - id - ) - ); + CREATE TABLE IF NOT EXISTS file_manager_pinned ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); - CREATE TABLE IF NOT EXISTS file_manager_shortcuts - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - user_id - TEXT - NOT - NULL, - host_id - INTEGER - NOT - NULL, - name - TEXT - NOT - NULL, - path - TEXT - NOT - NULL, - created_at - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - FOREIGN - KEY - ( - user_id - ) REFERENCES users - ( - id - ), - FOREIGN KEY - ( - host_id - ) REFERENCES ssh_data - ( - id - ) - ); + CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); - CREATE TABLE IF NOT EXISTS dismissed_alerts - ( - id - INTEGER - PRIMARY - KEY - AUTOINCREMENT, - user_id - TEXT - NOT - NULL, - alert_id - TEXT - NOT - NULL, - dismissed_at - TEXT - NOT - NULL - DEFAULT - CURRENT_TIMESTAMP, - FOREIGN - KEY - ( - user_id - ) REFERENCES users - ( - id - ) - ); + CREATE TABLE IF NOT EXISTS dismissed_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + alert_id TEXT NOT NULL, + dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + + CREATE TABLE IF NOT EXISTS ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + + CREATE TABLE IF NOT EXISTS ssh_credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id), + FOREIGN KEY (user_id) REFERENCES users (id) + ); `); const addColumnIfNotExists = (table: string, column: string, definition: string) => { @@ -384,16 +143,18 @@ const addColumnIfNotExists = (table: string, column: string, definition: string) FROM ${table} LIMIT 1`).get(); } catch (e) { try { + databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column }); sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); + databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column }); } catch (alterError) { - logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`); + databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError }); } } }; const migrateSchema = () => { - logger.info('Checking for schema updates...'); + databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' }); addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); @@ -405,8 +166,11 @@ const migrateSchema = () => { addColumnIfNotExists('users', 'authorization_url', 'TEXT'); addColumnIfNotExists('users', 'token_url', 'TEXT'); try { + databaseLogger.debug('Attempting to drop redirect_uri column', { operation: 'schema_migration', table: 'users' }); sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run(); + databaseLogger.success('redirect_uri column dropped', { operation: 'schema_migration', table: 'users' }); } catch (e) { + databaseLogger.debug('redirect_uri column does not exist or could not be dropped', { operation: 'schema_migration', table: 'users' }); } addColumnIfNotExists('users', 'identifier_path', 'TEXT'); @@ -433,38 +197,37 @@ const migrateSchema = () => { addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); - - // Add credential_id column for SSH credentials management + addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)'); addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL'); - logger.success('Schema migration completed'); + databaseLogger.success('Schema migration completed', { operation: 'schema_migration' }); }; const initializeDatabase = async () => { migrateSchema(); - // Run new migration system - const migrationManager = new MigrationManager(sqlite); - await migrationManager.runMigrations(); - try { const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); if (!row) { + databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' }); sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); + databaseLogger.success('Default settings initialized', { operation: 'db_init' }); + } else { + databaseLogger.debug('Default settings already exist', { operation: 'db_init' }); } } catch (e) { - logger.warn('Could not initialize default settings'); + databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e }); } }; -// Initialize database (async) initializeDatabase().catch(error => { - logger.error('Failed to initialize database:', error); + databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' }); process.exit(1); }); +databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath }); export const db = drizzle(sqlite, {schema}); \ No newline at end of file diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 6fc5cb54..4b359eaf 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,5 +1,6 @@ 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(), @@ -97,12 +98,12 @@ export const sshCredentials = sqliteTable('ssh_credentials', { description: text('description'), folder: text('folder'), tags: text('tags'), - authType: text('auth_type').notNull(), // 'password' | 'key' + authType: text('auth_type').notNull(), username: text('username').notNull(), - encryptedPassword: text('encrypted_password'), // AES encrypted - encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key - encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase - keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519' + password: text('password'), + key: text('key', {length: 16384}), + keyPassword: text('key_password'), + keyType: text('key_type'), usageCount: integer('usage_count').notNull().default(0), lastUsed: text('last_used'), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/migrations/001-add-credentials-tables.ts b/src/backend/database/migrations/001-add-credentials-tables.ts deleted file mode 100644 index a163856e..00000000 --- a/src/backend/database/migrations/001-add-credentials-tables.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Database } from 'better-sqlite3'; - -export const up = (db: Database) => { - // Create SSH credentials table - db.exec(` - CREATE TABLE IF NOT EXISTS ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL REFERENCES users(id), - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT NOT NULL, - encrypted_password TEXT, - encrypted_key TEXT, - encrypted_key_password TEXT, - key_type TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Create credential usage tracking table - db.exec(` - CREATE TABLE IF NOT EXISTS ssh_credential_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - credential_id INTEGER NOT NULL REFERENCES ssh_credentials(id), - host_id INTEGER NOT NULL REFERENCES ssh_data(id), - user_id TEXT NOT NULL REFERENCES users(id), - used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Add credential_id column to ssh_data table if it doesn't exist - const columns = db.prepare(`PRAGMA table_info(ssh_data)`).all(); - const hasCredentialId = columns.some((col: any) => col.name === 'credential_id'); - - if (!hasCredentialId) { - db.exec(` - ALTER TABLE ssh_data - ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) - `); - } - - // Create indexes for better performance - db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_user_id ON ssh_credentials(user_id)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_folder ON ssh_credentials(folder)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_credential_id ON ssh_credential_usage(credential_id)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_host_id ON ssh_credential_usage(host_id)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_data_credential_id ON ssh_data(credential_id)`); - - console.log('✅ Added SSH credentials management tables'); -}; - -export const down = (db: Database) => { - // Remove credential_id column from ssh_data table - db.exec(` - CREATE TABLE ssh_data_backup AS SELECT - id, user_id, name, ip, port, username, folder, tags, pin, auth_type, - password, key, key_password, key_type, enable_terminal, enable_tunnel, - tunnel_connections, enable_file_manager, default_path, created_at, updated_at - FROM ssh_data - `); - - db.exec(`DROP TABLE ssh_data`); - db.exec(`ALTER TABLE ssh_data_backup RENAME TO ssh_data`); - - // Drop credential tables - db.exec(`DROP TABLE IF EXISTS ssh_credential_usage`); - db.exec(`DROP TABLE IF EXISTS ssh_credentials`); - - console.log('✅ Removed SSH credentials management tables'); -}; \ No newline at end of file diff --git a/src/backend/database/migrations/migrator.ts b/src/backend/database/migrations/migrator.ts deleted file mode 100644 index a37566ee..00000000 --- a/src/backend/database/migrations/migrator.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { Database } from 'better-sqlite3'; -import chalk from 'chalk'; -import { readFileSync, readdirSync } from 'fs'; -import { join } from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const logger = { - info: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.cyan('[MIGRATION]')} ${msg}`); - }, - warn: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.warn(`${timestamp} ${chalk.yellow('[MIGRATION]')} ${msg}`); - }, - error: (msg: string, err?: unknown): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.error(`${timestamp} ${chalk.redBright('[MIGRATION]')} ${msg}`); - if (err) console.error(err); - }, - success: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.greenBright('[MIGRATION]')} ${msg}`); - } -}; - -interface Migration { - id: string; - name: string; - up: (db: Database) => void; - down: (db: Database) => void; -} - -class MigrationManager { - private db: Database; - private migrationsPath: string; - - constructor(db: Database) { - this.db = db; - this.migrationsPath = __dirname; - this.ensureMigrationsTable(); - } - - private ensureMigrationsTable() { - this.db.exec(` - CREATE TABLE IF NOT EXISTS migrations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `); - } - - private getAppliedMigrations(): Set { - const applied = this.db.prepare('SELECT id FROM migrations').all() as { id: string }[]; - return new Set(applied.map(m => m.id)); - } - - private async loadMigration(filename: string): Promise { - try { - const migrationPath = join(this.migrationsPath, filename); - // Convert to file:// URL for Windows compatibility - const migrationUrl = process.platform === 'win32' - ? `file:///${migrationPath.replace(/\\/g, '/')}` - : migrationPath; - const migration = await import(migrationUrl); - - // Extract migration ID and name from filename - const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/); - if (!matches) { - logger.warn(`Skipping invalid migration filename: ${filename}`); - return null; - } - - const [, id, name] = matches; - - return { - id: id.padStart(3, '0'), - name: name.replace(/-/g, ' '), - up: migration.up, - down: migration.down - }; - } catch (error) { - logger.error(`Failed to load migration ${filename}:`, error); - return null; - } - } - - private getMigrationFiles(): string[] { - try { - return readdirSync(this.migrationsPath) - .filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.includes('migrator')) - .sort(); - } catch (error) { - logger.error('Failed to read migrations directory:', error); - return []; - } - } - - async runMigrations(): Promise { - logger.info('Starting database migrations...'); - - const migrationFiles = this.getMigrationFiles(); - if (migrationFiles.length === 0) { - logger.info('No migrations found'); - return; - } - - const appliedMigrations = this.getAppliedMigrations(); - const migrations: Migration[] = []; - - // Load all migrations - for (const filename of migrationFiles) { - const migration = await this.loadMigration(filename); - if (migration) { - migrations.push(migration); - } - } - - // Filter out already applied migrations - const pendingMigrations = migrations.filter(m => !appliedMigrations.has(m.id)); - - if (pendingMigrations.length === 0) { - logger.info('All migrations are already applied'); - return; - } - - logger.info(`Found ${pendingMigrations.length} pending migration(s)`); - - // Run pending migrations in transaction - const transaction = this.db.transaction(() => { - for (const migration of pendingMigrations) { - logger.info(`Applying migration ${migration.id}: ${migration.name}`); - - try { - migration.up(this.db); - - // Record the migration - this.db.prepare(` - INSERT INTO migrations (id, name) - VALUES (?, ?) - `).run(migration.id, migration.name); - - logger.success(`Applied migration ${migration.id}: ${migration.name}`); - } catch (error) { - logger.error(`Failed to apply migration ${migration.id}:`, error); - throw error; - } - } - }); - - try { - transaction(); - logger.success(`Successfully applied ${pendingMigrations.length} migration(s)`); - } catch (error) { - logger.error('Migration transaction failed, rolling back:', error); - throw error; - } - } - - async rollbackMigration(targetId?: string): Promise { - logger.warn('Starting migration rollback...'); - - const appliedMigrations = this.db.prepare(` - SELECT id, name FROM migrations - ORDER BY id DESC - `).all() as { id: string; name: string }[]; - - if (appliedMigrations.length === 0) { - logger.info('No migrations to rollback'); - return; - } - - const migrationsToRollback = targetId - ? appliedMigrations.filter(m => m.id >= targetId) - : [appliedMigrations[0]]; // Only rollback the latest - - const migrationFiles = this.getMigrationFiles(); - const migrations: Migration[] = []; - - // Load migrations that need to be rolled back - for (const filename of migrationFiles) { - const migration = await this.loadMigration(filename); - if (migration && migrationsToRollback.some(m => m.id === migration.id)) { - migrations.push(migration); - } - } - - // Sort in reverse order for rollback - migrations.sort((a, b) => b.id.localeCompare(a.id)); - - const transaction = this.db.transaction(() => { - for (const migration of migrations) { - logger.info(`Rolling back migration ${migration.id}: ${migration.name}`); - - try { - migration.down(this.db); - - // Remove the migration record - this.db.prepare(`DELETE FROM migrations WHERE id = ?`).run(migration.id); - - logger.success(`Rolled back migration ${migration.id}: ${migration.name}`); - } catch (error) { - logger.error(`Failed to rollback migration ${migration.id}:`, error); - throw error; - } - } - }); - - try { - transaction(); - logger.success(`Successfully rolled back ${migrations.length} migration(s)`); - } catch (error) { - logger.error('Rollback transaction failed:', error); - throw error; - } - } - - getMigrationStatus(): { id: string; name: string; applied: boolean }[] { - const migrationFiles = this.getMigrationFiles(); - const appliedMigrations = this.getAppliedMigrations(); - - return migrationFiles.map(filename => { - const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/); - if (!matches) return null; - - const [, id, name] = matches; - const migrationId = id.padStart(3, '0'); - - return { - id: migrationId, - name: name.replace(/-/g, ' '), - applied: appliedMigrations.has(migrationId) - }; - }).filter(Boolean) as { id: string; name: string; applied: boolean }[]; - } - - printStatus(): void { - const status = this.getMigrationStatus(); - - logger.info('Migration Status:'); - console.log(chalk.gray('─'.repeat(60))); - - status.forEach(migration => { - const statusIcon = migration.applied ? chalk.green('✓') : chalk.yellow('○'); - const statusText = migration.applied ? chalk.green('Applied') : chalk.yellow('Pending'); - console.log(`${statusIcon} ${migration.id} - ${migration.name} [${statusText}]`); - }); - - console.log(chalk.gray('─'.repeat(60))); - const appliedCount = status.filter(m => m.applied).length; - console.log(`Total: ${status.length} migrations, ${appliedCount} applied, ${status.length - appliedCount} pending`); - } -} - -export { MigrationManager }; -export type { Migration }; \ No newline at end of file diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index 067be4d7..05301626 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -2,35 +2,10 @@ import express from 'express'; import {db} from '../db/index.js'; import {dismissedAlerts} from '../db/schema.js'; import {eq, and} from 'drizzle-orm'; -import chalk from 'chalk'; import fetch from 'node-fetch'; import type {Request, Response, NextFunction} from 'express'; +import { authLogger } from '../../utils/logger.js'; -const dbIconSymbol = '🚨'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; interface CacheEntry { data: any; @@ -88,9 +63,11 @@ async function fetchAlertsFromGitHub(): Promise { const cacheKey = 'termix_alerts'; const cachedData = alertCache.get(cacheKey); if (cachedData) { + authLogger.info('Returning cached alerts from GitHub', { operation: 'alerts_fetch', cacheKey, alertCount: cachedData.length }); return cachedData; } + authLogger.info('Fetching alerts from GitHub', { operation: 'alerts_fetch', url: `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}` }); try { const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; @@ -102,10 +79,12 @@ async function fetchAlertsFromGitHub(): Promise { }); if (!response.ok) { + 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}`); } const alerts: TermixAlert[] = await response.json() as TermixAlert[]; + authLogger.info('Successfully fetched alerts from GitHub', { operation: 'alerts_fetch', totalAlerts: alerts.length }); const now = new Date(); @@ -115,10 +94,12 @@ async function fetchAlertsFromGitHub(): Promise { return isValid; }); + authLogger.info('Filtered alerts by expiry date', { operation: 'alerts_fetch', totalAlerts: alerts.length, validAlerts: validAlerts.length }); alertCache.set(cacheKey, validAlerts); + authLogger.success('Alerts cached successfully', { operation: 'alerts_fetch', alertCount: validAlerts.length }); return validAlerts; } catch (error) { - logger.error('Failed to fetch alerts from GitHub', error); + authLogger.error('Failed to fetch alerts from GitHub', { operation: 'alerts_fetch', error: error instanceof Error ? error.message : 'Unknown error' }); return []; } } @@ -136,7 +117,7 @@ router.get('/', async (req, res) => { total_count: alerts.length }); } catch (error) { - logger.error('Failed to get alerts', error); + authLogger.error('Failed to get alerts', error); res.status(500).json({error: 'Failed to fetch alerts'}); } }); @@ -168,7 +149,7 @@ router.get('/user/:userId', async (req, res) => { dismissed_count: dismissedAlertIds.size }); } catch (error) { - logger.error('Failed to get user alerts', error); + authLogger.error('Failed to get user alerts', error); res.status(500).json({error: 'Failed to fetch user alerts'}); } }); @@ -180,7 +161,7 @@ router.post('/dismiss', async (req, res) => { const {userId, alertId} = req.body; if (!userId || !alertId) { - logger.warn('Missing userId or alertId in dismiss request'); + authLogger.warn('Missing userId or alertId in dismiss request'); return res.status(400).json({error: 'User ID and Alert ID are required'}); } @@ -193,7 +174,7 @@ router.post('/dismiss', async (req, res) => { )); if (existingDismissal.length > 0) { - logger.warn(`Alert ${alertId} already dismissed by user ${userId}`); + authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); return res.status(409).json({error: 'Alert already dismissed'}); } @@ -202,10 +183,10 @@ router.post('/dismiss', async (req, res) => { alertId }); - logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`); + authLogger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`); res.json({message: 'Alert dismissed successfully'}); } catch (error) { - logger.error('Failed to dismiss alert', error); + authLogger.error('Failed to dismiss alert', error); res.status(500).json({error: 'Failed to dismiss alert'}); } }); @@ -233,7 +214,7 @@ router.get('/dismissed/:userId', async (req, res) => { total_count: dismissedAlertRecords.length }); } catch (error) { - logger.error('Failed to get dismissed alerts', error); + authLogger.error('Failed to get dismissed alerts', error); res.status(500).json({error: 'Failed to fetch dismissed alerts'}); } }); @@ -259,10 +240,10 @@ router.delete('/dismiss', async (req, res) => { return res.status(404).json({error: 'Dismissed alert not found'}); } - logger.success(`Alert ${alertId} undismissed by user ${userId}`); + authLogger.success(`Alert ${alertId} undismissed by user ${userId}`); res.json({message: 'Alert undismissed successfully'}); } catch (error) { - logger.error('Failed to undismiss alert', error); + authLogger.error('Failed to undismiss alert', error); res.status(500).json({error: 'Failed to undismiss alert'}); } }); diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 90160e3e..164dd11a 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1,29 +1,11 @@ import express from 'express'; -import {credentialService} from '../../services/credentials.js'; +import {db} from '../db/index.js'; +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 chalk from 'chalk'; +import { authLogger } from '../../utils/logger.js'; -const credIconSymbol = '🔐'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#0f766e')(`[${credIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - } -}; const router = express.Router(); @@ -40,7 +22,7 @@ function isNonEmptyString(val: any): val is string { function authenticateJWT(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('Bearer ')) { - logger.warn('Missing or invalid Authorization header'); + authLogger.warn('Missing or invalid Authorization header'); return res.status(401).json({error: 'Missing or invalid Authorization header'}); } const token = authHeader.split(' ')[1]; @@ -50,7 +32,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { (req as any).userId = payload.userId; next(); } catch (err) { - logger.warn('Invalid or expired token'); + authLogger.warn('Invalid or expired token'); return res.status(401).json({error: 'Invalid or expired token'}); } } @@ -72,34 +54,56 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { keyType } = req.body; + authLogger.info('Credential creation request received', { operation: 'credential_create', userId, name, authType, username }); + if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { - logger.warn('Invalid credential creation data'); + 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)) { - logger.warn('Invalid auth type'); + 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 { - const credential = await credentialService.createCredential(userId, { - name, - description, - folder, - tags, - authType, - username, - password, - key, - keyPassword, - keyType - }); + if (authType === 'password' && !password) { + 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 }); + return res.status(400).json({error: 'SSH key is required for key authentication'}); + } - logger.success(`Created credential: ${name}`); - res.status(201).json(credential); + authLogger.info('Preparing credential data for database insertion', { operation: 'credential_create', userId, name, authType, hasPassword: !!password, hasKey: !!key }); + const plainPassword = (authType === 'password' && password) ? password : null; + const plainKey = (authType === 'key' && key) ? key : null; + const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null; + + const credentialData = { + userId, + name: name.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), + authType, + username: username.trim(), + password: plainPassword, + key: plainKey, + keyPassword: plainKeyPassword, + keyType: keyType || null, + usageCount: 0, + lastUsed: null, + }; + + authLogger.info('Inserting credential into database', { operation: 'credential_create', userId, name, authType, username }); + const result = await db.insert(sshCredentials).values(credentialData).returning(); + const created = result[0]; + authLogger.success('Credential created successfully', { operation: 'credential_create', userId, name, credentialId: created.id, authType, username }); + res.status(201).json(formatCredentialOutput(created)); } catch (err) { - logger.error('Failed to create credential', err); + 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' }); @@ -110,61 +114,97 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { // GET /credentials router.get('/', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - + if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for credential fetch'); + authLogger.warn('Invalid userId for credential fetch'); return res.status(400).json({error: 'Invalid userId'}); } try { - const credentials = await credentialService.getUserCredentials(userId); - res.json(credentials); + const credentials = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)) + .orderBy(desc(sshCredentials.updatedAt)); + + res.json(credentials.map(cred => formatCredentialOutput(cred))); } catch (err) { - logger.error('Failed to fetch credentials', err); + authLogger.error('Failed to fetch credentials', err); res.status(500).json({error: 'Failed to fetch credentials'}); } }); -// Get all unique credential folders for the authenticated user +// Get all unique credential folders for the authenticated user // GET /credentials/folders router.get('/folders', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - + if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for credential folder fetch'); + authLogger.warn('Invalid userId for credential folder fetch'); return res.status(400).json({error: 'Invalid userId'}); } try { - const folders = await credentialService.getCredentialsFolders(userId); + const result = await db + .select({folder: sshCredentials.folder}) + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); + + const folderCounts: Record = {}; + result.forEach(r => { + if (r.folder && r.folder.trim() !== '') { + folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; + } + }); + + const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); res.json(folders); } catch (err) { - logger.error('Failed to fetch credential folders', err); + authLogger.error('Failed to fetch credential folders', err); res.status(500).json({error: 'Failed to fetch credential folders'}); } }); -// Get a specific credential by ID (with decrypted secrets) +// Get a specific credential by ID (with plain text secrets) // GET /credentials/:id router.get('/:id', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; const {id} = req.params; if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid request for credential fetch'); + authLogger.warn('Invalid request for credential fetch'); return res.status(400).json({error: 'Invalid request'}); } try { - const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id)); - - if (!credential) { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId) + )); + + if (credentials.length === 0) { return res.status(404).json({error: 'Credential not found'}); } - res.json(credential); + const credential = credentials[0]; + const output = formatCredentialOutput(credential); + + if (credential.password) { + (output as any).password = credential.password; + } + if (credential.key) { + (output as any).key = credential.key; + } + if (credential.keyPassword) { + (output as any).keyPassword = credential.keyPassword; + } + + res.json(output); } catch (err) { - logger.error('Failed to fetch credential', err); + authLogger.error('Failed to fetch credential', err); res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to fetch credential' }); @@ -179,16 +219,70 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => { const updateData = req.body; if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid request for credential update'); + authLogger.warn('Invalid request for credential update'); return res.status(400).json({error: 'Invalid request'}); } try { - const credential = await credentialService.updateCredential(userId, parseInt(id), updateData); - logger.success(`Updated credential ID ${id}`); - res.json(credential); + const existing = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId) + )); + + if (existing.length === 0) { + return res.status(404).json({error: 'Credential not found'}); + } + + const updateFields: any = {}; + + if (updateData.name !== undefined) updateFields.name = updateData.name.trim(); + if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; + if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; + if (updateData.tags !== undefined) { + updateFields.tags = Array.isArray(updateData.tags) ? updateData.tags.join(',') : (updateData.tags || ''); + } + if (updateData.username !== undefined) updateFields.username = updateData.username.trim(); + if (updateData.authType !== undefined) updateFields.authType = updateData.authType; + if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType; + + if (updateData.password !== undefined) { + updateFields.password = updateData.password || null; + } + if (updateData.key !== undefined) { + updateFields.key = updateData.key || null; + } + if (updateData.keyPassword !== undefined) { + updateFields.keyPassword = updateData.keyPassword || null; + } + + if (Object.keys(updateFields).length === 0) { + const existing = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, parseInt(id))); + + return res.json(formatCredentialOutput(existing[0])); + } + + await db + .update(sshCredentials) + .set(updateFields) + .where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId) + )); + + const updated = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, parseInt(id))); + + res.json(formatCredentialOutput(updated[0])); } catch (err) { - logger.error('Failed to update credential', err); + authLogger.error('Failed to update credential', err); res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to update credential' }); @@ -202,16 +296,52 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { const {id} = req.params; if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid request for credential deletion'); + authLogger.warn('Invalid request for credential deletion'); return res.status(400).json({error: 'Invalid request'}); } try { - await credentialService.deleteCredential(userId, parseInt(id)); - logger.success(`Deleted credential ID ${id}`); + const hostsUsingCredential = await db + .select() + .from(sshData) + .where(and( + eq(sshData.credentialId, parseInt(id)), + eq(sshData.userId, userId) + )); + + if (hostsUsingCredential.length > 0) { + await db + .update(sshData) + .set({ + credentialId: null, + password: null, + key: null, + keyPassword: null, + authType: 'password' + }) + .where(and( + eq(sshData.credentialId, parseInt(id)), + eq(sshData.userId, userId) + )); + } + + await db + .delete(sshCredentialUsage) + .where(and( + eq(sshCredentialUsage.credentialId, parseInt(id)), + eq(sshCredentialUsage.userId, userId) + )); + + await db + .delete(sshCredentials) + .where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId) + )); + res.json({message: 'Credential deleted successfully'}); } catch (err) { - logger.error('Failed to delete credential', err); + authLogger.error('Failed to delete credential', err); res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to delete credential' }); @@ -225,18 +355,62 @@ router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, const {id: credentialId, hostId} = req.params; if (!isNonEmptyString(userId) || !credentialId || !hostId) { - logger.warn('Invalid request for credential application'); + authLogger.warn('Invalid request for credential application'); return res.status(400).json({error: 'Invalid request'}); } try { - const {sshHostService} = await import('../../services/ssh-host.js'); - await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId)); - - logger.success(`Applied credential ${credentialId} to host ${hostId}`); + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, parseInt(credentialId)), + eq(sshCredentials.userId, userId) + )); + + if (credentials.length === 0) { + return res.status(404).json({error: 'Credential not found'}); + } + + const credential = credentials[0]; + + await db + .update(sshData) + .set({ + credentialId: parseInt(credentialId), + username: credential.username, + authType: credential.authType, + password: null, + key: null, + keyPassword: null, + keyType: null, + updatedAt: new Date().toISOString() + }) + .where(and( + eq(sshData.id, parseInt(hostId)), + 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({ + usageCount: sql`${sshCredentials.usageCount} + + 1`, + lastUsed: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(eq(sshCredentials.id, parseInt(credentialId))); res.json({message: 'Credential applied to host successfully'}); } catch (err) { - logger.error('Failed to apply credential to host', err); + authLogger.error('Failed to apply credential to host', err); res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to apply credential to host' }); @@ -250,21 +424,69 @@ router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => const {id: credentialId} = req.params; if (!isNonEmptyString(userId) || !credentialId) { - logger.warn('Invalid request for credential hosts fetch'); + authLogger.warn('Invalid request for credential hosts fetch'); return res.status(400).json({error: 'Invalid request'}); } try { - const {sshHostService} = await import('../../services/ssh-host.js'); - const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId)); - - res.json(hosts); + const hosts = await db + .select() + .from(sshData) + .where(and( + eq(sshData.credentialId, parseInt(credentialId)), + eq(sshData.userId, userId) + )); + + res.json(hosts.map(host => formatSSHHostOutput(host))); } catch (err) { - logger.error('Failed to fetch hosts using credential', err); + authLogger.error('Failed to fetch hosts using credential', err); res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential' }); } }); +function formatCredentialOutput(credential: any): any { + return { + id: credential.id, + name: credential.name, + description: credential.description, + folder: credential.folder, + tags: typeof credential.tags === 'string' + ? (credential.tags ? credential.tags.split(',').filter(Boolean) : []) + : [], + authType: credential.authType, + username: credential.username, + keyType: credential.keyType, + usageCount: credential.usageCount || 0, + lastUsed: credential.lastUsed, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + }; +} + +function formatSSHHostOutput(host: any): any { + return { + id: host.id, + userId: host.userId, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: typeof host.tags === 'string' + ? (host.tags ? host.tags.split(',').filter(Boolean) : []) + : [], + pin: !!host.pin, + authType: host.authType, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], + enableFileManager: !!host.enableFileManager, + defaultPath: host.defaultPath, + createdAt: host.createdAt, + updatedAt: host.updatedAt, + }; +} + export default router; \ No newline at end of file diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 5941b4ab..222cdc15 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -1,72 +1,32 @@ import express from 'express'; import {db} from '../db/index.js'; -import {sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts} from '../db/schema.js'; +import {sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts} from '../db/schema.js'; import {eq, and, desc} from 'drizzle-orm'; -import chalk from 'chalk'; +import type {Request, Response, NextFunction} from 'express'; import jwt from 'jsonwebtoken'; import multer from 'multer'; -import type {Request, Response, NextFunction} from 'express'; - -const dbIconSymbol = '🗄️'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; +import { sshLogger } from '../../utils/logger.js'; const router = express.Router(); -function isNonEmptyString(val: any): val is string { - return typeof val === 'string' && val.trim().length > 0; -} - -function isValidPort(val: any): val is number { - return typeof val === 'number' && val > 0 && val < 65536; -} +const upload = multer({storage: multer.memoryStorage()}); interface JWTPayload { userId: string; - iat?: number; - exp?: number; } -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: 10 * 1024 * 1024, - }, - fileFilter: (req, file, cb) => { - if (file.fieldname === 'key') { - cb(null, true); - } else { - cb(new Error('Invalid file type')); - } - } -}); +function isNonEmptyString(value: any): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidPort(port: any): port is number { + return typeof port === 'number' && port > 0 && port <= 65535; +} function authenticateJWT(req: Request, res: Response, next: NextFunction) { - const authHeader = req.headers['authorization']; + const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - logger.warn('Missing or invalid Authorization header'); + sshLogger.warn('Missing or invalid Authorization header'); return res.status(401).json({error: 'Missing or invalid Authorization header'}); } const token = authHeader.split(' ')[1]; @@ -76,7 +36,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { (req as any).userId = payload.userId; next(); } catch (err) { - logger.warn('Invalid or expired token'); + sshLogger.warn('Invalid or expired token'); return res.status(401).json({error: 'Invalid or expired token'}); } } @@ -89,23 +49,25 @@ function isLocalhost(req: Request) { // Internal-only endpoint for autostart (no JWT) router.get('/db/host/internal', async (req: Request, res: Response) => { if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') { - logger.warn('Unauthorized attempt to access internal SSH host endpoint'); + sshLogger.warn('Unauthorized attempt to access internal SSH host endpoint'); return res.status(403).json({error: 'Forbidden'}); } try { const data = await db.select().from(sshData); - const result = data.map((row: any) => ({ - ...row, - tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], - enableFileManager: !!row.enableFileManager, - })); + const result = data.map((row: any) => { + return { + ...row, + tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], + enableFileManager: !!row.enableFileManager, + }; + }); res.json(result); } catch (err) { - logger.error('Failed to fetch SSH data (internal)', err); + sshLogger.error('Failed to fetch SSH data (internal)', err); res.status(500).json({error: 'Failed to fetch SSH data'}); } }); @@ -113,26 +75,33 @@ router.get('/db/host/internal', async (req: Request, res: Response) => { // Route: Create SSH data (requires JWT) // POST /ssh/host router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { + const userId = (req as any).userId; + sshLogger.info('SSH host creation request received', { operation: 'host_create', userId, contentType: req.headers['content-type'] }); + let hostData: any; if (req.headers['content-type']?.includes('multipart/form-data')) { + sshLogger.info('Processing multipart form data for SSH host creation', { operation: 'host_create', userId }); if (req.body.data) { try { hostData = JSON.parse(req.body.data); + sshLogger.info('Successfully parsed JSON data from multipart request', { operation: 'host_create', userId, hasKey: !!req.file }); } catch (err) { - logger.warn('Invalid JSON data in multipart request'); + sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_create', userId, error: err }); return res.status(400).json({error: 'Invalid JSON data'}); } } else { - logger.warn('Missing data field in multipart request'); + sshLogger.warn('Missing data field in multipart request', { operation: 'host_create', userId }); return res.status(400).json({error: 'Missing data field'}); } if (req.file) { hostData.key = req.file.buffer.toString('utf8'); + sshLogger.info('SSH key file processed from multipart request', { operation: 'host_create', userId, keySize: req.file.size }); } } else { hostData = req.body; + sshLogger.info('Processing JSON data for SSH host creation', { operation: 'host_create', userId }); } const { @@ -156,9 +125,14 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque defaultPath, tunnelConnections } = hostData; - const userId = (req as any).userId; if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { - logger.warn('Invalid SSH data input'); + 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'}); } @@ -166,38 +140,42 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque const sshDataObj: any = { userId: userId, name, - folder, + folder: folder || null, tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), ip, port, username, authType: effectiveAuthType, credentialId: credentialId || null, - pin: !!pin ? 1 : 0, - enableTerminal: !!enableTerminal ? 1 : 0, - enableTunnel: !!enableTunnel ? 1 : 0, + pin: !pin ? 1 : 0, + enableTerminal: !enableTerminal ? 1 : 0, + enableTunnel: !enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, enableFileManager: !!enableFileManager ? 1 : 0, defaultPath: defaultPath || null, }; if (effectiveAuthType === 'password') { - sshDataObj.password = password; + sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; + sshLogger.info('SSH host configured for password authentication', { operation: 'host_create', userId, name, ip, port }); } else if (effectiveAuthType === 'key') { - sshDataObj.key = key; - sshDataObj.keyPassword = keyPassword; + sshDataObj.key = key || null; + sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; + sshLogger.info('SSH host configured for key authentication', { operation: 'host_create', userId, name, ip, port, keyType }); } try { + sshLogger.info('Attempting to save SSH host to database', { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType }); await db.insert(sshData).values(sshDataObj); + sshLogger.success('SSH host created successfully', { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType, enableTerminal, enableTunnel, enableFileManager }); res.json({message: 'SSH data created'}); } catch (err) { - logger.error('Failed to save SSH data', err); + 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'}); } }); @@ -205,26 +183,34 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque // Route: Update SSH data (requires JWT) // PUT /ssh/host/:id router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { + const hostId = req.params.id; + const userId = (req as any).userId; + sshLogger.info('SSH host update request received', { operation: 'host_update', hostId: parseInt(hostId), userId, contentType: req.headers['content-type'] }); + let hostData: any; if (req.headers['content-type']?.includes('multipart/form-data')) { + sshLogger.info('Processing multipart form data for SSH host update', { operation: 'host_update', hostId: parseInt(hostId), userId }); if (req.body.data) { try { hostData = JSON.parse(req.body.data); + sshLogger.info('Successfully parsed JSON data from multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, hasKey: !!req.file }); } catch (err) { - logger.warn('Invalid JSON data in multipart request'); + 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 { - logger.warn('Missing data field in multipart request'); + sshLogger.warn('Missing data field in multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId }); return res.status(400).json({error: 'Missing data field'}); } if (req.file) { hostData.key = req.file.buffer.toString('utf8'); + sshLogger.info('SSH key file processed from multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, keySize: req.file.size }); } } else { hostData = req.body; + sshLogger.info('Processing JSON data for SSH host update', { operation: 'host_update', hostId: parseInt(hostId), userId }); } const { @@ -248,10 +234,15 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re defaultPath, tunnelConnections } = hostData; - const {id} = req.params; - const userId = (req as any).userId; - if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) { - logger.warn('Invalid SSH data input for update'); + 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) + }); return res.status(400).json({error: 'Invalid SSH data'}); } @@ -265,11 +256,11 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re username, authType: effectiveAuthType, credentialId: credentialId || null, - pin: !!pin ? 1 : 0, - enableTerminal: !!enableTerminal ? 1 : 0, - enableTunnel: !!enableTunnel ? 1 : 0, + pin: !pin ? 1 : 0, + enableTerminal: !enableTerminal ? 1 : 0, + enableTunnel: !enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, - enableFileManager: !!enableFileManager ? 1 : 0, + enableFileManager: !enableFileManager ? 1 : 0, defaultPath: defaultPath || null, }; @@ -280,26 +271,30 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; + sshLogger.info('SSH host update configured for password authentication', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port }); } else if (effectiveAuthType === 'key') { if (key) { sshDataObj.key = key; } if (keyPassword !== undefined) { - sshDataObj.keyPassword = keyPassword; + sshDataObj.keyPassword = keyPassword || null; } if (keyType) { sshDataObj.keyType = keyType; } sshDataObj.password = null; + sshLogger.info('SSH host update configured for key authentication', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, keyType }); } try { + sshLogger.info('Attempting to update SSH host in database', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType }); await db.update(sshData) .set(sshDataObj) - .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + sshLogger.success('SSH host updated successfully', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType, enableTerminal, enableTunnel, enableFileManager }); res.json({message: 'SSH data updated'}); } catch (err) { - logger.error('Failed to update SSH data', err); + 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'}); } }); @@ -308,27 +303,37 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re // GET /ssh/host router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; + sshLogger.info('SSH hosts fetch request received', { operation: 'host_fetch', userId }); if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for SSH data fetch'); + sshLogger.warn('Invalid userId for SSH data fetch', { operation: 'host_fetch', userId }); return res.status(400).json({error: 'Invalid userId'}); } try { + sshLogger.info('Fetching SSH hosts from database', { operation: 'host_fetch', userId }); const data = await db .select() .from(sshData) .where(eq(sshData.userId, userId)); - const result = data.map((row: any) => ({ - ...row, - tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], - enableFileManager: !!row.enableFileManager, + + sshLogger.info('Processing SSH hosts and resolving credentials', { operation: 'host_fetch', userId, hostCount: data.length }); + const result = await Promise.all(data.map(async (row: any) => { + const baseHost = { + ...row, + tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], + enableFileManager: !!row.enableFileManager, + }; + + return await resolveHostCredentials(baseHost) || baseHost; })); + + sshLogger.success('SSH hosts fetched successfully', { operation: 'host_fetch', userId, hostCount: result.length }); res.json(result); } catch (err) { - logger.error('Failed to fetch SSH data', err); + sshLogger.error('Failed to fetch SSH hosts from database', err, { operation: 'host_fetch', userId }); res.status(500).json({error: 'Failed to fetch SSH data'}); } }); @@ -336,21 +341,23 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { // Route: Get SSH host by ID (requires JWT) // GET /ssh/host/:id router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { - const {id} = req.params; + const hostId = req.params.id; const userId = (req as any).userId; - - if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid request for SSH host fetch'); - return res.status(400).json({error: 'Invalid request'}); + sshLogger.info('SSH host fetch by ID request received', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), 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 }); + return res.status(400).json({error: 'Invalid userId or hostId'}); } - try { + sshLogger.info('Fetching SSH host by ID from database', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); const data = await db .select() .from(sshData) - .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); + .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 }); return res.status(404).json({error: 'SSH host not found'}); } @@ -364,147 +371,12 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], enableFileManager: !!host.enableFileManager, }; - - res.json(result); - } catch (err) { - logger.error('Failed to fetch SSH host', err); - res.status(500).json({error: 'Failed to fetch SSH host'}); - } -}); - -// Route: Get all unique folders for the authenticated user (requires JWT) -// GET /ssh/folders -router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for SSH folder fetch'); - return res.status(400).json({error: 'Invalid userId'}); - } - try { - const data = await db - .select({folder: sshData.folder}) - .from(sshData) - .where(eq(sshData.userId, userId)); - - const folderCounts: Record = {}; - data.forEach(d => { - if (d.folder && d.folder.trim() !== '') { - folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1; - } - }); - - const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); - - res.json(folders); - } catch (err) { - logger.error('Failed to fetch SSH folders', err); - res.status(500).json({error: 'Failed to fetch SSH folders'}); - } -}); - -// Route: Get all folders with usage statistics for the authenticated user (requires JWT) -// GET /ssh/folders/with-stats -router.get('/db/folders/with-stats', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for SSH folder stats fetch'); - return res.status(400).json({error: 'Invalid userId'}); - } - try { - const data = await db - .select({ - folder: sshData.folder, - hostId: sshData.id, - hostName: sshData.name, - hostIp: sshData.ip - }) - .from(sshData) - .where(eq(sshData.userId, userId)); - - const folderStats: Record; - }> = {}; - - data.forEach(d => { - if (d.folder && d.folder.trim() !== '') { - if (!folderStats[d.folder]) { - folderStats[d.folder] = { - name: d.folder, - hostCount: 0, - hosts: [] - }; - } - folderStats[d.folder].hostCount++; - folderStats[d.folder].hosts.push({ - id: d.hostId, - name: d.hostName || undefined, - ip: d.hostIp - }); - } - }); - - const result = Object.values(folderStats).sort((a, b) => a.name.localeCompare(b.name)); - - res.json(result); - } catch (err) { - logger.error('Failed to fetch SSH folder statistics', err); - res.status(500).json({error: 'Failed to fetch SSH folder statistics'}); - } -}); - -// Route: Rename folder across all hosts for the authenticated user (requires JWT) -// PUT /ssh/folders/rename -router.put('/db/folders/rename', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {oldName, newName} = req.body; - - if (!isNonEmptyString(userId) || !isNonEmptyString(oldName) || !isNonEmptyString(newName)) { - logger.warn('Invalid parameters for folder rename'); - return res.status(400).json({error: 'userId, oldName, and newName are required'}); - } - - if (oldName === newName) { - logger.warn('Attempt to rename folder to the same name'); - return res.status(400).json({error: 'New folder name must be different from old name'}); - } - - try { - // Check if the old folder exists - const existingHosts = await db - .select({id: sshData.id}) - .from(sshData) - .where(and( - eq(sshData.userId, userId), - eq(sshData.folder, oldName) - )); - - if (existingHosts.length === 0) { - logger.warn(`Attempt to rename non-existent folder: ${oldName}`); - return res.status(404).json({error: 'Folder not found'}); - } - - // Update all hosts using this folder name - const result = await db - .update(sshData) - .set({folder: newName}) - .where(and( - eq(sshData.userId, userId), - eq(sshData.folder, oldName) - )); - - logger.success(`Renamed folder "${oldName}" to "${newName}" for ${existingHosts.length} hosts`); - res.json({ - message: `Folder renamed successfully`, - oldName, - newName, - affectedHostsCount: existingHosts.length - }); + sshLogger.success('SSH host fetched by ID successfully', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId, hostName: result.name }); + res.json(await resolveHostCredentials(result) || result); } catch (err) { - logger.error('Failed to rename SSH folder', err); - res.status(500).json({error: 'Failed to rename SSH folder'}); + 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'}); } }); @@ -512,17 +384,21 @@ router.put('/db/folders/rename', authenticateJWT, async (req: Request, res: Resp // DELETE /ssh/host/:id router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {id} = req.params; - if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid userId or id for SSH host delete'); + const hostId = req.params.id; + sshLogger.info('SSH host deletion request received', { operation: 'host_delete', hostId: parseInt(hostId), userId }); + + if (!isNonEmptyString(userId) || !hostId) { + 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 { + sshLogger.info('Attempting to delete SSH host from database', { operation: 'host_delete', hostId: parseInt(hostId), userId }); const result = await db.delete(sshData) - .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + sshLogger.success('SSH host deleted successfully', { operation: 'host_delete', hostId: parseInt(hostId), userId }); res.json({message: 'SSH host deleted'}); } catch (err) { - logger.error('Failed to delete SSH host', err); + 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'}); } }); @@ -534,12 +410,12 @@ router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Re const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for recent files fetch'); + sshLogger.warn('Invalid userId for recent files fetch'); return res.status(400).json({error: 'Invalid userId'}); } if (!hostId) { - logger.warn('Host ID is required for recent files fetch'); + sshLogger.warn('Host ID is required for recent files fetch'); return res.status(400).json({error: 'Host ID is required'}); } @@ -547,82 +423,82 @@ router.get('/file_manager/recent', authenticateJWT, async (req: Request, res: Re const recentFiles = await db .select() .from(fileManagerRecent) - .where(and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId) - )) - .orderBy(desc(fileManagerRecent.lastOpened)); + .where(and(eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, hostId))) + .orderBy(desc(fileManagerRecent.lastOpened)) + .limit(20); + res.json(recentFiles); } catch (err) { - logger.error('Failed to fetch recent files', err); + sshLogger.error('Failed to fetch recent files', err); res.status(500).json({error: 'Failed to fetch recent files'}); } }); -// Route: Add file to recent (requires JWT) +// Route: Add recent file (requires JWT) // POST /ssh/file_manager/recent router.post('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - logger.warn('Invalid request for adding recent file'); - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.path, path), - eq(fileManagerRecent.hostId, hostId) - ]; + const { hostId, path, name } = req.body; + if (!isNonEmptyString(userId) || !hostId || !path) { + sshLogger.warn('Invalid data for recent file addition'); + return res.status(400).json({error: 'Invalid data'}); + } + + try { + // Check if file already exists const existing = await db .select() .from(fileManagerRecent) - .where(and(...conditions)); + .where(and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, hostId), + eq(fileManagerRecent.path, path) + )); if (existing.length > 0) { + // Update last opened time await db .update(fileManagerRecent) - .set({lastOpened: new Date().toISOString()}) - .where(and(...conditions)); + .set({ lastOpened: new Date().toISOString() }) + .where(eq(fileManagerRecent.id, existing[0].id)); } else { + // Insert new record await db.insert(fileManagerRecent).values({ userId, hostId, - name, path, + name: name || path.split('/').pop() || 'Unknown', lastOpened: new Date().toISOString() }); } - res.json({message: 'File added to recent'}); + + res.json({message: 'Recent file added'}); } catch (err) { - logger.error('Failed to add recent file', err); + sshLogger.error('Failed to add recent file', err); res.status(500).json({error: 'Failed to add recent file'}); } }); -// Route: Remove file from recent (requires JWT) -// DELETE /ssh/file_manager/recent -router.delete('/file_manager/recent', authenticateJWT, async (req: Request, res: Response) => { +// Route: Remove recent file (requires JWT) +// DELETE /ssh/file_manager/recent/:id +router.delete('/file_manager/recent/:id', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - logger.warn('Invalid request for removing recent file'); - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.path, path), - eq(fileManagerRecent.hostId, hostId) - ]; + const id = req.params.id; - const result = await db + if (!isNonEmptyString(userId) || !id) { + sshLogger.warn('Invalid userId or id for recent file deletion'); + return res.status(400).json({error: 'Invalid userId or id'}); + } + + try { + await db .delete(fileManagerRecent) - .where(and(...conditions)); - res.json({message: 'File removed from recent'}); + .where(and(eq(fileManagerRecent.id, Number(id)), eq(fileManagerRecent.userId, userId))); + + res.json({message: 'Recent file removed'}); } catch (err) { - logger.error('Failed to remove recent file', err); + sshLogger.error('Failed to remove recent file', err); res.status(500).json({error: 'Failed to remove recent file'}); } }); @@ -634,12 +510,12 @@ router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Re const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId for pinned files fetch'); + sshLogger.warn('Invalid userId for pinned files fetch'); return res.status(400).json({error: 'Invalid userId'}); } if (!hostId) { - logger.warn('Host ID is required for pinned files fetch'); + sshLogger.warn('Host ID is required for pinned files fetch'); return res.status(400).json({error: 'Host ID is required'}); } @@ -647,92 +523,93 @@ router.get('/file_manager/pinned', authenticateJWT, async (req: Request, res: Re const pinnedFiles = await db .select() .from(fileManagerPinned) - .where(and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId) - )) - .orderBy(fileManagerPinned.pinnedAt); + .where(and(eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, hostId))) + .orderBy(desc(fileManagerPinned.pinnedAt)); + res.json(pinnedFiles); } catch (err) { - logger.error('Failed to fetch pinned files', err); + sshLogger.error('Failed to fetch pinned files', err); res.status(500).json({error: 'Failed to fetch pinned files'}); } }); -// Route: Add file to pinned (requires JWT) +// Route: Add pinned file (requires JWT) // POST /ssh/file_manager/pinned router.post('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - logger.warn('Invalid request for adding pinned file'); - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.path, path), - eq(fileManagerPinned.hostId, hostId) - ]; + const { hostId, path, name } = req.body; + if (!isNonEmptyString(userId) || !hostId || !path) { + sshLogger.warn('Invalid data for pinned file addition'); + return res.status(400).json({error: 'Invalid data'}); + } + + try { + // Check if file already exists const existing = await db .select() .from(fileManagerPinned) - .where(and(...conditions)); + .where(and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + eq(fileManagerPinned.path, path) + )); - if (existing.length === 0) { - await db.insert(fileManagerPinned).values({ - userId, - hostId, - name, - path, - pinnedAt: new Date().toISOString() - }); + if (existing.length > 0) { + return res.status(409).json({error: 'File already pinned'}); } - res.json({message: 'File pinned successfully'}); + + await db.insert(fileManagerPinned).values({ + userId, + hostId, + path, + name: name || path.split('/').pop() || 'Unknown', + pinnedAt: new Date().toISOString() + }); + + res.json({message: 'File pinned'}); } catch (err) { - logger.error('Failed to pin file', err); + sshLogger.error('Failed to pin file', err); res.status(500).json({error: 'Failed to pin file'}); } }); -// Route: Remove file from pinned (requires JWT) -// DELETE /ssh/file_manager/pinned -router.delete('/file_manager/pinned', authenticateJWT, async (req: Request, res: Response) => { +// Route: Remove pinned file (requires JWT) +// DELETE /ssh/file_manager/pinned/:id +router.delete('/file_manager/pinned/:id', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - logger.warn('Invalid request for removing pinned file'); - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.path, path), - eq(fileManagerPinned.hostId, hostId) - ]; + const id = req.params.id; - const result = await db + if (!isNonEmptyString(userId) || !id) { + sshLogger.warn('Invalid userId or id for pinned file deletion'); + return res.status(400).json({error: 'Invalid userId or id'}); + } + + try { + await db .delete(fileManagerPinned) - .where(and(...conditions)); - res.json({message: 'File unpinned successfully'}); + .where(and(eq(fileManagerPinned.id, Number(id)), eq(fileManagerPinned.userId, userId))); + + res.json({message: 'Pinned file removed'}); } catch (err) { - logger.error('Failed to unpin file', err); - res.status(500).json({error: 'Failed to unpin file'}); + sshLogger.error('Failed to remove pinned file', err); + res.status(500).json({error: 'Failed to remove pinned file'}); } }); -// Route: Get folder shortcuts (requires JWT) +// Route: Get shortcuts (requires JWT) // GET /ssh/file_manager/shortcuts router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; if (!isNonEmptyString(userId)) { + sshLogger.warn('Invalid userId for shortcuts fetch'); return res.status(400).json({error: 'Invalid userId'}); } if (!hostId) { + sshLogger.warn('Host ID is required for shortcuts fetch'); return res.status(400).json({error: 'Host ID is required'}); } @@ -740,292 +617,109 @@ router.get('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: const shortcuts = await db .select() .from(fileManagerShortcuts) - .where(and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId) - )) - .orderBy(fileManagerShortcuts.createdAt); + .where(and(eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, hostId))) + .orderBy(desc(fileManagerShortcuts.createdAt)); + res.json(shortcuts); } catch (err) { - logger.error('Failed to fetch shortcuts', err); + sshLogger.error('Failed to fetch shortcuts', err); res.status(500).json({error: 'Failed to fetch shortcuts'}); } }); -// Route: Add folder shortcut (requires JWT) +// Route: Add shortcut (requires JWT) // POST /ssh/file_manager/shortcuts router.post('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.path, path), - eq(fileManagerShortcuts.hostId, hostId) - ]; + const { hostId, path, name } = req.body; + if (!isNonEmptyString(userId) || !hostId || !path) { + sshLogger.warn('Invalid data for shortcut addition'); + return res.status(400).json({error: 'Invalid data'}); + } + + try { + // Check if shortcut already exists const existing = await db .select() .from(fileManagerShortcuts) - .where(and(...conditions)); + .where(and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + eq(fileManagerShortcuts.path, path) + )); - if (existing.length === 0) { - await db.insert(fileManagerShortcuts).values({ - userId, - hostId, - name, - path, - createdAt: new Date().toISOString() - }); + if (existing.length > 0) { + return res.status(409).json({error: 'Shortcut already exists'}); } - res.json({message: 'Shortcut added successfully'}); + + await db.insert(fileManagerShortcuts).values({ + userId, + hostId, + path, + name: name || path.split('/').pop() || 'Unknown', + createdAt: new Date().toISOString() + }); + + res.json({message: 'Shortcut added'}); } catch (err) { - logger.error('Failed to add shortcut', err); + sshLogger.error('Failed to add shortcut', err); res.status(500).json({error: 'Failed to add shortcut'}); } }); -// Route: Remove folder shortcut (requires JWT) -// DELETE /ssh/file_manager/shortcuts -router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, res: Response) => { +// Route: Remove shortcut (requires JWT) +// DELETE /ssh/file_manager/shortcuts/:id +router.delete('/file_manager/shortcuts/:id', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; - const {name, path, hostId} = req.body; - if (!isNonEmptyString(userId) || !name || !path || !hostId) { - return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); - } - try { - const conditions = [ - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.path, path), - eq(fileManagerShortcuts.hostId, hostId) - ]; + const id = req.params.id; - const result = await db + if (!isNonEmptyString(userId) || !id) { + sshLogger.warn('Invalid userId or id for shortcut deletion'); + return res.status(400).json({error: 'Invalid userId or id'}); + } + + try { + await db .delete(fileManagerShortcuts) - .where(and(...conditions)); - res.json({message: 'Shortcut removed successfully'}); + .where(and(eq(fileManagerShortcuts.id, Number(id)), eq(fileManagerShortcuts.userId, userId))); + + res.json({message: 'Shortcut removed'}); } catch (err) { - logger.error('Failed to remove shortcut', err); + sshLogger.error('Failed to remove shortcut', err); res.status(500).json({error: 'Failed to remove shortcut'}); } }); -// Route: Get SSH host by ID with resolved credentials (requires JWT) -// GET /ssh/host/:id/with-credentials -router.get('/db/host/:id/with-credentials', authenticateJWT, async (req: Request, res: Response) => { - const {id} = req.params; - const userId = (req as any).userId; - - if (!isNonEmptyString(userId) || !id) { - logger.warn('Invalid request for SSH host with credentials fetch'); - return res.status(400).json({error: 'Invalid request'}); - } - +async function resolveHostCredentials(host: any): Promise { try { - const {sshHostService} = await import('../../services/ssh-host.js'); - const host = await sshHostService.getHostWithCredentials(userId, parseInt(id)); + if (host.credentialId && host.userId) { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId) + )); - if (!host) { - return res.status(404).json({error: 'SSH host not found'}); + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + username: credential.username, + authType: credential.authType, + password: credential.password, + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType + }; + } } - - res.json(host); - } catch (err) { - logger.error('Failed to fetch SSH host with credentials', err); - res.status(500).json({error: 'Failed to fetch SSH host with credentials'}); + return host; + } catch (error) { + sshLogger.warn(`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + return host; } -}); +} -// Route: Apply credential to SSH host (requires JWT) -// POST /ssh/host/:id/apply-credential -router.post('/db/host/:id/apply-credential', authenticateJWT, async (req: Request, res: Response) => { - const {id: hostId} = req.params; - const {credentialId} = req.body; - const userId = (req as any).userId; - - if (!isNonEmptyString(userId) || !hostId || !credentialId) { - logger.warn('Invalid request for applying credential to host'); - return res.status(400).json({error: 'Host ID and credential ID are required'}); - } - - try { - const {sshHostService} = await import('../../services/ssh-host.js'); - await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId)); - - res.json({message: 'Credential applied to host successfully'}); - } catch (err) { - logger.error('Failed to apply credential to host', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to apply credential to host' - }); - } -}); - -// Route: Remove credential from SSH host (requires JWT) -// DELETE /ssh/host/:id/credential -router.delete('/db/host/:id/credential', authenticateJWT, async (req: Request, res: Response) => { - const {id: hostId} = req.params; - const userId = (req as any).userId; - - if (!isNonEmptyString(userId) || !hostId) { - logger.warn('Invalid request for removing credential from host'); - return res.status(400).json({error: 'Invalid request'}); - } - - try { - const {sshHostService} = await import('../../services/ssh-host.js'); - await sshHostService.removeCredentialFromHost(userId, parseInt(hostId)); - - res.json({message: 'Credential removed from host successfully'}); - } catch (err) { - logger.error('Failed to remove credential from host', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to remove credential from host' - }); - } -}); - -// Route: Migrate host to managed credential (requires JWT) -// POST /ssh/host/:id/migrate-to-credential -router.post('/db/host/:id/migrate-to-credential', authenticateJWT, async (req: Request, res: Response) => { - const {id: hostId} = req.params; - const {credentialName} = req.body; - const userId = (req as any).userId; - - if (!isNonEmptyString(userId) || !hostId || !credentialName) { - logger.warn('Invalid request for migrating host to credential'); - return res.status(400).json({error: 'Host ID and credential name are required'}); - } - - try { - const {sshHostService} = await import('../../services/ssh-host.js'); - const credentialId = await sshHostService.migrateHostToCredential(userId, parseInt(hostId), credentialName); - - res.json({ - message: 'Host migrated to managed credential successfully', - credentialId - }); - } catch (err) { - logger.error('Failed to migrate host to credential', err); - res.status(500).json({ - error: err instanceof Error ? err.message : 'Failed to migrate host to credential' - }); - } -}); - -// Route: Bulk import SSH hosts from JSON (requires JWT) -// POST /ssh/bulk-import -router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {hosts} = req.body; - - if (!Array.isArray(hosts) || hosts.length === 0) { - logger.warn('Invalid bulk import data - 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) { - logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); - return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); - } - - const results = { - success: 0, - failed: 0, - errors: [] as string[] - }; - - for (let i = 0; i < hosts.length; i++) { - const hostData = hosts[i]; - - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); - continue; - } - - if (hostData.authType !== 'password' && hostData.authType !== 'key') { - results.failed++; - results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); - continue; - } - - if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Password required for password authentication`); - continue; - } - - if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { - results.failed++; - results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); - continue; - } - - if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { - for (let j = 0; j < hostData.tunnelConnections.length; j++) { - const conn = hostData.tunnelConnections[j]; - if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { - results.failed++; - results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); - break; - } - } - } - - const sshDataObj: any = { - userId: userId, - name: hostData.name || '', - folder: hostData.folder || '', - tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), - ip: hostData.ip, - port: hostData.port, - username: hostData.username, - authType: hostData.authType, - pin: !!hostData.pin ? 1 : 0, - enableTerminal: !!hostData.enableTerminal ? 1 : 0, - enableTunnel: !!hostData.enableTunnel ? 1 : 0, - tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, - enableFileManager: !!hostData.enableFileManager ? 1 : 0, - defaultPath: hostData.defaultPath || null, - }; - - if (hostData.authType === 'password') { - sshDataObj.password = hostData.password; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (hostData.authType === 'key') { - sshDataObj.key = hostData.key; - sshDataObj.keyPassword = hostData.keyPassword || null; - sshDataObj.keyType = hostData.keyType || null; - sshDataObj.password = null; - } - - await db.insert(sshData).values(sshDataObj); - results.success++; - - } catch (err) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); - logger.error(`Failed to import host ${i + 1}:`, err); - } - } - - if (results.success > 0) { - logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); - } else { - logger.warn(`Bulk import failed: ${results.failed} failed`); - } - - res.json({ - message: `Import completed: ${results.success} successful, ${results.failed} failed`, - ...results - }); -}); - -export default router; \ No newline at end of file +export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 2c2e9100..1a9f4f47 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -2,13 +2,13 @@ import express from 'express'; import {db} from '../db/index.js'; import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js'; import {eq, and} from 'drizzle-orm'; -import chalk from 'chalk'; import bcrypt from 'bcryptjs'; import {nanoid} from 'nanoid'; 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'; async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { try { @@ -36,7 +36,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str } } } catch (discoveryError) { - logger.error(`OIDC discovery failed: ${discoveryError}`); + authLogger.error(`OIDC discovery failed: ${discoveryError}`); } let jwks: any = null; @@ -52,13 +52,13 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str jwksUrl = url; break; } else { - logger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`); + authLogger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`); } } else { - logger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`); + authLogger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`); } } catch (error) { - logger.error(`JWKS fetch error from ${url}:`, error); + authLogger.error(`JWKS fetch error from ${url}:`, error); continue; } } @@ -89,36 +89,11 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str return payload; } catch (error) { - logger.error('OIDC token verification failed:', error); + authLogger.error('OIDC token verification failed:', error); throw error; } } -const dbIconSymbol = '🗄️'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; const router = express.Router(); @@ -136,7 +111,7 @@ interface JWTPayload { function authenticateJWT(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('Bearer ')) { - logger.warn('Missing or invalid Authorization header'); + 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]; @@ -144,9 +119,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { try { const payload = jwt.verify(token, jwtSecret) as JWTPayload; (req as any).userId = payload.userId; + authLogger.debug('JWT authentication successful', { operation: 'auth', userId: payload.userId, method: req.method, url: req.url }); next(); } catch (err) { - logger.warn('Invalid or expired token'); + 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'}); } } @@ -165,7 +141,7 @@ router.post('/create', async (req, res) => { const {username, password} = req.body; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - logger.warn('Invalid user creation attempt - missing username or 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'}); } @@ -175,7 +151,7 @@ router.post('/create', async (req, res) => { .from(users) .where(eq(users.username, username)); if (existing && existing.length > 0) { - logger.warn(`Attempt to create duplicate username: ${username}`); + authLogger.warn(`Attempt to create duplicate username: ${username}`, { operation: 'user_create', username }); return res.status(409).json({error: 'Username already exists'}); } @@ -183,14 +159,19 @@ router.post('/create', async (req, res) => { try { const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); isFirstUser = ((countResult as any)?.count || 0) === 0; + authLogger.info('Checked user count for admin status', { operation: 'user_create', username, isFirstUser }); } catch (e) { isFirstUser = true; + authLogger.warn('Failed to check user count, assuming first user', { operation: 'user_create', username, error: e }); } + authLogger.info('Hashing password for new user', { operation: 'user_create', username, saltRounds: parseInt(process.env.SALT || '10', 10) }); const saltRounds = parseInt(process.env.SALT || '10', 10); const password_hash = await bcrypt.hash(password, saltRounds); const id = nanoid(); + authLogger.info('Generated user ID and hashed password', { operation: 'user_create', username, userId: id }); + authLogger.info('Inserting new user into database', { operation: 'user_create', username, userId: id, isAdmin: isFirstUser }); await db.insert(users).values({ id, username, @@ -210,10 +191,10 @@ router.post('/create', async (req, res) => { totp_backup_codes: null, }); - logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`); + 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}); } catch (err) { - logger.error('Failed to create user', err); + authLogger.error('Failed to create user', err); res.status(500).json({error: 'Failed to create user'}); } }); @@ -240,30 +221,96 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { scopes } = req.body; - if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) || - !isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) || - !isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) || - !isNonEmptyString(name_path)) { + authLogger.info('OIDC config update request received', { + operation: 'oidc_config_update', + userId, + hasClientId: !!client_id, + hasClientSecret: !!client_secret, + hasIssuerUrl: !!issuer_url, + hasAuthUrl: !!authorization_url, + hasTokenUrl: !!token_url, + hasIdentifierPath: !!identifier_path, + hasNamePath: !!name_path, + clientIdValue: `"${client_id}"`, + clientSecretValue: client_secret ? '[REDACTED]' : `"${client_secret}"`, + issuerUrlValue: `"${issuer_url}"`, + authUrlValue: `"${authorization_url}"`, + tokenUrlValue: `"${token_url}"`, + identifierPathValue: `"${identifier_path}"`, + namePathValue: `"${name_path}"`, + scopesValue: `"${scopes}"`, + userinfoUrlValue: `"${userinfo_url}"` + }); + + const isDisableRequest = (!client_id || client_id.trim() === '') && + (!client_secret || client_secret.trim() === '') && + (!issuer_url || issuer_url.trim() === '') && + (!authorization_url || authorization_url.trim() === '') && + (!token_url || token_url.trim() === ''); + + const isEnableRequest = isNonEmptyString(client_id) && isNonEmptyString(client_secret) && + isNonEmptyString(issuer_url) && isNonEmptyString(authorization_url) && + isNonEmptyString(token_url) && isNonEmptyString(identifier_path) && + isNonEmptyString(name_path); + + authLogger.info('OIDC validation results', { + operation: 'oidc_config_update', + userId, + isDisableRequest, + isEnableRequest, + disableChecks: { + clientIdEmpty: !client_id || client_id.trim() === '', + clientSecretEmpty: !client_secret || client_secret.trim() === '', + issuerUrlEmpty: !issuer_url || issuer_url.trim() === '', + authUrlEmpty: !authorization_url || authorization_url.trim() === '', + tokenUrlEmpty: !token_url || token_url.trim() === '' + }, + enableChecks: { + clientIdPresent: isNonEmptyString(client_id), + clientSecretPresent: isNonEmptyString(client_secret), + issuerUrlPresent: isNonEmptyString(issuer_url), + authUrlPresent: isNonEmptyString(authorization_url), + tokenUrlPresent: isNonEmptyString(token_url), + identifierPathPresent: isNonEmptyString(identifier_path), + namePathPresent: isNonEmptyString(name_path) + } + }); + + if (!isDisableRequest && !isEnableRequest) { + authLogger.warn('OIDC validation failed - neither disable nor enable request', { + operation: 'oidc_config_update', + userId, + isDisableRequest, + isEnableRequest + }); return res.status(400).json({error: 'All OIDC configuration fields are required'}); } - const config = { - client_id, - client_secret, - issuer_url, - authorization_url, - token_url, - userinfo_url: userinfo_url || '', - identifier_path, - name_path, - scopes: scopes || 'openid email profile' - }; + if (isDisableRequest) { + // Disable OIDC by removing the configuration + db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); + 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, + issuer_url, + authorization_url, + token_url, + userinfo_url: userinfo_url || '', + identifier_path, + name_path, + scopes: scopes || 'openid email profile' + }; - db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config)); - - res.json({message: 'OIDC configuration updated'}); + 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 }); + res.json({message: 'OIDC configuration updated'}); + } } catch (err) { - logger.error('Failed to update OIDC config', err); + authLogger.error('Failed to update OIDC config', err); res.status(500).json({error: 'Failed to update OIDC config'}); } }); @@ -278,7 +325,7 @@ router.get('/oidc-config', async (req, res) => { } res.json(JSON.parse((row as any).value)); } catch (err) { - logger.error('Failed to get OIDC config', err); + authLogger.error('Failed to get OIDC config', err); res.status(500).json({error: 'Failed to get OIDC config'}); } }); @@ -318,7 +365,7 @@ router.get('/oidc/authorize', async (req, res) => { res.json({auth_url: authUrl.toString(), state, nonce}); } catch (err) { - logger.error('Failed to generate OIDC auth URL', err); + authLogger.error('Failed to generate OIDC auth URL', err); res.status(500).json({error: 'Failed to generate authorization URL'}); } }); @@ -369,7 +416,7 @@ router.get('/oidc/callback', async (req, res) => { }); if (!tokenResponse.ok) { - logger.error('OIDC token exchange failed', await tokenResponse.text()); + authLogger.error('OIDC token exchange failed', await tokenResponse.text()); return res.status(400).json({error: 'Failed to exchange authorization code'}); } @@ -391,7 +438,7 @@ router.get('/oidc/callback', async (req, res) => { } } } catch (discoveryError) { - logger.error(`OIDC discovery failed: ${discoveryError}`); + authLogger.error(`OIDC discovery failed: ${discoveryError}`); } if (config.userinfo_url) { @@ -412,18 +459,18 @@ router.get('/oidc/callback', async (req, res) => { if (tokenData.id_token) { try { userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); - logger.info('Successfully verified ID token and extracted user info'); + authLogger.info('Successfully verified ID token and extracted user info'); } catch (error) { - logger.error('OIDC token verification failed, trying userinfo endpoints', error); + authLogger.error('OIDC token verification failed, trying userinfo endpoints', error); try { const parts = tokenData.id_token.split('.'); if (parts.length === 3) { const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); userInfo = payload; - logger.info('Successfully decoded ID token payload without verification'); + authLogger.info('Successfully decoded ID token payload without verification'); } } catch (decodeError) { - logger.error('Failed to decode ID token payload:', decodeError); + authLogger.error('Failed to decode ID token payload:', decodeError); } } } @@ -441,21 +488,21 @@ router.get('/oidc/callback', async (req, res) => { userInfo = await userInfoResponse.json(); break; } else { - logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`); + authLogger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`); } } catch (error) { - logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); + authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); continue; } } } if (!userInfo) { - logger.error('Failed to get user information from all sources'); - logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); - logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`); - logger.error(`Has id_token: ${!!tokenData.id_token}`); - logger.error(`Has access_token: ${!!tokenData.access_token}`); + authLogger.error('Failed to get user information from all sources'); + authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); + authLogger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`); + authLogger.error(`Has id_token: ${!!tokenData.id_token}`); + authLogger.error(`Has access_token: ${!!tokenData.access_token}`); return res.status(400).json({error: 'Failed to get user information'}); } @@ -477,8 +524,8 @@ router.get('/oidc/callback', async (req, res) => { identifier; if (!identifier) { - logger.error(`Identifier not found at path: ${config.identifier_path}`); - logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`); + authLogger.error(`Identifier not found at path: ${config.identifier_path}`); + authLogger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`); return res.status(400).json({error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(', ')}`}); } @@ -549,7 +596,7 @@ router.get('/oidc/callback', async (req, res) => { res.redirect(redirectUrl.toString()); } catch (err) { - logger.error('OIDC callback failed', err); + authLogger.error('OIDC callback failed', err); let frontendUrl = redirectUri.replace('/users/oidc/callback', ''); @@ -570,7 +617,7 @@ router.post('/login', async (req, res) => { const {username, password} = req.body; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - logger.warn('Invalid traditional login attempt'); + authLogger.warn('Invalid traditional login attempt', { operation: 'user_login', hasUsername: !!username, hasPassword: !!password }); return res.status(400).json({error: 'Invalid username or password'}); } @@ -581,38 +628,45 @@ router.post('/login', async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { - logger.warn(`User not found: ${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 }); return res.status(403).json({error: 'This user uses external authentication'}); } + authLogger.info('Verifying password for user login', { operation: 'user_login', username, userId: userRecord.id }); const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { - logger.warn(`Incorrect password for user: ${username}`); + authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id }); return res.status(401).json({error: 'Incorrect password'}); } + authLogger.info('Password verified, generating JWT token', { operation: 'user_login', username, userId: userRecord.id, totpEnabled: userRecord.totp_enabled }); const jwtSecret = process.env.JWT_SECRET || 'secret'; const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); if (userRecord.totp_enabled) { + authLogger.info('User has TOTP enabled, requiring additional verification', { operation: 'user_login', username, userId: userRecord.id }); + const tempToken = jwt.sign( + {userId: userRecord.id, pending_totp: true}, + jwtSecret, + {expiresIn: '10m'} + ); + authLogger.success('TOTP verification required for login', { operation: 'user_login', username, userId: userRecord.id }); return res.json({ requires_totp: true, - temp_token: jwt.sign( - {userId: userRecord.id, pending_totp: true}, - jwtSecret, - {expiresIn: '10m'} - ) + temp_token: tempToken }); } + authLogger.success('User login successful', { operation: 'user_login', username, userId: userRecord.id, isAdmin: !!userRecord.is_admin }); return res.json({ token, is_admin: !!userRecord.is_admin, @@ -620,7 +674,7 @@ router.post('/login', async (req, res) => { }); } catch (err) { - logger.error('Failed to log in user', err); + authLogger.error('Failed to log in user', err); return res.status(500).json({error: 'Login failed'}); } }); @@ -630,7 +684,7 @@ router.post('/login', async (req, res) => { router.get('/me', authenticateJWT, async (req: Request, res: Response) => { const userId = (req as any).userId; if (!isNonEmptyString(userId)) { - logger.warn('Invalid userId in JWT for /users/me'); + authLogger.warn('Invalid userId in JWT for /users/me'); return res.status(401).json({error: 'Invalid userId'}); } try { @@ -639,7 +693,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { .from(users) .where(eq(users.id, userId)); if (!user || user.length === 0) { - logger.warn(`User not found for /users/me: ${userId}`); + authLogger.warn(`User not found for /users/me: ${userId}`); return res.status(401).json({error: 'User not found'}); } res.json({ @@ -650,7 +704,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { totp_enabled: !!user[0].totp_enabled }); } catch (err) { - logger.error('Failed to get username', err); + authLogger.error('Failed to get username', err); res.status(500).json({error: 'Failed to get username'}); } }); @@ -663,7 +717,7 @@ router.get('/count', async (req, res) => { const count = (countResult as any)?.count || 0; res.json({count}); } catch (err) { - logger.error('Failed to count users', err); + authLogger.error('Failed to count users', err); res.status(500).json({error: 'Failed to count users'}); } }); @@ -675,7 +729,7 @@ router.get('/db-health', async (req, res) => { db.$client.prepare('SELECT 1').get(); res.json({status: 'ok'}); } catch (err) { - logger.error('DB health check failed', err); + authLogger.error('DB health check failed', err); res.status(500).json({error: 'Database not accessible'}); } }); @@ -687,7 +741,7 @@ router.get('/registration-allowed', async (req, res) => { const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); res.json({allowed: row ? (row as any).value === 'true' : true}); } catch (err) { - logger.error('Failed to get registration allowed', err); + authLogger.error('Failed to get registration allowed', err); res.status(500).json({error: 'Failed to get registration allowed'}); } }); @@ -708,7 +762,7 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => { db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false'); res.json({allowed}); } catch (err) { - logger.error('Failed to set registration allowed', err); + authLogger.error('Failed to set registration allowed', err); res.status(500).json({error: 'Failed to set registration allowed'}); } }); @@ -737,7 +791,7 @@ router.delete('/delete-account', authenticateJWT, async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { - logger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`); + authLogger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`); return res.status(401).json({error: 'Incorrect password'}); } @@ -750,11 +804,11 @@ router.delete('/delete-account', authenticateJWT, async (req, res) => { await db.delete(users).where(eq(users.id, userId)); - logger.success(`User account deleted: ${userRecord.username}`); + authLogger.success(`User account deleted: ${userRecord.username}`); res.json({message: 'Account deleted successfully'}); } catch (err) { - logger.error('Failed to delete user account', err); + authLogger.error('Failed to delete user account', err); res.status(500).json({error: 'Failed to delete account'}); } }); @@ -775,7 +829,7 @@ router.post('/initiate-reset', async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { - logger.warn(`Password reset attempted for non-existent user: ${username}`); + authLogger.warn(`Password reset attempted for non-existent user: ${username}`); return res.status(404).json({error: 'User not found'}); } @@ -791,12 +845,12 @@ router.post('/initiate-reset', async (req, res) => { JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()}) ); - logger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`); + authLogger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`); res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'}); } catch (err) { - logger.error('Failed to initiate password reset', err); + authLogger.error('Failed to initiate password reset', err); res.status(500).json({error: 'Failed to initiate password reset'}); } }); @@ -840,7 +894,7 @@ router.post('/verify-reset-code', async (req, res) => { res.json({message: 'Reset code verified', tempToken}); } catch (err) { - logger.error('Failed to verify reset code', err); + authLogger.error('Failed to verify reset code', err); res.status(500).json({error: 'Failed to verify reset code'}); } }); @@ -884,11 +938,11 @@ router.post('/complete-reset', async (req, res) => { db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); - logger.success(`Password successfully reset for user: ${username}`); + authLogger.success(`Password successfully reset for user: ${username}`); res.json({message: 'Password has been successfully reset'}); } catch (err) { - logger.error('Failed to complete password reset', err); + authLogger.error('Failed to complete password reset', err); res.status(500).json({error: 'Failed to complete password reset'}); } }); @@ -912,7 +966,7 @@ router.get('/list', authenticateJWT, async (req, res) => { res.json({users: allUsers}); } catch (err) { - logger.error('Failed to list users', err); + authLogger.error('Failed to list users', err); res.status(500).json({error: 'Failed to list users'}); } }); @@ -946,11 +1000,11 @@ router.post('/make-admin', authenticateJWT, async (req, res) => { .set({is_admin: true}) .where(eq(users.username, username)); - logger.success(`User ${username} made admin by ${adminUser[0].username}`); + authLogger.success(`User ${username} made admin by ${adminUser[0].username}`); res.json({message: `User ${username} is now an admin`}); } catch (err) { - logger.error('Failed to make user admin', err); + authLogger.error('Failed to make user admin', err); res.status(500).json({error: 'Failed to make user admin'}); } }); @@ -988,11 +1042,11 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => { .set({is_admin: false}) .where(eq(users.username, username)); - logger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); + authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); res.json({message: `Admin status removed from ${username}`}); } catch (err) { - logger.error('Failed to remove admin status', err); + authLogger.error('Failed to remove admin status', err); res.status(500).json({error: 'Failed to remove admin status'}); } }); @@ -1057,7 +1111,7 @@ router.post('/totp/verify-login', async (req, res) => { }); } catch (err) { - logger.error('TOTP verification failed', err); + authLogger.error('TOTP verification failed', err); return res.status(500).json({error: 'TOTP verification failed'}); } }); @@ -1096,7 +1150,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => { }); } catch (err) { - logger.error('Failed to setup TOTP', err); + authLogger.error('Failed to setup TOTP', err); res.status(500).json({error: 'Failed to setup TOTP'}); } }); @@ -1155,7 +1209,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => { }); } catch (err) { - logger.error('Failed to enable TOTP', err); + authLogger.error('Failed to enable TOTP', err); res.status(500).json({error: 'Failed to enable TOTP'}); } }); @@ -1213,7 +1267,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => { res.json({message: 'TOTP disabled successfully'}); } catch (err) { - logger.error('Failed to disable TOTP', err); + authLogger.error('Failed to disable TOTP', err); res.status(500).json({error: 'Failed to disable TOTP'}); } }); @@ -1271,7 +1325,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => { res.json({backup_codes: backupCodes}); } catch (err) { - logger.error('Failed to generate backup codes', err); + authLogger.error('Failed to generate backup codes', err); res.status(500).json({error: 'Failed to generate backup codes'}); } }); @@ -1318,20 +1372,18 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => { await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId)); await db.delete(sshData).where(eq(sshData.userId, targetUserId)); - // Note: All user-related data has been deleted above - // The tables config_editor_* and shared_hosts don't exist in the current schema } catch (cleanupError) { - logger.error(`Cleanup failed for user ${username}:`, cleanupError); + authLogger.error(`Cleanup failed for user ${username}:`, cleanupError); throw cleanupError; } await db.delete(users).where(eq(users.id, targetUserId)); - logger.success(`User ${username} deleted by admin ${adminUser[0].username}`); + authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`); res.json({message: `User ${username} deleted successfully`}); } catch (err) { - logger.error('Failed to delete user', err); + authLogger.error('Failed to delete user', err); if (err && typeof err === 'object' && 'code' in err) { if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { diff --git a/src/backend/services/credentials.ts b/src/backend/services/credentials.ts deleted file mode 100644 index 2b3b79ae..00000000 --- a/src/backend/services/credentials.ts +++ /dev/null @@ -1,370 +0,0 @@ -import {db} from '../database/db/index.js'; -import {sshCredentials, sshCredentialUsage, sshData} from '../database/db/schema.js'; -import {eq, and, desc, sql} from 'drizzle-orm'; -import {encryptionService} from './encryption.js'; -import chalk from 'chalk'; - -const logger = { - info: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`); - }, - warn: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`); - }, - error: (msg: string, err?: unknown): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`); - if (err) console.error(err); - }, - success: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`); - } -}; - -export interface CredentialInput { - name: string; - description?: string; - folder?: string; - tags?: string[]; - authType: 'password' | 'key'; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; -} - -export interface CredentialOutput { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: 'password' | 'key'; - username: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; -} - -export interface CredentialWithSecrets extends CredentialOutput { - password?: string; - key?: string; - keyPassword?: string; -} - -class CredentialService { - /** - * Create a new credential - */ - async createCredential(userId: string, input: CredentialInput): Promise { - try { - // Validate input - if (!input.name?.trim()) { - throw new Error('Credential name is required'); - } - if (!input.username?.trim()) { - throw new Error('Username is required'); - } - if (!['password', 'key'].includes(input.authType)) { - throw new Error('Invalid auth type'); - } - if (input.authType === 'password' && !input.password) { - throw new Error('Password is required for password authentication'); - } - if (input.authType === 'key' && !input.key) { - throw new Error('SSH key is required for key authentication'); - } - - // Encrypt sensitive data - let encryptedPassword: string | null = null; - let encryptedKey: string | null = null; - let encryptedKeyPassword: string | null = null; - - if (input.authType === 'password' && input.password) { - encryptedPassword = encryptionService.encryptToString(input.password); - } else if (input.authType === 'key') { - if (input.key) { - encryptedKey = encryptionService.encryptToString(input.key); - } - if (input.keyPassword) { - encryptedKeyPassword = encryptionService.encryptToString(input.keyPassword); - } - } - - const credentialData = { - userId, - name: input.name.trim(), - description: input.description?.trim() || null, - folder: input.folder?.trim() || null, - tags: Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''), - authType: input.authType, - username: input.username.trim(), - encryptedPassword, - encryptedKey, - encryptedKeyPassword, - keyType: input.keyType || null, - usageCount: 0, - lastUsed: null, - }; - - const result = await db.insert(sshCredentials).values(credentialData).returning(); - const created = result[0]; - - logger.success(`Created credential "${input.name}" (ID: ${created.id})`); - - return this.formatCredentialOutput(created); - } catch (error) { - logger.error('Failed to create credential', error); - throw error; - } - } - - /** - * Get all credentials for a user - */ - async getUserCredentials(userId: string): Promise { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .orderBy(desc(sshCredentials.updatedAt)); - - return credentials.map(cred => this.formatCredentialOutput(cred)); - } catch (error) { - logger.error('Failed to fetch user credentials', error); - throw error; - } - } - - /** - * Get a credential by ID with decrypted secrets - */ - async getCredentialWithSecrets(userId: string, credentialId: number): Promise { - try { - const credentials = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId) - )); - - if (credentials.length === 0) { - return null; - } - - const credential = credentials[0]; - const output: CredentialWithSecrets = { - ...this.formatCredentialOutput(credential) - }; - - // Decrypt sensitive data - try { - if (credential.encryptedPassword) { - output.password = encryptionService.decryptFromString(credential.encryptedPassword); - } - if (credential.encryptedKey) { - output.key = encryptionService.decryptFromString(credential.encryptedKey); - } - if (credential.encryptedKeyPassword) { - output.keyPassword = encryptionService.decryptFromString(credential.encryptedKeyPassword); - } - } catch (decryptError) { - logger.error(`Failed to decrypt credential ${credentialId}`, decryptError); - throw new Error('Failed to decrypt credential data'); - } - - return output; - } catch (error) { - logger.error('Failed to get credential with secrets', error); - throw error; - } - } - - /** - * Update a credential - */ - async updateCredential(userId: string, credentialId: number, input: Partial): Promise { - try { - // Check if credential exists and belongs to user - const existing = await db - .select() - .from(sshCredentials) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId) - )); - - if (existing.length === 0) { - throw new Error('Credential not found'); - } - - const updateData: any = { - updatedAt: new Date().toISOString() - }; - - if (input.name !== undefined) updateData.name = input.name.trim(); - if (input.description !== undefined) updateData.description = input.description?.trim() || null; - if (input.folder !== undefined) updateData.folder = input.folder?.trim() || null; - if (input.tags !== undefined) { - updateData.tags = Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''); - } - if (input.username !== undefined) updateData.username = input.username.trim(); - if (input.authType !== undefined) updateData.authType = input.authType; - if (input.keyType !== undefined) updateData.keyType = input.keyType; - - // Handle sensitive data updates - if (input.password !== undefined) { - updateData.encryptedPassword = input.password ? encryptionService.encryptToString(input.password) : null; - } - if (input.key !== undefined) { - updateData.encryptedKey = input.key ? encryptionService.encryptToString(input.key) : null; - } - if (input.keyPassword !== undefined) { - updateData.encryptedKeyPassword = input.keyPassword ? encryptionService.encryptToString(input.keyPassword) : null; - } - - await db - .update(sshCredentials) - .set(updateData) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId) - )); - - // Fetch updated credential - const updated = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, credentialId)); - - logger.success(`Updated credential ID ${credentialId}`); - - return this.formatCredentialOutput(updated[0]); - } catch (error) { - logger.error('Failed to update credential', error); - throw error; - } - } - - /** - * Delete a credential - */ - async deleteCredential(userId: string, credentialId: number): Promise { - try { - // Check if credential is in use - const hostsUsingCredential = await db - .select() - .from(sshData) - .where(and( - eq(sshData.credentialId, credentialId), - eq(sshData.userId, userId) - )); - - if (hostsUsingCredential.length > 0) { - throw new Error(`Cannot delete credential: it is currently used by ${hostsUsingCredential.length} host(s)`); - } - - // Delete usage records - await db - .delete(sshCredentialUsage) - .where(and( - eq(sshCredentialUsage.credentialId, credentialId), - eq(sshCredentialUsage.userId, userId) - )); - - // Delete credential - const result = await db - .delete(sshCredentials) - .where(and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId) - )); - - logger.success(`Deleted credential ID ${credentialId}`); - } catch (error) { - logger.error('Failed to delete credential', error); - throw error; - } - } - - /** - * Record credential usage - */ - async recordUsage(userId: string, credentialId: number, hostId: number): Promise { - try { - // Record usage - await db.insert(sshCredentialUsage).values({ - credentialId, - hostId, - userId, - }); - - // Update credential usage stats - await db - .update(sshCredentials) - .set({ - usageCount: sql`${sshCredentials.usageCount} + 1`, - lastUsed: new Date().toISOString(), - updatedAt: new Date().toISOString() - }) - .where(eq(sshCredentials.id, credentialId)); - - } catch (error) { - logger.error('Failed to record credential usage', error); - // Don't throw - this is not critical - } - } - - /** - * Get credentials grouped by folder - */ - async getCredentialsFolders(userId: string): Promise { - try { - const result = await db - .select({folder: sshCredentials.folder}) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); - - const folderCounts: Record = {}; - result.forEach(r => { - if (r.folder && r.folder.trim() !== '') { - folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; - } - }); - - return Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); - } catch (error) { - logger.error('Failed to get credential folders', error); - throw error; - } - } - - private formatCredentialOutput(credential: any): CredentialOutput { - return { - id: credential.id, - name: credential.name, - description: credential.description, - folder: credential.folder, - tags: typeof credential.tags === 'string' - ? (credential.tags ? credential.tags.split(',').filter(Boolean) : []) - : [], - authType: credential.authType, - username: credential.username, - keyType: credential.keyType, - usageCount: credential.usageCount || 0, - lastUsed: credential.lastUsed, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - }; - } -} - -export const credentialService = new CredentialService(); \ No newline at end of file diff --git a/src/backend/services/encryption.ts b/src/backend/services/encryption.ts deleted file mode 100644 index 38bcaf66..00000000 --- a/src/backend/services/encryption.ts +++ /dev/null @@ -1,133 +0,0 @@ -import crypto from 'crypto'; -import chalk from 'chalk'; - -const ALGORITHM = 'aes-256-gcm'; -const KEY_LENGTH = 32; // 256 bits -const IV_LENGTH = 16; // 128 bits -const TAG_LENGTH = 16; // 128 bits - -interface EncryptionResult { - encrypted: string; - iv: string; - tag: string; -} - -interface DecryptionInput { - encrypted: string; - iv: string; - tag: string; -} - -class EncryptionService { - private key: Buffer; - - constructor() { - // Get or generate encryption key - const keyEnv = process.env.CREDENTIAL_ENCRYPTION_KEY; - if (keyEnv) { - this.key = Buffer.from(keyEnv, 'hex'); - if (this.key.length !== KEY_LENGTH) { - throw new Error(`Invalid encryption key length. Expected ${KEY_LENGTH} bytes, got ${this.key.length}`); - } - } else { - // Generate a new key - in production, this should be stored securely - this.key = crypto.randomBytes(KEY_LENGTH); - console.warn(chalk.yellow(`[SECURITY] Generated new encryption key. Store this in CREDENTIAL_ENCRYPTION_KEY: ${this.key.toString('hex')}`)); - } - } - - /** - * Encrypt sensitive data - * @param plaintext - The data to encrypt - * @returns Encryption result with encrypted data, IV, and tag - */ - encrypt(plaintext: string): EncryptionResult { - try { - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv); - - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const tag = cipher.getAuthTag(); - - return { - encrypted, - iv: iv.toString('hex'), - tag: tag.toString('hex') - }; - } catch (error) { - throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - /** - * Decrypt sensitive data - * @param input - Encrypted data with IV and tag - * @returns Decrypted plaintext - */ - decrypt(input: DecryptionInput): string { - try { - const iv = Buffer.from(input.iv, 'hex'); - const tag = Buffer.from(input.tag, 'hex'); - const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv); - decipher.setAuthTag(tag); - - let decrypted = decipher.update(input.encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - /** - * Encrypt data and return as single base64-encoded string - * Format: iv:tag:encrypted - */ - encryptToString(plaintext: string): string { - const result = this.encrypt(plaintext); - const combined = `${result.iv}:${result.tag}:${result.encrypted}`; - return Buffer.from(combined).toString('base64'); - } - - /** - * Decrypt data from base64-encoded string - */ - decryptFromString(encryptedString: string): string { - try { - const combined = Buffer.from(encryptedString, 'base64').toString(); - const parts = combined.split(':'); - - if (parts.length !== 3) { - throw new Error('Invalid encrypted string format'); - } - - return this.decrypt({ - iv: parts[0], - tag: parts[1], - encrypted: parts[2] - }); - } catch (error) { - throw new Error(`Failed to decrypt string: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - /** - * Validate that a string can be decrypted (useful for testing) - */ - canDecrypt(encryptedString: string): boolean { - try { - this.decryptFromString(encryptedString); - return true; - } catch { - return false; - } - } -} - -// Singleton instance -export const encryptionService = new EncryptionService(); - -// Types for external use -export type { EncryptionResult, DecryptionInput }; \ No newline at end of file diff --git a/src/backend/services/ssh-host.ts b/src/backend/services/ssh-host.ts deleted file mode 100644 index 95a13c1a..00000000 --- a/src/backend/services/ssh-host.ts +++ /dev/null @@ -1,277 +0,0 @@ -import {db} from '../database/db/index.js'; -import {sshData, sshCredentials} from '../database/db/schema.js'; -import {eq, and} from 'drizzle-orm'; -import {credentialService} from './credentials.js'; -import {encryptionService} from './encryption.js'; -import chalk from 'chalk'; - -const logger = { - info: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`); - }, - warn: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`); - }, - error: (msg: string, err?: unknown): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`); - if (err) console.error(err); - }, - success: (msg: string): void => { - const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`); - console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`); - } -}; - -export interface SSHHostWithCredentials { - id: number; - userId: string; - name?: string; - ip: string; - port: number; - username: string; - folder?: string; - tags: string[]; - pin: boolean; - authType: string; - // Auth data - either from credential or legacy fields - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - credentialName?: string; - // Other fields - enableTerminal: boolean; - enableTunnel: boolean; - tunnelConnections: any[]; - enableFileManager: boolean; - defaultPath?: string; - createdAt: string; - updatedAt: string; -} - -class SSHHostService { - /** - * Get SSH host with resolved credentials - */ - async getHostWithCredentials(userId: string, hostId: number): Promise { - try { - const hosts = await db - .select() - .from(sshData) - .where(and( - eq(sshData.id, hostId), - eq(sshData.userId, userId) - )); - - if (hosts.length === 0) { - return null; - } - - const host = hosts[0]; - return await this.resolveHostCredentials(host); - } catch (error) { - logger.error(`Failed to get host ${hostId} with credentials`, error); - throw error; - } - } - - /** - * Apply a credential to an SSH host - */ - async applyCredentialToHost(userId: string, hostId: number, credentialId: number): Promise { - try { - // Verify credential exists and belongs to user - const credential = await credentialService.getCredentialWithSecrets(userId, credentialId); - if (!credential) { - throw new Error('Credential not found'); - } - - // Update host to reference the credential and clear legacy fields - await db - .update(sshData) - .set({ - credentialId: credentialId, - username: credential.username, - authType: credential.authType, - // Clear legacy credential fields since we're using the credential reference - password: null, - key: null, - keyPassword: null, - keyType: null, - updatedAt: new Date().toISOString() - }) - .where(and( - eq(sshData.id, hostId), - eq(sshData.userId, userId) - )); - - // Record credential usage - await credentialService.recordUsage(userId, credentialId, hostId); - - logger.success(`Applied credential ${credentialId} to host ${hostId}`); - } catch (error) { - logger.error(`Failed to apply credential ${credentialId} to host ${hostId}`, error); - throw error; - } - } - - /** - * Remove credential from host (revert to legacy mode) - */ - async removeCredentialFromHost(userId: string, hostId: number): Promise { - try { - await db - .update(sshData) - .set({ - credentialId: null, - updatedAt: new Date().toISOString() - }) - .where(and( - eq(sshData.id, hostId), - eq(sshData.userId, userId) - )); - - logger.success(`Removed credential reference from host ${hostId}`); - } catch (error) { - logger.error(`Failed to remove credential from host ${hostId}`, error); - throw error; - } - } - - /** - * Get all hosts using a specific credential - */ - async getHostsUsingCredential(userId: string, credentialId: number): Promise { - try { - const hosts = await db - .select() - .from(sshData) - .where(and( - eq(sshData.credentialId, credentialId), - eq(sshData.userId, userId) - )); - - const result: SSHHostWithCredentials[] = []; - for (const host of hosts) { - const resolved = await this.resolveHostCredentials(host); - result.push(resolved); - } - - return result; - } catch (error) { - logger.error(`Failed to get hosts using credential ${credentialId}`, error); - throw error; - } - } - - /** - * Resolve host credentials from either credential reference or legacy fields - */ - private async resolveHostCredentials(host: any): Promise { - const baseHost: SSHHostWithCredentials = { - id: host.id, - userId: host.userId, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: typeof host.tags === 'string' - ? (host.tags ? host.tags.split(',').filter(Boolean) : []) - : [], - pin: !!host.pin, - authType: host.authType, - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], - enableFileManager: !!host.enableFileManager, - defaultPath: host.defaultPath, - createdAt: host.createdAt, - updatedAt: host.updatedAt, - }; - - // If host uses a credential reference, get credentials from there - if (host.credentialId) { - try { - const credential = await credentialService.getCredentialWithSecrets(host.userId, host.credentialId); - if (credential) { - baseHost.credentialId = credential.id; - baseHost.credentialName = credential.name; - baseHost.username = credential.username; - baseHost.authType = credential.authType; - baseHost.password = credential.password; - baseHost.key = credential.key; - baseHost.keyPassword = credential.keyPassword; - baseHost.keyType = credential.keyType; - } else { - logger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); - // Fall back to legacy data - this.addLegacyCredentials(baseHost, host); - } - } catch (error) { - logger.error(`Failed to resolve credential ${host.credentialId} for host ${host.id}`, error); - // Fall back to legacy data - this.addLegacyCredentials(baseHost, host); - } - } else { - // Use legacy credential fields - this.addLegacyCredentials(baseHost, host); - } - - return baseHost; - } - - private addLegacyCredentials(baseHost: SSHHostWithCredentials, host: any): void { - baseHost.password = host.password; - baseHost.key = host.key; - baseHost.keyPassword = host.keyPassword; - baseHost.keyType = host.keyType; - } - - /** - * Migrate a host from legacy credentials to a managed credential - */ - async migrateHostToCredential(userId: string, hostId: number, credentialName: string): Promise { - try { - const host = await this.getHostWithCredentials(userId, hostId); - if (!host) { - throw new Error('Host not found'); - } - - if (host.credentialId) { - throw new Error('Host already uses managed credentials'); - } - - // Create a new credential from the host's legacy data - const credentialData = { - name: credentialName, - description: `Migrated from host ${host.name || host.ip}`, - folder: host.folder, - tags: host.tags, - authType: host.authType as 'password' | 'key', - username: host.username, - password: host.password, - key: host.key, - keyPassword: host.keyPassword, - keyType: host.keyType, - }; - - const credential = await credentialService.createCredential(userId, credentialData); - - // Apply the new credential to the host - await this.applyCredentialToHost(userId, hostId, credential.id); - - logger.success(`Migrated host ${hostId} to managed credential ${credential.id}`); - return credential.id; - } catch (error) { - logger.error(`Failed to migrate host ${hostId} to credential`, error); - throw error; - } - } -} - -export const sshHostService = new SSHHostService(); \ No newline at end of file diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index ee8bcc9e..886c6722 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1,7 +1,10 @@ import express from 'express'; import cors from 'cors'; import {Client as SSHClient} from 'ssh2'; -import chalk from "chalk"; +import {db} from '../database/db/index.js'; +import {sshData, sshCredentials} from '../database/db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import { fileLogger } from '../utils/logger.js'; const app = express(); @@ -14,31 +17,6 @@ app.use(express.json({limit: '100mb'})); app.use(express.urlencoded({limit: '100mb', extended: true})); app.use(express.raw({limit: '200mb', type: 'application/octet-stream'})); -const sshIconSymbol = '📁'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; interface SSHSession { client: SSHClient; @@ -69,15 +47,52 @@ function scheduleSessionCleanup(sessionId: string) { } app.post('/ssh/file_manager/ssh/connect', async (req, res) => { - const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body; + const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body; + + fileLogger.info('File manager SSH connection request received', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType, hasCredentialId: !!credentialId }); + if (!sessionId || !ip || !username || !port) { + fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port }); return res.status(400).json({error: 'Missing SSH connection parameters'}); } if (sshSessions[sessionId]?.isConnected) { + fileLogger.info('Cleaning up existing SSH session', { operation: 'file_connect', sessionId }); cleanupSession(sessionId); } const client = new SSHClient(); + + let resolvedCredentials = {password, sshKey, keyPassword, authType}; + if (credentialId && hostId && userId) { + fileLogger.info('Resolving credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId }); + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId) + )); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + authType: credential.authType + }; + fileLogger.success('Credentials resolved successfully for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, authType: credential.authType }); + } else { + fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId }); + } + } catch (error) { + fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); + } + } else { + fileLogger.info('Using direct credentials for file manager connection', { operation: 'file_connect', sessionId, hostId, authType }); + } + const config: any = { host: ip, port: port || 22, @@ -121,26 +136,29 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { } }; - if (sshKey && sshKey.trim()) { + if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) { + fileLogger.info('Configuring SSH key authentication for file manager', { operation: 'file_connect', sessionId, hostId, hasKeyPassword: !!resolvedCredentials.keyPassword }); try { - if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) { + if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) { throw new Error('Invalid private key format'); } - - const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - + + const cleanKey = resolvedCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + config.privateKey = Buffer.from(cleanKey, 'utf8'); - - if (keyPassword) config.passphrase = keyPassword; - - logger.info('SSH key authentication configured successfully for file manager'); + + if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; + + fileLogger.success('SSH key authentication configured successfully for file manager', { operation: 'file_connect', sessionId, hostId }); } catch (keyError) { - logger.error('SSH key format error: ' + keyError.message); + fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message }); return res.status(400).json({error: 'Invalid SSH key format'}); } - } else if (password && password.trim()) { - config.password = password; + } else if (resolvedCredentials.password && resolvedCredentials.password.trim()) { + fileLogger.info('Configuring password authentication for file manager', { operation: 'file_connect', sessionId, hostId }); + config.password = resolvedCredentials.password; } else { + fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId }); return res.status(400).json({error: 'Either password or SSH key must be provided'}); } @@ -149,6 +167,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { client.on('ready', () => { if (responseSent) return; responseSent = true; + fileLogger.success('SSH connection established for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType: resolvedCredentials.authType }); sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()}; res.json({status: 'success', message: 'SSH connection established'}); }); @@ -156,7 +175,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => { client.on('error', (err) => { if (responseSent) return; responseSent = true; - logger.error(`SSH connection error for session ${sessionId}:`, err.message); + fileLogger.error('SSH connection failed for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, error: err.message }); res.status(500).json({status: 'error', message: err.message}); }); @@ -194,12 +213,12 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => { } sshConn.lastActive = Date.now(); - + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { if (err) { - logger.error('SSH listFiles error:', err); + fileLogger.error('SSH listFiles error:', err); return res.status(500).json({error: err.message}); } @@ -216,7 +235,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => { stream.on('close', (code) => { if (code !== 0) { - logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -264,12 +283,12 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => { } sshConn.lastActive = Date.now(); - + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { if (err) { - logger.error('SSH readFile error:', err); + fileLogger.error('SSH readFile error:', err); return res.status(500).json({error: err.message}); } @@ -286,7 +305,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => { stream.on('close', (code) => { if (code !== 0) { - logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -321,7 +340,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { try { sshConn.client.sftp((err, sftp) => { if (err) { - logger.warn(`SFTP failed, trying fallback method: ${err.message}`); + fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`); tryFallbackMethod(); return; } @@ -336,7 +355,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { fileBuffer = Buffer.from(content); } } catch (bufferErr) { - logger.error('Buffer conversion error:', bufferErr); + fileLogger.error('Buffer conversion error:', bufferErr); if (!res.headersSent) { return res.status(500).json({error: 'Invalid file content format'}); } @@ -351,14 +370,14 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { writeStream.on('error', (streamErr) => { if (hasError || hasFinished) return; hasError = true; - logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); + fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); tryFallbackMethod(); }); writeStream.on('finish', () => { if (hasError || hasFinished) return; hasFinished = true; - logger.success(`File written successfully via SFTP: ${filePath}`); + fileLogger.success(`File written successfully via SFTP: ${filePath}`); if (!res.headersSent) { res.json({message: 'File written successfully', path: filePath}); } @@ -367,7 +386,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { writeStream.on('close', () => { if (hasError || hasFinished) return; hasFinished = true; - logger.success(`File written successfully via SFTP: ${filePath}`); + fileLogger.success(`File written successfully via SFTP: ${filePath}`); if (!res.headersSent) { res.json({message: 'File written successfully', path: filePath}); } @@ -379,12 +398,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { } catch (writeErr) { if (hasError || hasFinished) return; hasError = true; - logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); + fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); tryFallbackMethod(); } }); } catch (sftpErr) { - logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); + fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); tryFallbackMethod(); } }; @@ -399,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - logger.error('Fallback write command failed:', err); + fileLogger.error('Fallback write command failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Write failed: ${err.message}`}); } @@ -421,12 +440,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { if (outputData.includes('SUCCESS')) { - logger.success(`File written successfully via fallback: ${filePath}`); + fileLogger.success(`File written successfully via fallback: ${filePath}`); if (!res.headersSent) { res.json({message: 'File written successfully', path: filePath}); } } else { - logger.error(`Fallback write failed with code ${code}: ${errorData}`); + fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`); if (!res.headersSent) { res.status(500).json({error: `Write failed: ${errorData}`}); } @@ -435,7 +454,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { stream.on('error', (streamErr) => { - logger.error('Fallback write stream error:', streamErr); + fileLogger.error('Fallback write stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Write stream error: ${streamErr.message}`}); } @@ -443,7 +462,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { }); } catch (fallbackErr) { - logger.error('Fallback method failed:', fallbackErr); + fileLogger.error('Fallback method failed:', fallbackErr); if (!res.headersSent) { res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`}); } @@ -470,17 +489,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { } sshConn.lastActive = Date.now(); - + const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; - const trySFTP = () => { try { sshConn.client.sftp((err, sftp) => { if (err) { - logger.warn(`SFTP failed, trying fallback method: ${err.message}`); + fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`); tryFallbackMethod(); return; } @@ -496,7 +514,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { } } catch (bufferErr) { - logger.error('Buffer conversion error:', bufferErr); + fileLogger.error('Buffer conversion error:', bufferErr); if (!res.headersSent) { return res.status(500).json({error: 'Invalid file content format'}); } @@ -511,7 +529,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { writeStream.on('error', (streamErr) => { if (hasError || hasFinished) return; hasError = true; - logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); + fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); tryFallbackMethod(); }); @@ -519,7 +537,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; - logger.success(`File uploaded successfully via SFTP: ${fullPath}`); + fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); } @@ -529,7 +547,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; - logger.success(`File uploaded successfully via SFTP: ${fullPath}`); + fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); } @@ -541,12 +559,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { } catch (writeErr) { if (hasError || hasFinished) return; hasError = true; - logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); + fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); tryFallbackMethod(); } }); } catch (sftpErr) { - logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); + fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); tryFallbackMethod(); } }; @@ -570,8 +588,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - - logger.error('Fallback upload command failed:', err); + + fileLogger.error('Fallback upload command failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Upload failed: ${err.message}`}); } @@ -590,15 +608,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('close', (code) => { - + if (outputData.includes('SUCCESS')) { - logger.success(`File uploaded successfully via fallback: ${fullPath}`); + fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); } } else { - logger.error(`Fallback upload failed with code ${code}: ${errorData}`); + fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`); if (!res.headersSent) { res.status(500).json({error: `Upload failed: ${errorData}`}); } @@ -606,8 +624,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('error', (streamErr) => { - - logger.error('Fallback upload stream error:', streamErr); + + fileLogger.error('Fallback upload stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Upload stream error: ${streamErr.message}`}); } @@ -628,8 +646,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { sshConn.client.exec(writeCommand, (err, stream) => { if (err) { - - logger.error('Chunked fallback upload failed:', err); + + fileLogger.error('Chunked fallback upload failed:', err); if (!res.headersSent) { return res.status(500).json({error: `Chunked upload failed: ${err.message}`}); } @@ -648,15 +666,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('close', (code) => { - + if (outputData.includes('SUCCESS')) { - logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); + fileLogger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); if (!res.headersSent) { res.json({message: 'File uploaded successfully', path: fullPath}); } } else { - logger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); + fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); if (!res.headersSent) { res.status(500).json({error: `Chunked upload failed: ${errorData}`}); } @@ -664,7 +682,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); stream.on('error', (streamErr) => { - logger.error('Chunked fallback upload stream error:', streamErr); + fileLogger.error('Chunked fallback upload stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`}); } @@ -672,7 +690,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { }); } } catch (fallbackErr) { - logger.error('Fallback method failed:', fallbackErr); + fileLogger.error('Fallback method failed:', fallbackErr); if (!res.headersSent) { res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`}); } @@ -707,7 +725,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { sshConn.client.exec(createCommand, (err, stream) => { if (err) { - logger.error('SSH createFile error:', err); + fileLogger.error('SSH createFile error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); } @@ -725,7 +743,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - logger.error(`Permission denied creating file: ${fullPath}`); + fileLogger.error(`Permission denied creating file: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.` @@ -744,7 +762,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { } if (code !== 0) { - logger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -757,7 +775,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { }); stream.on('error', (streamErr) => { - logger.error('SSH createFile stream error:', streamErr); + fileLogger.error('SSH createFile stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); } @@ -791,7 +809,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { sshConn.client.exec(createCommand, (err, stream) => { if (err) { - logger.error('SSH createFolder error:', err); + fileLogger.error('SSH createFolder error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); } @@ -809,7 +827,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - logger.error(`Permission denied creating folder: ${fullPath}`); + fileLogger.error(`Permission denied creating folder: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.` @@ -828,7 +846,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { } if (code !== 0) { - logger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -841,7 +859,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { }); stream.on('error', (streamErr) => { - logger.error('SSH createFolder stream error:', streamErr); + fileLogger.error('SSH createFolder stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); } @@ -874,7 +892,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { sshConn.client.exec(deleteCommand, (err, stream) => { if (err) { - logger.error('SSH deleteItem error:', err); + fileLogger.error('SSH deleteItem error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); } @@ -892,7 +910,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - logger.error(`Permission denied deleting: ${itemPath}`); + fileLogger.error(`Permission denied deleting: ${itemPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.` @@ -911,7 +929,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { } if (code !== 0) { - logger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -924,7 +942,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { }); stream.on('error', (streamErr) => { - logger.error('SSH deleteItem stream error:', streamErr); + fileLogger.error('SSH deleteItem stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); } @@ -959,7 +977,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { sshConn.client.exec(renameCommand, (err, stream) => { if (err) { - logger.error('SSH renameItem error:', err); + fileLogger.error('SSH renameItem error:', err); if (!res.headersSent) { return res.status(500).json({error: err.message}); } @@ -977,7 +995,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { errorData += chunk.toString(); if (chunk.toString().includes('Permission denied')) { - logger.error(`Permission denied renaming: ${oldPath}`); + fileLogger.error(`Permission denied renaming: ${oldPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.` @@ -996,7 +1014,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { } if (code !== 0) { - logger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); if (!res.headersSent) { return res.status(500).json({error: `Command failed: ${errorData}`}); } @@ -1009,7 +1027,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { }); stream.on('error', (streamErr) => { - logger.error('SSH renameItem stream error:', streamErr); + fileLogger.error('SSH renameItem stream error:', streamErr); if (!res.headersSent) { res.status(500).json({error: `Stream error: ${streamErr.message}`}); } @@ -1029,4 +1047,5 @@ process.on('SIGTERM', () => { const PORT = 8084; app.listen(PORT, () => { + fileLogger.success('File Manager API server started', { operation: 'server_start', port: PORT }); }); \ No newline at end of file diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 3d853b40..477c2ed7 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -1,16 +1,40 @@ import express from 'express'; -import chalk from 'chalk'; import fetch from 'node-fetch'; import net from 'net'; import cors from 'cors'; import {Client, type ConnectConfig} from 'ssh2'; -import {sshHostService} from '../services/ssh-host.js'; -import type {SSHHostWithCredentials} from '../services/ssh-host.js'; - -// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead +import {db} from '../database/db/index.js'; +import {sshData, sshCredentials} from '../database/db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import { statsLogger } from '../utils/logger.js'; type HostStatus = 'online' | 'offline'; +interface SSHHostWithCredentials { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; + userId: string; +} + type StatusEntry = { status: HostStatus; lastChecked: string; @@ -33,92 +57,127 @@ app.use((req, res, next) => { }); app.use(express.json()); -const statsIconSymbol = '📡'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#22c55e')(`[${statsIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; const hostStatuses: Map = new Map(); async function fetchAllHosts(): Promise { - const url = 'http://localhost:8081/ssh/db/host/internal'; try { - const resp = await fetch(url, { - headers: {'x-internal-request': '1'} - }); - if (!resp.ok) { - throw new Error(`DB service error: ${resp.status} ${resp.statusText}`); - } - const data = await resp.json(); - const rawHosts = Array.isArray(data) ? data : []; - - // Resolve credentials for each host using the same logic as main SSH connections + const hosts = await db.select().from(sshData); + const hostsWithCredentials: SSHHostWithCredentials[] = []; - for (const rawHost of rawHosts) { + for (const host of hosts) { try { - // Use the ssh-host service to properly resolve credentials - const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id); - if (host) { - hostsWithCredentials.push(host); + const hostWithCreds = await resolveHostCredentials(host); + if (hostWithCreds) { + hostsWithCredentials.push(hostWithCreds); } } catch (err) { - logger.warn(`Failed to resolve credentials for host ${rawHost.id}: ${err instanceof Error ? err.message : 'Unknown error'}`); + statsLogger.warn(`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : 'Unknown error'}`); } } - + return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port); } catch (err) { - logger.error('Failed to fetch hosts from database service', err); + statsLogger.error('Failed to fetch hosts from database', err); return []; } } async function fetchHostById(id: number): Promise { try { - // Get all users that might own this host - const url = 'http://localhost:8081/ssh/db/host/internal'; - const resp = await fetch(url, { - headers: {'x-internal-request': '1'} - }); - if (!resp.ok) { - throw new Error(`DB service error: ${resp.status} ${resp.statusText}`); - } - const data = await resp.json(); - const rawHost = (Array.isArray(data) ? data : []).find((h: any) => h.id === id); - - if (!rawHost) { + const hosts = await db.select().from(sshData).where(eq(sshData.id, id)); + + if (hosts.length === 0) { return undefined; } - - // Use ssh-host service to properly resolve credentials - return await sshHostService.getHostWithCredentials(rawHost.userId, id); + + const host = hosts[0]; + return await resolveHostCredentials(host); } catch (err) { - logger.error(`Failed to fetch host ${id}`, err); + statsLogger.error(`Failed to fetch host ${id}`, err); return undefined; } } +async function resolveHostCredentials(host: any): Promise { + try { + statsLogger.info('Resolving credentials for host', { operation: 'host_credential_resolve', hostId: host.id, hostName: host.name, hasCredentialId: !!host.credentialId }); + + const baseHost: any = { + id: host.id, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder || '', + tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [], + pin: !!host.pin, + authType: host.authType, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + enableFileManager: !!host.enableFileManager, + defaultPath: host.defaultPath || '/', + tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], + createdAt: host.createdAt, + updatedAt: host.updatedAt, + userId: host.userId + }; + + if (host.credentialId) { + statsLogger.info('Fetching credentials from database', { operation: 'host_credential_resolve', hostId: host.id, credentialId: host.credentialId, userId: host.userId }); + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId) + )); + + if (credentials.length > 0) { + const credential = credentials[0]; + baseHost.credentialId = credential.id; + baseHost.username = credential.username; + baseHost.authType = credential.authType; + + if (credential.password) { + baseHost.password = credential.password; + } + if (credential.key) { + baseHost.key = credential.key; + } + if (credential.keyPassword) { + baseHost.keyPassword = credential.keyPassword; + } + if (credential.keyType) { + baseHost.keyType = credential.keyType; + } + } else { + statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); + addLegacyCredentials(baseHost, host); + } + } catch (error) { + statsLogger.warn(`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + addLegacyCredentials(baseHost, host); + } + } else { + addLegacyCredentials(baseHost, host); + } + + return baseHost; + } catch (error) { + statsLogger.error(`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + return undefined; + } +} + +function addLegacyCredentials(baseHost: any, host: any): void { + baseHost.password = host.password || null; + baseHost.key = host.key || null; + baseHost.keyPassword = host.keyPassword || null; + baseHost.keyType = host.keyType; +} + function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { const base: ConnectConfig = { host: host.ip, @@ -128,7 +187,6 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { algorithms: {} } as ConnectConfig; - // Use the same authentication logic as main SSH connections if (host.authType === 'password') { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); @@ -138,27 +196,27 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { if (!host.key) { throw new Error(`No SSH key available for host ${host.ip}`); } - + try { if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) { throw new Error('Invalid private key format'); } - + const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - + (base as any).privateKey = Buffer.from(cleanKey, 'utf8'); - + if (host.keyPassword) { (base as any).passphrase = host.keyPassword; } } catch (keyError) { - logger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`); + statsLogger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`); throw new Error(`Invalid SSH key format for host ${host.ip}`); } } else { throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`); } - + return base; } @@ -316,24 +374,22 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ let usedHuman: string | null = null; let totalHuman: string | null = null; try { - // Get both human-readable and bytes format for accurate calculation const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2'); const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2'); - + const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; - + const humanParts = humanLine.split(/\s+/); const bytesParts = bytesLine.split(/\s+/); - + if (humanParts.length >= 6 && bytesParts.length >= 6) { totalHuman = humanParts[1] || null; usedHuman = humanParts[2] || null; - - // Calculate our own percentage using bytes for accuracy + const totalBytes = Number(bytesParts[1]); const usedBytes = Number(bytesParts[2]); - + if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) { diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)); } @@ -381,25 +437,30 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise } async function pollStatusesOnce(): Promise { + statsLogger.info('Starting status polling for all hosts', { operation: 'status_poll' }); const hosts = await fetchAllHosts(); if (hosts.length === 0) { - logger.warn('No hosts retrieved for status polling'); + statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' }); return; } + statsLogger.info('Polling status for hosts', { operation: 'status_poll', hostCount: hosts.length, hostIds: hosts.map(h => h.id) }); const now = new Date().toISOString(); const checks = hosts.map(async (h) => { + statsLogger.info('Checking host status', { operation: 'status_poll', hostId: h.id, hostName: h.name, ip: h.ip, port: h.port }); const isOnline = await tcpPing(h.ip, h.port, 5000); const now = new Date().toISOString(); const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; hostStatuses.set(h.id, statusEntry); + statsLogger.info('Host status check completed', { operation: 'status_poll', hostId: h.id, hostName: h.name, status: isOnline ? 'online' : 'offline' }); return isOnline; }); const results = await Promise.allSettled(checks); const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length; const offlineCount = hosts.length - onlineCount; + statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount }); } app.get('/status', async (req, res) => { @@ -424,15 +485,15 @@ app.get('/status/:id', async (req, res) => { if (!host) { return res.status(404).json({error: 'Host not found'}); } - + const isOnline = await tcpPing(host.ip, host.port, 5000); const now = new Date().toISOString(); const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; - + hostStatuses.set(id, statusEntry); res.json(statusEntry); } catch (err) { - logger.error('Failed to check host status', err); + statsLogger.error('Failed to check host status', err); res.status(500).json({error: 'Failed to check host status'}); } }); @@ -455,7 +516,7 @@ app.get('/metrics/:id', async (req, res) => { const metrics = await collectMetrics(host); res.json({...metrics, lastChecked: new Date().toISOString()}); } catch (err) { - logger.error('Failed to collect metrics', err); + statsLogger.error('Failed to collect metrics', err); return res.json({ cpu: {percent: null, cores: null, load: null}, memory: {percent: null, usedGiB: null, totalGiB: null}, @@ -467,9 +528,10 @@ app.get('/metrics/:id', async (req, res) => { const PORT = 8085; app.listen(PORT, async () => { + statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT }); try { await pollStatusesOnce(); } catch (err) { - logger.error('Initial poll failed', err); + statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' }); } }); \ No newline at end of file diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index a77a7e90..8d761bc8 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,48 +1,27 @@ import {WebSocketServer, WebSocket, type RawData} from 'ws'; import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; -import chalk from 'chalk'; +import {db} from '../database/db/index.js'; +import {sshData, sshCredentials} from '../database/db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import { sshLogger } from '../utils/logger.js'; const wss = new WebSocketServer({port: 8082}); +sshLogger.success('SSH Terminal WebSocket server started', { operation: 'server_start', port: 8082 }); -const sshIconSymbol = '🖥️'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; - - wss.on('connection', (ws: WebSocket) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; - + + sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' }); ws.on('close', () => { + sshLogger.info('WebSocket connection closed', { operation: 'websocket_disconnect' }); cleanupSSH(); }); @@ -53,7 +32,7 @@ wss.on('connection', (ws: WebSocket) => { try { parsed = JSON.parse(msg.toString()); } catch (e) { - logger.error('Invalid JSON received: ' + msg.toString()); + sshLogger.error('Invalid JSON received', e, { operation: 'websocket_message', messageLength: msg.toString().length }); ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); return; } @@ -62,7 +41,11 @@ wss.on('connection', (ws: WebSocket) => { switch (type) { case 'connectToHost': - handleConnectToHost(data); + sshLogger.info('SSH connection request received', { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, port: data.hostConfig?.port }); + handleConnectToHost(data).catch(error => { + sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip }); + ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')})); + }); break; case 'resize': @@ -70,6 +53,7 @@ wss.on('connection', (ws: WebSocket) => { break; case 'disconnect': + sshLogger.info('SSH disconnect requested', { operation: 'ssh_disconnect' }); cleanupSSH(); break; @@ -90,14 +74,15 @@ wss.on('connection', (ws: WebSocket) => { break; default: - logger.warn('Unknown message type: ' + type); + sshLogger.warn('Unknown message type received', { operation: 'websocket_message', messageType: type }); } }); - function handleConnectToHost(data: { + async function handleConnectToHost(data: { cols: number; rows: number; hostConfig: { + id: number; ip: string; port: number; username: string; @@ -106,25 +91,27 @@ wss.on('connection', (ws: WebSocket) => { keyPassword?: string; keyType?: string; authType?: string; + credentialId?: number; + userId?: string; }; }) { const {cols, rows, hostConfig} = data; - const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig; + const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig; if (!username || typeof username !== 'string' || username.trim() === '') { - logger.error('Invalid username provided'); + sshLogger.error('Invalid username provided', undefined, { operation: 'ssh_connect', hostId: id, ip }); ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); return; } if (!ip || typeof ip !== 'string' || ip.trim() === '') { - logger.error('Invalid IP provided'); + sshLogger.error('Invalid IP provided', undefined, { operation: 'ssh_connect', hostId: id, username }); ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); return; } if (!port || typeof port !== 'number' || port <= 0) { - logger.error('Invalid port provided'); + sshLogger.error('Invalid port provided', undefined, { operation: 'ssh_connect', hostId: id, ip, username, port }); ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'})); return; } @@ -133,14 +120,41 @@ wss.on('connection', (ws: WebSocket) => { const connectionTimeout = setTimeout(() => { if (sshConn) { - logger.error('SSH connection timeout'); + sshLogger.error('SSH connection timeout', undefined, { operation: 'ssh_connect', hostId: id, ip, port, username }); ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); cleanupSSH(connectionTimeout); } }, 60000); + let resolvedCredentials = {password, key, keyPassword, keyType, authType}; + if (credentialId && id) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, hostConfig.userId || '') + )); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authType: credential.authType + }; + } + } catch (error) { + sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); + } + } + sshConn.on('ready', () => { clearTimeout(connectionTimeout); + sshLogger.success('SSH connection established', { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType }); sshConn!.shell({ @@ -149,7 +163,7 @@ wss.on('connection', (ws: WebSocket) => { term: 'xterm-256color' } as PseudoTtyOptions, (err, stream) => { if (err) { - logger.error('Shell error: ' + err.message); + sshLogger.error('Shell error', err, { operation: 'ssh_shell', hostId: id, ip, port, username }); ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); return; } @@ -161,12 +175,12 @@ wss.on('connection', (ws: WebSocket) => { }); stream.on('close', () => { - + sshLogger.info('SSH stream closed', { operation: 'ssh_stream', hostId: id, ip, port, username }); ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); }); stream.on('error', (err: Error) => { - logger.error('SSH stream error: ' + err.message); + sshLogger.error('SSH stream error', err, { operation: 'ssh_stream', hostId: id, ip, port, username }); ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); }); @@ -178,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => { sshConn.on('error', (err: Error) => { clearTimeout(connectionTimeout); - logger.error('SSH connection error: ' + err.message); + sshLogger.error('SSH connection error', err, { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType }); let errorMessage = 'SSH error: ' + err.message; if (err.message.includes('No matching key exchange algorithm')) { @@ -210,7 +224,6 @@ wss.on('connection', (ws: WebSocket) => { }); - const connectConfig: any = { host: ip, port, @@ -269,34 +282,34 @@ wss.on('connection', (ws: WebSocket) => { ] } }; - if (authType === 'key' && key) { + if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) { try { - if (!key.includes('-----BEGIN') || !key.includes('-----END')) { + if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) { throw new Error('Invalid private key format'); } - - const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - + + const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + connectConfig.privateKey = Buffer.from(cleanKey, 'utf8'); - - if (keyPassword) { - connectConfig.passphrase = keyPassword; + + if (resolvedCredentials.keyPassword) { + connectConfig.passphrase = resolvedCredentials.keyPassword; } - - if (keyType && keyType !== 'auto') { - connectConfig.privateKeyType = keyType; + + if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') { + connectConfig.privateKeyType = resolvedCredentials.keyType; } } catch (keyError) { - logger.error('SSH key format error: ' + keyError.message); + sshLogger.error('SSH key format error: ' + keyError.message); ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'})); return; } - } else if (authType === 'key') { - logger.error('SSH key authentication requested but no key provided'); + } else if (resolvedCredentials.authType === 'key') { + sshLogger.error('SSH key authentication requested but no key provided'); ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'})); return; } else { - connectConfig.password = password; + connectConfig.password = resolvedCredentials.password; } sshConn.connect(connectConfig); @@ -323,7 +336,7 @@ wss.on('connection', (ws: WebSocket) => { try { sshStream.end(); } catch (e: any) { - logger.error('Error closing stream: ' + e.message); + sshLogger.error('Error closing stream: ' + e.message); } sshStream = null; } @@ -332,7 +345,7 @@ wss.on('connection', (ws: WebSocket) => { try { sshConn.end(); } catch (e: any) { - logger.error('Error closing connection: ' + e.message); + sshLogger.error('Error closing connection: ' + e.message); } sshConn = null; } @@ -344,7 +357,7 @@ wss.on('connection', (ws: WebSocket) => { try { sshStream.write('\x00'); } catch (e: any) { - logger.error('SSH keepalive failed: ' + e.message); + sshLogger.error('SSH keepalive failed: ' + e.message); cleanupSSH(); } } diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 274af443..db4db51d 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -2,9 +2,23 @@ import express from 'express'; import cors from 'cors'; import {Client} from 'ssh2'; import {ChildProcess} from 'child_process'; -import chalk from 'chalk'; import axios from 'axios'; import * as net from 'net'; +import {db} from '../database/db/index.js'; +import {sshData, sshCredentials} from '../database/db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import type { + SSHHost, + TunnelConfig, + TunnelConnection, + TunnelStatus, + HostConfig, + VerificationData, + ConnectionState, + ErrorType +} from '../../types/index.js'; +import { CONNECTION_STATES } from '../../types/index.js'; +import { tunnelLogger } from '../utils/logger.js'; const app = express(); app.use(cors({ @@ -14,31 +28,6 @@ app.use(cors({ })); app.use(express.json()); -const tunnelIconSymbol = '📡'; -const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${tunnelIconSymbol}]`)} ${message}`; -}; -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; const activeTunnels = new Map(); const retryCounters = new Map(); @@ -53,109 +42,17 @@ const retryExhaustedTunnels = new Set(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); -interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; -} -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; -} - -interface TunnelConfig { - name: string; - hostName: string; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword?: string; - sourceAuthMethod: string; - sourceSSHKey?: string; - sourceKeyPassword?: string; - sourceKeyType?: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword?: string; - endpointAuthMethod: string; - endpointSSHKey?: string; - endpointKeyPassword?: string; - endpointKeyType?: string; - sourcePort: number; - endpointPort: number; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - isPinned: boolean; -} - -interface HostConfig { - host: SSHHost; - tunnels: TunnelConfig[]; -} - -interface TunnelStatus { - connected: boolean; - status: ConnectionState; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - reason?: string; - errorType?: ErrorType; - manualDisconnect?: boolean; - retryExhausted?: boolean; -} - -interface VerificationData { - conn: Client; - timeout: NodeJS.Timeout; -} - -const CONNECTION_STATES = { - DISCONNECTED: "disconnected", - CONNECTING: "connecting", - CONNECTED: "connected", - VERIFYING: "verifying", - FAILED: "failed", - UNSTABLE: "unstable", - RETRYING: "retrying", - WAITING: "waiting" -} as const; const ERROR_TYPES = { - AUTH: "authentication", - NETWORK: "network", - PORT: "port_conflict", - PERMISSION: "permission", - TIMEOUT: "timeout", - UNKNOWN: "unknown" + AUTH: "AUTHENTICATION_FAILED", + NETWORK: "NETWORK_ERROR", + PORT: "CONNECTION_FAILED", + PERMISSION: "CONNECTION_FAILED", + TIMEOUT: "TIMEOUT", + UNKNOWN: "UNKNOWN" } as const; -type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES]; -type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES]; function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) { @@ -178,7 +75,7 @@ function getAllTunnelStatus(): Record { } function classifyError(errorMessage: string): ErrorType { - if (!errorMessage) return ERROR_TYPES.UNKNOWN; + if (!errorMessage) return 'UNKNOWN'; const message = errorMessage.toLowerCase(); @@ -186,34 +83,34 @@ function classifyError(errorMessage: string): ErrorType { message.includes("connection reset by peer") || message.includes("connection refused") || message.includes("broken pipe")) { - return ERROR_TYPES.NETWORK; + return 'NETWORK_ERROR'; } if (message.includes("authentication failed") || message.includes("permission denied") || message.includes("incorrect password")) { - return ERROR_TYPES.AUTH; + return 'AUTHENTICATION_FAILED'; } if (message.includes("connect etimedout") || message.includes("timeout") || message.includes("timed out") || message.includes("keepalive timeout")) { - return ERROR_TYPES.TIMEOUT; + return 'TIMEOUT'; } if (message.includes("bind: address already in use") || message.includes("failed for listen port") || message.includes("port forwarding failed")) { - return ERROR_TYPES.PORT; + return 'CONNECTION_FAILED'; } if (message.includes("permission") || message.includes("access denied")) { - return ERROR_TYPES.PERMISSION; + return 'CONNECTION_FAILED'; } - return ERROR_TYPES.UNKNOWN; + return 'UNKNOWN'; } function getTunnelMarker(tunnelName: string) { @@ -225,7 +122,7 @@ function cleanupTunnelResources(tunnelName: string): void { if (tunnelConfig) { killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { if (err) { - logger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`); + tunnelLogger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`); } }); } @@ -237,7 +134,7 @@ function cleanupTunnelResources(tunnelName: string): void { proc.kill('SIGTERM'); } } catch (e) { - logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e); + tunnelLogger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e); } activeTunnelProcesses.delete(tunnelName); } @@ -249,7 +146,7 @@ function cleanupTunnelResources(tunnelName: string): void { conn.end(); } } catch (e) { - logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e); + tunnelLogger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e); } activeTunnels.delete(tunnelName); } @@ -359,7 +256,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, retryCount = retryCount + 1; if (retryCount > maxRetries) { - logger.error(`All ${maxRetries} retries failed for ${tunnelName}`); + tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`); retryExhaustedTunnels.add(tunnelName); activeTunnels.delete(tunnelName); @@ -423,7 +320,9 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, if (!manualDisconnects.has(tunnelName)) { activeTunnels.delete(tunnelName); - connectSSHTunnel(tunnelConfig, retryCount); + connectSSHTunnel(tunnelConfig, retryCount).catch(error => { + tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + }); } }, retryInterval); @@ -457,7 +356,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void clearInterval(verificationTimers.get(pingKey)!); verificationTimers.delete(pingKey); } - + const pingInterval = setInterval(() => { const currentStatus = connectionStatus.get(tunnelName); if (currentStatus?.status === CONNECTION_STATES.CONNECTED) { @@ -475,15 +374,18 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void verificationTimers.delete(pingKey); } }, 120000); - + verificationTimers.set(pingKey, pingInterval); } -function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { +async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): Promise { const tunnelName = tunnelConfig.name; const tunnelMarker = getTunnelMarker(tunnelName); + tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, retryAttempt, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort }); + if (manualDisconnects.has(tunnelName)) { + tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName }); return; } @@ -492,10 +394,14 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { if (retryAttempt === 0) { retryExhaustedTunnels.delete(tunnelName); retryCounters.delete(tunnelName); + tunnelLogger.info('Reset retry state for tunnel', { operation: 'tunnel_connect', tunnelName }); + } else { + tunnelLogger.warn('Tunnel connection retry attempt', { operation: 'tunnel_connect', tunnelName, retryAttempt }); } const currentStatus = connectionStatus.get(tunnelName); if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { + tunnelLogger.info('Broadcasting tunnel connecting status', { operation: 'tunnel_connect', tunnelName, retryAttempt }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.CONNECTING, @@ -504,7 +410,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { } if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) { - logger.error(`Invalid connection details for '${tunnelName}'`); + tunnelLogger.error('Invalid tunnel connection details', { operation: 'tunnel_connect', tunnelName, hasSourceIP: !!tunnelConfig?.sourceIP, hasSourceUsername: !!tunnelConfig?.sourceUsername, hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, @@ -513,6 +419,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { return; } + let resolvedSourceCredentials = { + password: tunnelConfig.sourcePassword, + sshKey: tunnelConfig.sourceSSHKey, + keyPassword: tunnelConfig.sourceKeyPassword, + keyType: tunnelConfig.sourceKeyType, + authMethod: tunnelConfig.sourceAuthMethod + }; + + if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { + tunnelLogger.info('Resolving source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, userId: tunnelConfig.sourceUserId }); + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, tunnelConfig.sourceCredentialId), + eq(sshCredentials.userId, tunnelConfig.sourceUserId) + )); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedSourceCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authMethod: credential.authType + }; + tunnelLogger.success('Source credentials resolved successfully', { operation: 'tunnel_connect', tunnelName, credentialId: credential.id, authType: credential.authType }); + } else { + tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId }); + } + } catch (error) { + tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' }); + } + } else { + tunnelLogger.info('Using direct source credentials from tunnel config', { operation: 'tunnel_connect', tunnelName, authMethod: tunnelConfig.sourceAuthMethod }); + } + + // Resolve endpoint credentials if tunnel config has endpointCredentialId + let resolvedEndpointCredentials = { + password: tunnelConfig.endpointPassword, + sshKey: tunnelConfig.endpointSSHKey, + keyPassword: tunnelConfig.endpointKeyPassword, + keyType: tunnelConfig.endpointKeyType, + authMethod: tunnelConfig.endpointAuthMethod + }; + + if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { + try { + const credentials = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, tunnelConfig.endpointCredentialId), + eq(sshCredentials.userId, tunnelConfig.endpointUserId) + )); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedEndpointCredentials = { + password: credential.password, + sshKey: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + authMethod: credential.authType + }; + } + } catch (error) { + tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + const conn = new Client(); const connectionTimeout = setTimeout(() => { @@ -536,7 +515,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { conn.on("error", (err) => { clearTimeout(connectionTimeout); - logger.error(`SSH error for '${tunnelName}': ${err.message}`); + tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`); if (activeRetryTimers.has(tunnelName)) { return; @@ -555,11 +534,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { activeTunnels.delete(tunnelName); - const shouldNotRetry = errorType === ERROR_TYPES.AUTH || - errorType === ERROR_TYPES.PORT || - errorType === ERROR_TYPES.PERMISSION || + const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' || + errorType === 'CONNECTION_FAILED' || manualDisconnects.has(tunnelName); - handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); @@ -596,25 +573,24 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { } let tunnelCmd: string; - if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) { + if (resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.sshKey) { const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; - tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; + tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; } else { - tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; + tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; } conn.exec(tunnelCmd, (err, stream) => { if (err) { - logger.error(`Connection error for '${tunnelName}': ${err.message}`); + tunnelLogger.error(`Connection error for '${tunnelName}': ${err.message}`); conn.end(); activeTunnels.delete(tunnelName); const errorType = classifyError(err.message); - const shouldNotRetry = errorType === ERROR_TYPES.AUTH || - errorType === ERROR_TYPES.PORT || - errorType === ERROR_TYPES.PERMISSION; + const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' || + errorType === 'CONNECTION_FAILED'; handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); return; @@ -696,7 +672,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, + keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, @@ -737,9 +713,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { } }; - if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) { - logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`); + if (resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey) { + if (!resolvedSourceCredentials.sshKey.includes('-----BEGIN') || !resolvedSourceCredentials.sshKey.includes('-----END')) { + tunnelLogger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, @@ -748,16 +724,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { return; } - const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const cleanKey = resolvedSourceCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); - if (tunnelConfig.sourceKeyPassword) { - connOptions.passphrase = tunnelConfig.sourceKeyPassword; + if (resolvedSourceCredentials.keyPassword) { + connOptions.passphrase = resolvedSourceCredentials.keyPassword; } - if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { - connOptions.privateKeyType = tunnelConfig.sourceKeyType; + if (resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== 'auto') { + connOptions.privateKeyType = resolvedSourceCredentials.keyType; } - } else if (tunnelConfig.sourceAuthMethod === "key") { - logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); + } else if (resolvedSourceCredentials.authMethod === "key") { + tunnelLogger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, @@ -765,7 +741,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { }); return; } else { - connOptions.password = tunnelConfig.sourcePassword; + connOptions.password = resolvedSourceCredentials.password; } const finalStatus = connectionStatus.get(tunnelName); @@ -832,7 +808,7 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string callback(new Error('Invalid SSH key format')); return; } - + const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); connOptions.privateKey = Buffer.from(cleanKey, 'utf8'); if (tunnelConfig.sourceKeyPassword) { @@ -898,7 +874,9 @@ app.post('/ssh/tunnel/connect', (req, res) => { tunnelConfigs.set(tunnelName, tunnelConfig); - connectSSHTunnel(tunnelConfig, 0); + connectSSHTunnel(tunnelConfig, 0).catch(error => { + tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + }); res.json({message: 'Connection request received', tunnelName}); }); @@ -1027,22 +1005,25 @@ async function initializeAutoStartTunnels(): Promise { } } - logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`); + tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`); for (const tunnelConfig of autoStartTunnels) { tunnelConfigs.set(tunnelConfig.name, tunnelConfig); setTimeout(() => { - connectSSHTunnel(tunnelConfig, 0); + connectSSHTunnel(tunnelConfig, 0).catch(error => { + tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + }); }, 1000); } } catch (error: any) { - logger.error('Failed to initialize auto-start tunnels:', error.message); + tunnelLogger.error('Failed to initialize auto-start tunnels:', error.message); } } const PORT = 8083; app.listen(PORT, () => { + tunnelLogger.success('SSH Tunnel API server started', { operation: 'server_start', port: PORT }); setTimeout(() => { initializeAutoStartTunnels(); }, 2000); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index fcfe1dd9..6bd03942 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -6,51 +6,47 @@ import './ssh/terminal.js'; import './ssh/tunnel.js'; import './ssh/file-manager.js'; import './ssh/server-stats.js'; -import chalk from 'chalk'; - -const fixedIconSymbol = '🚀'; - -const getTimeStamp = (): string => { - return chalk.gray(`[${new Date().toLocaleTimeString()}]`); -}; - -const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { - return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`; -}; - -const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, - error: (msg: string, err?: unknown): void => { - console.error(formatMessage('error', chalk.redBright, msg)); - if (err) console.error(err); - }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, - debug: (msg: string): void => { - if (process.env.NODE_ENV !== 'production') { - console.debug(formatMessage('debug', chalk.magenta, msg)); - } - } -}; +import { systemLogger } from './utils/logger.js'; (async () => { try { - logger.info("Starting all backend servers..."); + systemLogger.info("Initializing backend services...", { operation: 'startup' }); + + systemLogger.info("Loading database service...", { operation: 'database_init' }); + systemLogger.info("Loading SSH terminal service...", { operation: 'terminal_init' }); + systemLogger.info("Loading SSH tunnel service...", { operation: 'tunnel_init' }); + systemLogger.info("Loading file manager service...", { operation: 'file_manager_init' }); + systemLogger.info("Loading server stats service...", { operation: 'stats_init' }); - logger.success("All servers started successfully"); + systemLogger.success("All backend services initialized successfully", { + operation: 'startup_complete', + services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'] + }); process.on('SIGINT', () => { - logger.info("Shutting down servers..."); + systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' }); + systemLogger.info("Shutting down all services...", { operation: 'shutdown' }); process.exit(0); }); + + process.on('SIGTERM', () => { + systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' }); + systemLogger.info("Shutting down all services...", { operation: 'shutdown' }); + process.exit(0); + }); + + process.on('uncaughtException', (error) => { + systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' }); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' }); + process.exit(1); + }); + } catch (error) { - logger.error("Failed to start servers:", error); + systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' }); process.exit(1); } })(); \ No newline at end of file diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts new file mode 100644 index 00000000..ee139b19 --- /dev/null +++ b/src/backend/utils/logger.ts @@ -0,0 +1,172 @@ +import chalk from 'chalk'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; + +export interface LogContext { + service?: string; + operation?: string; + userId?: string; + hostId?: number; + tunnelName?: string; + sessionId?: string; + requestId?: string; + duration?: number; + [key: string]: any; +} + +class Logger { + private serviceName: string; + private serviceIcon: string; + private serviceColor: string; + + constructor(serviceName: string, serviceIcon: string, serviceColor: string) { + this.serviceName = serviceName; + this.serviceIcon = serviceIcon; + this.serviceColor = serviceColor; + } + + private getTimeStamp(): string { + return chalk.gray(`[${new Date().toLocaleTimeString()}]`); + } + + private formatMessage(level: LogLevel, message: string, context?: LogContext): string { + const timestamp = this.getTimeStamp(); + const levelColor = this.getLevelColor(level); + const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`); + const levelTag = levelColor(`[${level.toUpperCase()}]`); + + let contextStr = ''; + if (context) { + const contextParts = []; + if (context.operation) contextParts.push(`op:${context.operation}`); + if (context.userId) contextParts.push(`user:${context.userId}`); + if (context.hostId) contextParts.push(`host:${context.hostId}`); + if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); + if (context.sessionId) contextParts.push(`session:${context.sessionId}`); + if (context.requestId) contextParts.push(`req:${context.requestId}`); + if (context.duration) contextParts.push(`duration:${context.duration}ms`); + + if (contextParts.length > 0) { + contextStr = chalk.gray(` [${contextParts.join(',')}]`); + } + } + + return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; + } + + private getLevelColor(level: LogLevel): chalk.Chalk { + switch (level) { + case 'debug': return chalk.magenta; + case 'info': return chalk.cyan; + case 'warn': return chalk.yellow; + case 'error': return chalk.redBright; + case 'success': return chalk.greenBright; + default: return chalk.white; + } + } + + private shouldLog(level: LogLevel): boolean { + if (level === 'debug' && process.env.NODE_ENV === 'production') { + return false; + } + return true; + } + + debug(message: string, context?: LogContext): void { + if (!this.shouldLog('debug')) return; + console.debug(this.formatMessage('debug', message, context)); + } + + info(message: string, context?: LogContext): void { + if (!this.shouldLog('info')) return; + console.log(this.formatMessage('info', message, context)); + } + + warn(message: string, context?: LogContext): void { + if (!this.shouldLog('warn')) return; + console.warn(this.formatMessage('warn', message, context)); + } + + error(message: string, error?: unknown, context?: LogContext): void { + if (!this.shouldLog('error')) return; + console.error(this.formatMessage('error', message, context)); + if (error) { + console.error(error); + } + } + + success(message: string, context?: LogContext): void { + if (!this.shouldLog('success')) return; + 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' }); + } + + db(message: string, context?: LogContext): void { + this.info(`DB: ${message}`, { ...context, operation: 'database' }); + } + + ssh(message: string, context?: LogContext): void { + this.info(`SSH: ${message}`, { ...context, operation: 'ssh' }); + } + + tunnel(message: string, context?: LogContext): void { + this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' }); + } + + file(message: string, context?: LogContext): void { + this.info(`FILE: ${message}`, { ...context, operation: 'file' }); + } + + api(message: string, context?: LogContext): void { + this.info(`API: ${message}`, { ...context, operation: 'api' }); + } + + request(message: string, context?: LogContext): void { + this.info(`REQUEST: ${message}`, { ...context, operation: 'request' }); + } + + response(message: string, context?: LogContext): void { + this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' }); + } + + connection(message: string, context?: LogContext): void { + this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' }); + } + + disconnect(message: string, context?: LogContext): void { + this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' }); + } + + 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' }); + } +} + +// Service-specific loggers +export const databaseLogger = new Logger('DATABASE', '🗄️', '#1e3a8a'); +export const sshLogger = new Logger('SSH', '🖥️', '#1e3a8a'); +export const tunnelLogger = new Logger('TUNNEL', '📡', '#1e3a8a'); +export const fileLogger = new Logger('FILE', '📁', '#1e3a8a'); +export const statsLogger = new Logger('STATS', '📊', '#22c55e'); +export const apiLogger = new Logger('API', '🌐', '#3b82f6'); +export const authLogger = new Logger('AUTH', '🔐', '#dc2626'); +export const systemLogger = new Logger('SYSTEM', '🚀', '#1e3a8a'); + +// Default logger for general use +export const logger = systemLogger; diff --git a/src/components/CredentialSelector.tsx b/src/components/CredentialSelector.tsx index d41361fe..c0321f99 100644 --- a/src/components/CredentialSelector.tsx +++ b/src/components/CredentialSelector.tsx @@ -4,15 +4,7 @@ import { Input } from "@/components/ui/input"; import { FormControl, FormItem, FormLabel } from "@/components/ui/form"; import { getCredentials } from '@/ui/main-axios'; import { useTranslation } from "react-i18next"; - -interface Credential { - id: number; - name: string; - description?: string; - username: string; - authType: 'password' | 'key'; - folder?: string; -} +import type { Credential } from '../types/index.js'; interface CredentialSelectorProps { value?: number | null; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..707e7591 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,416 @@ +// ============================================================================ +// CENTRAL TYPE DEFINITIONS +// ============================================================================ +// This file contains all shared interfaces and types used across the application +// to avoid duplication and ensure consistency. + +import type { Client } from 'ssh2'; + +// ============================================================================ +// SSH HOST TYPES +// ============================================================================ + +export interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: 'password' | 'key' | 'credential'; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; + userId?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: TunnelConnection[]; + createdAt: string; + updatedAt: string; +} + +export interface SSHHostData { + name?: string; + ip: string; + port: number; + username: string; + folder?: string; + tags?: string[]; + pin?: boolean; + authType: 'password' | 'key' | 'credential'; + password?: string; + key?: File | null; + keyPassword?: string; + keyType?: string; + credentialId?: number | null; + enableTerminal?: boolean; + enableTunnel?: boolean; + enableFileManager?: boolean; + defaultPath?: string; + tunnelConnections?: any[]; +} + +// ============================================================================ +// CREDENTIAL TYPES +// ============================================================================ + +export interface Credential { + id: number; + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: 'password' | 'key'; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + usageCount: number; + lastUsed?: string; + createdAt: string; + updatedAt: string; +} + +export interface CredentialData { + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: 'password' | 'key'; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; +} + +// ============================================================================ +// TUNNEL TYPES +// ============================================================================ + +export interface TunnelConnection { + sourcePort: number; + endpointPort: number; + endpointHost: string; + maxRetries: number; + retryInterval: number; + autoStart: boolean; +} + +export interface TunnelConfig { + name: string; + hostName: string; + sourceIP: string; + sourceSSHPort: number; + sourceUsername: string; + sourcePassword?: string; + sourceAuthMethod: string; + sourceSSHKey?: string; + sourceKeyPassword?: string; + sourceKeyType?: string; + sourceCredentialId?: number; + sourceUserId?: string; + endpointIP: string; + endpointSSHPort: number; + endpointUsername: string; + endpointPassword?: string; + endpointAuthMethod: string; + endpointSSHKey?: string; + endpointKeyPassword?: string; + endpointKeyType?: string; + endpointCredentialId?: number; + endpointUserId?: string; + sourcePort: number; + endpointPort: number; + maxRetries: number; + retryInterval: number; + autoStart: boolean; + isPinned: boolean; +} + +export interface TunnelStatus { + connected: boolean; + status: ConnectionState; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + reason?: string; + errorType?: ErrorType; + manualDisconnect?: boolean; + retryExhausted?: boolean; +} + +// ============================================================================ +// FILE MANAGER TYPES +// ============================================================================ + +export interface Tab { + id: string | number; + title: string; + fileName: string; + content: string; + isSSH?: boolean; + sshSessionId?: string; + filePath?: string; + loading?: boolean; + dirty?: boolean; +} + +export interface FileManagerFile { + name: string; + path: string; + type?: 'file' | 'directory'; + isSSH?: boolean; + sshSessionId?: string; +} + +export interface FileManagerShortcut { + name: string; + path: string; +} + +export interface FileItem { + name: string; + path: string; + isPinned?: boolean; + type: 'file' | 'directory'; + sshSessionId?: string; +} + +export interface ShortcutItem { + name: string; + path: string; +} + +export interface SSHConnection { + id: number; + name: string; + ip: string; + port: number; + username: string; + isPinned?: boolean; +} + +// ============================================================================ +// HOST INFO TYPES +// ============================================================================ + +export interface HostInfo { + id: number; + name?: string; + ip: string; + port: number; + createdAt: string; +} + +// ============================================================================ +// ALERT TYPES +// ============================================================================ + +export interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +// ============================================================================ +// TAB TYPES +// ============================================================================ + +export interface TabContextTab { + id: number; + type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager'; + title: string; + hostConfig?: any; + terminalRef?: React.RefObject; +} + +// ============================================================================ +// CONNECTION STATES +// ============================================================================ + +export const CONNECTION_STATES = { + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + VERIFYING: "verifying", + FAILED: "failed", + UNSTABLE: "unstable", + RETRYING: "retrying", + WAITING: "waiting", + DISCONNECTING: "disconnecting" +} as const; + +export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES]; + +export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN'; + +// ============================================================================ +// AUTHENTICATION TYPES +// ============================================================================ + +export type AuthType = 'password' | 'key' | 'credential'; + +export type KeyType = 'rsa' | 'ecdsa' | 'ed25519'; + +// ============================================================================ +// API RESPONSE TYPES +// ============================================================================ + +export interface ApiResponse { + data?: T; + error?: string; + message?: string; + status?: number; +} + +// ============================================================================ +// COMPONENT PROP TYPES +// ============================================================================ + +export interface CredentialsManagerProps { + onEditCredential?: (credential: Credential) => void; +} + +export interface CredentialEditorProps { + editingCredential?: Credential | null; + onFormSubmit?: () => void; +} + +export interface CredentialViewerProps { + credential: Credential; + onClose: () => void; + onEdit: () => void; +} + +export interface CredentialSelectorProps { + value?: number | null; + onChange: (value: number | null) => void; +} + +export interface HostManagerProps { + onSelectView?: (view: string) => void; + isTopbarOpen?: boolean; +} + +export interface SSHManagerHostEditorProps { + editingHost?: SSHHost | null; + onFormSubmit?: () => void; +} + +export interface SSHManagerHostViewerProps { + onEditHost?: (host: SSHHost) => void; +} + +export interface HostProps { + host: SSHHost; + onHostConnect?: () => void; +} + +export interface SSHTunnelProps { + filterHostKey?: string; +} + +export interface SSHTunnelViewerProps { + hosts?: SSHHost[]; + tunnelStatuses?: Record; + tunnelActions?: Record Promise>; + onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; +} + +export interface FileManagerProps { + onSelectView?: (view: string) => void; + embedded?: boolean; + initialHost?: SSHHost | null; +} + +export interface FileManagerLeftSidebarProps { + onSelectView?: (view: string) => void; + onOpenFile: (file: any) => void; + tabs: Tab[]; + host: SSHHost; + onOperationComplete?: () => void; + onError?: (error: string) => void; + onSuccess?: (message: string) => void; + onPathChange?: (path: string) => void; + onDeleteItem?: (item: any) => void; +} + +export interface FileManagerOperationsProps { + currentPath: string; + sshSessionId: string | null; + onOperationComplete?: () => void; + onError?: (error: string) => void; + onSuccess?: (message: string) => void; +} + +export interface AlertCardProps { + alert: TermixAlert; + onDismiss: (alertId: string) => void; +} + +export interface AlertManagerProps { + alerts: TermixAlert[]; + onDismiss: (alertId: string) => void; + loggedIn: boolean; +} + +export interface SSHTunnelObjectProps { + host: SSHHost; + tunnelStatuses: Record; + tunnelActions: Record; + onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; + compact?: boolean; + bare?: boolean; +} + +export interface FolderStats { + totalHosts: number; + hostsByType: Array<{ + type: string; + count: number; + }>; +} + +export interface FolderManagerProps { + onFolderChanged?: () => void; +} + +// ============================================================================ +// BACKEND TYPES +// ============================================================================ + +export interface HostConfig { + host: SSHHost; + tunnels: TunnelConfig[]; +} + +export interface VerificationData { + conn: Client; + timeout: NodeJS.Timeout; + startTime: number; + attempts: number; + maxAttempts: number; +} + +// ============================================================================ +// UTILITY TYPES +// ============================================================================ + +export type Optional = Omit & Partial>; + +export type RequiredFields = T & Required>; + +export type PartialExcept = Partial & Pick; \ No newline at end of file diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index ca1ab4f0..e78b59c0 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -313,17 +313,30 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
- +
diff --git a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx index 06ebd274..87e9a11d 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx @@ -19,34 +19,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import React, { useEffect, useRef, useState } from "react" import { Alert, AlertDescription } from "@/components/ui/alert" import { toast } from "sonner" -import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios' +import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios' import { useTranslation } from "react-i18next" - -interface Credential { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: 'password' | 'key'; - username: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; -} - -interface CredentialEditorProps { - editingCredential?: Credential | null; - onFormSubmit?: () => void; -} +import type { Credential, CredentialEditorProps } from '../../../types/index.js' export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) { const { t } = useTranslation(); const [credentials, setCredentials] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); + const [fullCredentialDetails, setFullCredentialDetails] = useState(null); const [authTab, setAuthTab] = useState<'password' | 'key'>('password'); @@ -60,8 +42,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential const uniqueFolders = [...new Set( credentialsData .filter(credential => credential.folder && credential.folder.trim() !== '') - .map(credential => credential.folder) - )].sort(); + .map(credential => credential.folder!) + )].sort() as string[]; setFolders(uniqueFolders); } catch (error) { @@ -73,6 +55,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential fetchData(); }, []); + useEffect(() => { + const fetchCredentialDetails = async () => { + if (editingCredential) { + try { + const fullDetails = await getCredentialDetails(editingCredential.id); + setFullCredentialDetails(fullDetails); + } catch (error) { + console.error('Failed to fetch credential details:', error); + toast.error(t('credentials.failedToFetchCredentialDetails')); + } + } else { + setFullCredentialDetails(null); + } + }; + + fetchCredentialDetails(); + }, [editingCredential, t]); + const formSchema = z.object({ name: z.string().min(1), description: z.string().optional(), @@ -81,7 +81,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential authType: z.enum(['password', 'key']), username: z.string().min(1), password: z.string().optional(), - key: z.instanceof(File).optional().nullable(), + key: z.any().optional().nullable(), keyPassword: z.string().optional(), keyType: z.enum([ 'rsa', @@ -127,24 +127,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential }); useEffect(() => { - if (editingCredential) { - const defaultAuthType = editingCredential.key ? 'key' : 'password'; + if (editingCredential && fullCredentialDetails) { + const defaultAuthType = fullCredentialDetails.authType; setAuthTab(defaultAuthType); form.reset({ - name: editingCredential.name || "", - description: editingCredential.description || "", - folder: editingCredential.folder || "", - tags: editingCredential.tags || [], + name: fullCredentialDetails.name || "", + description: fullCredentialDetails.description || "", + folder: fullCredentialDetails.folder || "", + tags: fullCredentialDetails.tags || [], authType: defaultAuthType as 'password' | 'key', - username: editingCredential.username || "", - password: "", + username: fullCredentialDetails.username || "", + password: fullCredentialDetails.password || "", key: null, - keyPassword: "", - keyType: (editingCredential.keyType as any) || "rsa", + keyPassword: fullCredentialDetails.keyPassword || "", + keyType: (fullCredentialDetails.keyType as any) || "rsa", }); - } else { + } else if (!editingCredential) { setAuthTab('password'); form.reset({ @@ -160,7 +160,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential keyType: "rsa", }); } - }, [editingCredential, form]); + }, [editingCredential, fullCredentialDetails, form]); const onSubmit = async (data: any) => { try { @@ -170,11 +170,38 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential formData.name = formData.username; } + const submitData: any = { + name: formData.name, + description: formData.description, + folder: formData.folder, + tags: formData.tags, + authType: formData.authType, + username: formData.username, + keyType: formData.keyType + }; + + if (formData.password !== undefined) { + submitData.password = formData.password; + } + + if (formData.key !== undefined) { + if (formData.key instanceof File) { + const keyContent = await formData.key.text(); + submitData.key = keyContent; + } else { + submitData.key = formData.key; + } + } + + if (formData.keyPassword !== undefined) { + submitData.keyPassword = formData.keyPassword; + } + if (editingCredential) { - await updateCredential(editingCredential.id, formData); + await updateCredential(editingCredential.id, submitData); toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name })); } else { - await createCredential(formData); + await createCredential(submitData); toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name })); } diff --git a/src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx b/src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx index 33e5e162..87d11b7f 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx @@ -27,45 +27,11 @@ import { import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; - -interface Credential { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: 'password' | 'key'; - username: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; -} - -interface CredentialWithSecrets extends Credential { - password?: string; - key?: string; - keyPassword?: string; -} - -interface HostInfo { - id: number; - name?: string; - ip: string; - port: number; - createdAt: string; -} - -interface CredentialViewerProps { - credential: Credential; - onClose: () => void; - onEdit: () => void; -} +import type { Credential, HostInfo, CredentialViewerProps } from '../../../types/index.js'; const CredentialViewer: React.FC = ({ credential, onClose, onEdit }) => { const { t } = useTranslation(); - const [credentialDetails, setCredentialDetails] = useState(null); + const [credentialDetails, setCredentialDetails] = useState(null); const [hostsUsing, setHostsUsing] = useState([]); const [loading, setLoading] = useState(true); const [showSensitive, setShowSensitive] = useState>({}); diff --git a/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx b/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx index 864226cd..03676216 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx @@ -21,25 +21,7 @@ import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import {CredentialEditor} from './CredentialEditor'; import CredentialViewer from './CredentialViewer'; - -interface Credential { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: 'password' | 'key'; - username: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; -} - -interface CredentialsManagerProps { - onEditCredential?: (credential: Credential) => void; -} +import type { Credential, CredentialsManagerProps } from '../../../types/index.js'; export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) { const { t } = useTranslation(); @@ -83,20 +65,16 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName })); await fetchCredentials(); window.dispatchEvent(new CustomEvent('credentials:changed')); - } catch (err) { - toast.error(t('credentials.failedToDeleteCredential')); + } catch (err: any) { + if (err.response?.data?.details) { + toast.error(`${err.response.data.error}\n${err.response.data.details}`); + } else { + toast.error(t('credentials.failedToDeleteCredential')); + } } } }; - - - - - - - - const filteredAndSortedCredentials = useMemo(() => { let filtered = credentials; diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index b2d06587..5b6902be 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -26,41 +26,7 @@ import { getSSHStatus, connectSSH } from '@/ui/main-axios.ts'; - -interface Tab { - id: string | number; - title: string; - fileName: string; - content: string; - isSSH?: boolean; - sshSessionId?: string; - filePath?: string; - loading?: boolean; - dirty?: boolean; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} +import type { SSHHost, Tab, FileManagerProps } from '../../../types/index.js'; export function FileManager({onSelectView, embedded = false, initialHost = null}: { onSelectView?: (view: string) => void, @@ -378,12 +344,16 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} if (!status.connected) { const connectPromise = connectSSH(tab.sshSessionId, { + hostId: currentHost.id, ip: currentHost.ip, port: currentHost.port, username: currentHost.username, password: currentHost.password, sshKey: currentHost.key, - keyPassword: currentHost.keyPassword + keyPassword: currentHost.keyPassword, + authType: currentHost.authType, + credentialId: currentHost.credentialId, + userId: currentHost.userId }); const connectTimeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000) diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx index 7e735ca2..a160f9dd 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx @@ -5,19 +5,7 @@ import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx import {Input} from '@/components/ui/input.tsx'; import {useState} from 'react'; import {useTranslation} from 'react-i18next'; - -interface FileItem { - name: string; - path: string; - isPinned?: boolean; - type: 'file' | 'directory'; - sshSessionId?: string; -} - -interface ShortcutItem { - name: string; - path: string; -} +import type { FileItem, ShortcutItem } from '../../../types/index.js'; interface FileManagerHomeViewProps { recent: FileItem[]; diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx index 290c4ab7..bda89c7e 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx @@ -19,29 +19,7 @@ import { getSSHStatus, connectSSH } from '@/ui/main-axios.ts'; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} +import type { SSHHost, FileManagerLeftSidebarProps } from '../../../types/index.js'; const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( {onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: { @@ -133,12 +111,16 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( } const connectionConfig = { + hostId: server.id, ip: server.ip, port: server.port, username: server.username, password: server.password, sshKey: server.key, keyPassword: server.keyPassword, + authType: server.authType, + credentialId: server.credentialId, + userId: server.userId, }; await connectSSH(sessionId, connectionConfig); diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx index 4d71fc02..36ce4c13 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx @@ -17,14 +17,7 @@ import { } from 'lucide-react'; import {cn} from '@/lib/utils.ts'; import {useTranslation} from 'react-i18next'; - -interface FileManagerOperationsProps { - currentPath: string; - sshSessionId: string | null; - onOperationComplete: () => void; - onError: (error: string) => void; - onSuccess: (message: string) => void; -} +import type { FileManagerOperationsProps } from '../../../types/index.js'; export function FileManagerOperations({ currentPath, diff --git a/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx b/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx index eb0ec554..9694e07c 100644 --- a/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx +++ b/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx @@ -13,6 +13,7 @@ import { import { getFoldersWithStats, renameFolder } from '@/ui/main-axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import type { FolderManagerProps } from '../../../types/index.js'; interface FolderStats { name: string; @@ -24,10 +25,6 @@ interface FolderStats { }>; } -interface FolderManagerProps { - onFolderChanged?: () => void; -} - export function FolderManager({ onFolderChanged }: FolderManagerProps) { const { t } = useTranslation(); const [folders, setFolders] = useState([]); diff --git a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx index f2b19982..9f698024 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx @@ -7,34 +7,7 @@ import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManag import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useTranslation} from "react-i18next"; - -interface HostManagerProps { - onSelectView: (view: string) => void; - isTopbarOpen?: boolean; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} +import type { SSHHost, HostManagerProps } from '../../../types/index.js'; export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { const {t} = useTranslation(); diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx index 16aa4412..b1942590 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx @@ -106,7 +106,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos authType: z.enum(['password', 'key', 'credential']), credentialId: z.number().optional().nullable(), password: z.string().optional(), - key: z.instanceof(File).optional().nullable(), + key: z.any().optional().nullable(), keyPassword: z.string().optional(), keyType: z.enum([ 'auto', @@ -205,7 +205,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos useEffect(() => { if (editingHost) { const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password'); - setAuthTab(defaultAuthType); form.reset({ @@ -219,7 +218,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos authType: defaultAuthType as 'password' | 'key' | 'credential', credentialId: editingHost.credentialId || null, password: editingHost.password || "", - key: editingHost.key ? new File([editingHost.key], "key.pem") : null, + key: null, keyPassword: editingHost.keyPassword || "", keyType: (editingHost.keyType as any) || "auto", enableTerminal: editingHost.enableTerminal !== false, @@ -230,7 +229,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos }); } else { setAuthTab('password'); - form.reset({ name: "", ip: "", @@ -283,11 +281,52 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos formData.name = `${formData.username}@${formData.ip}`; } + const submitData: any = { + name: formData.name, + ip: formData.ip, + port: formData.port, + username: formData.username, + folder: formData.folder, + tags: formData.tags, + pin: formData.pin, + authType: formData.authType, + enableTerminal: formData.enableTerminal, + enableTunnel: formData.enableTunnel, + enableFileManager: formData.enableFileManager, + defaultPath: formData.defaultPath, + tunnelConnections: formData.tunnelConnections + }; + + if (formData.authType === 'credential') { + submitData.credentialId = formData.credentialId; + submitData.password = null; + submitData.key = null; + submitData.keyPassword = null; + submitData.keyType = null; + } else if (formData.authType === 'password') { + submitData.credentialId = null; + submitData.password = formData.password; + submitData.key = null; + submitData.keyPassword = null; + submitData.keyType = null; + } else if (formData.authType === 'key') { + submitData.credentialId = null; + submitData.password = null; + if (formData.key instanceof File) { + const keyContent = await formData.key.text(); + submitData.key = keyContent; + } else { + submitData.key = formData.key; + } + submitData.keyPassword = formData.keyPassword; + submitData.keyType = formData.keyType; + } + if (editingHost) { - await updateSSHHost(editingHost.id, formData); + await updateSSHHost(editingHost.id, submitData); toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name })); } else { - await createSSHHost(formData); + await createSSHHost(submitData); toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name })); } diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx index 69a8c8c9..9a36cd57 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx @@ -27,29 +27,7 @@ import { Pencil } from "lucide-react"; import {Separator} from "@/components/ui/separator.tsx"; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -interface SSHManagerHostViewerProps { - onEditHost?: (host: SSHHost) => void; -} +import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js'; export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const {t} = useTranslation(); diff --git a/src/ui/Desktop/Apps/Tunnel/Tunnel.tsx b/src/ui/Desktop/Apps/Tunnel/Tunnel.tsx index 6c45bca8..f5edee6d 100644 --- a/src/ui/Desktop/Apps/Tunnel/Tunnel.tsx +++ b/src/ui/Desktop/Apps/Tunnel/Tunnel.tsx @@ -1,52 +1,7 @@ import React, {useState, useEffect, useCallback} from "react"; import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx"; import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts"; - -interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; -} - -interface TunnelStatus { - status: string; - reason?: string; - errorType?: string; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - retryExhausted?: boolean; -} - -interface SSHTunnelProps { - filterHostKey?: string; -} +import type { SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps } from '../../../types/index.js'; export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement { const [allHosts, setAllHosts] = useState([]); @@ -163,6 +118,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement { sourceSSHKey: host.authType === 'key' ? host.key : undefined, sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined, sourceKeyType: host.authType === 'key' ? host.keyType : undefined, + sourceCredentialId: host.credentialId, + sourceUserId: host.userId, endpointIP: endpointHost.ip, endpointSSHPort: endpointHost.port, endpointUsername: endpointHost.username, @@ -171,6 +128,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement { endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined, endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined, endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined, + endpointCredentialId: endpointHost.credentialId, + endpointUserId: endpointHost.userId, sourcePort: tunnel.sourcePort, endpointPort: tunnel.endpointPort, maxRetries: tunnel.maxRetries, diff --git a/src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx b/src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx index 52105a53..b4782c34 100644 --- a/src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx +++ b/src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx @@ -20,65 +20,7 @@ import { X } from "lucide-react"; import {Badge} from "@/components/ui/badge.tsx"; - -const CONNECTION_STATES = { - DISCONNECTED: "disconnected", - CONNECTING: "connecting", - CONNECTED: "connected", - VERIFYING: "verifying", - FAILED: "failed", - UNSTABLE: "unstable", - RETRYING: "retrying", - WAITING: "waiting", - DISCONNECTING: "disconnecting" -}; - -interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; -} - -interface TunnelStatus { - status: string; - reason?: string; - errorType?: string; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - retryExhausted?: boolean; -} - -interface SSHTunnelObjectProps { - host: SSHHost; - tunnelStatuses: Record; - tunnelActions: Record; - onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; - compact?: boolean; - bare?: boolean; -} +import type { SSHHost, TunnelConnection, TunnelStatus, CONNECTION_STATES, SSHTunnelObjectProps } from '../../../types/index.js'; export function TunnelObject({ host, diff --git a/src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx b/src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx index dcec9123..d4c059ce 100644 --- a/src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx +++ b/src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx @@ -1,44 +1,7 @@ import React from "react"; import {TunnelObject} from "./TunnelObject.tsx"; import {useTranslation} from 'react-i18next'; - -interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; -} - -interface TunnelStatus { - status: string; - reason?: string; - errorType?: string; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - retryExhausted?: boolean; -} +import type { SSHHost, TunnelConnection, TunnelStatus } from '../../../types/index.js'; interface SSHTunnelViewerProps { hosts: SSHHost[]; diff --git a/src/ui/Desktop/Homepage/HomepageAlertCard.tsx b/src/ui/Desktop/Homepage/HomepageAlertCard.tsx index e2f774fa..201e9dfc 100644 --- a/src/ui/Desktop/Homepage/HomepageAlertCard.tsx +++ b/src/ui/Desktop/Homepage/HomepageAlertCard.tsx @@ -4,17 +4,7 @@ import {Button} from "@/components/ui/button.tsx"; import {Badge} from "@/components/ui/badge.tsx"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; import {useTranslation} from "react-i18next"; - -interface TermixAlert { - id: string; - title: string; - message: string; - expiresAt: string; - priority?: 'low' | 'medium' | 'high' | 'critical'; - type?: 'info' | 'warning' | 'error' | 'success'; - actionUrl?: string; - actionText?: string; -} +import type { TermixAlert } from '../../../types/index.js'; interface AlertCardProps { alert: TermixAlert; diff --git a/src/ui/Desktop/Homepage/HomepageAlertManager.tsx b/src/ui/Desktop/Homepage/HomepageAlertManager.tsx index 6e68b941..0e4bf66b 100644 --- a/src/ui/Desktop/Homepage/HomepageAlertManager.tsx +++ b/src/ui/Desktop/Homepage/HomepageAlertManager.tsx @@ -3,17 +3,7 @@ import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {Button} from "@/components/ui/button.tsx"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; import {useTranslation} from "react-i18next"; - -interface TermixAlert { - id: string; - title: string; - message: string; - expiresAt: string; - priority?: 'low' | 'medium' | 'high' | 'critical'; - type?: 'info' | 'warning' | 'error' | 'success'; - actionUrl?: string; - actionText?: string; -} +import type { TermixAlert } from '../../../types/index.js'; interface AlertManagerProps { userId: string | null; diff --git a/src/ui/Desktop/Navigation/Hosts/Host.tsx b/src/ui/Desktop/Navigation/Hosts/Host.tsx index 930bf57b..bf60f5f1 100644 --- a/src/ui/Desktop/Navigation/Hosts/Host.tsx +++ b/src/ui/Desktop/Navigation/Hosts/Host.tsx @@ -5,33 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Server, Terminal} from "lucide-react"; import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import {getServerStatusById} from "@/ui/main-axios.ts"; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -interface HostProps { - host: SSHHost; -} +import type { SSHHost, HostProps } from '../../../types/index.js'; export function Host({host}: HostProps): React.ReactElement { const {addTab} = useTabs(); diff --git a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx index 47bca744..51d6a457 100644 --- a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx @@ -1,13 +1,8 @@ import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import {useTranslation} from 'react-i18next'; +import type { TabContextTab } from '../../../types/index.js'; -export interface Tab { - id: number; - type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager'; - title: string; - hostConfig?: any; - terminalRef?: React.RefObject; -} +export type Tab = TabContextTab; interface TabContextType { tabs: Tab[]; diff --git a/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx b/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx index eb594d73..9d016020 100644 --- a/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx @@ -1,13 +1,8 @@ import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import {useTranslation} from 'react-i18next'; +import type { TabContextTab } from '../../../../types/index.js'; -export interface Tab { - id: number; - type: 'terminal'; - title: string; - hostConfig?: any; - terminalRef?: React.RefObject; -} +export type Tab = TabContextTab; interface TabContextType { tabs: Tab[]; diff --git a/src/ui/Mobile/Navigation/Tabs/TabContext.tsx b/src/ui/Mobile/Navigation/Tabs/TabContext.tsx index eb594d73..b6abd5ea 100644 --- a/src/ui/Mobile/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Mobile/Navigation/Tabs/TabContext.tsx @@ -1,13 +1,8 @@ import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import {useTranslation} from 'react-i18next'; +import type { TabContextTab } from '../../../types/index.js'; -export interface Tab { - id: number; - type: 'terminal'; - title: string; - hostConfig?: any; - terminalRef?: React.RefObject; -} +export type Tab = TabContextTab; interface TabContextType { tabs: Tab[]; diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 705260ae..3cfbc732 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1,103 +1,16 @@ import axios, { AxiosError, type AxiosInstance } from 'axios'; - -// ============================================================================ -// TYPES & INTERFACES -// ============================================================================ - -interface SSHHostData { - name?: string; - ip: string; - port: number; - username: string; - folder?: string; - tags?: string[]; - pin?: boolean; - authType: 'password' | 'key' | 'credential'; - password?: string; - key?: File | null; - keyPassword?: string; - keyType?: string; - credentialId?: number | null; - enableTerminal?: boolean; - enableTunnel?: boolean; - enableFileManager?: boolean; - defaultPath?: string; - tunnelConnections?: any[]; -} - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -interface TunnelConfig { - name: string; - hostName: string; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword?: string; - sourceAuthMethod: string; - sourceSSHKey?: string; - sourceKeyPassword?: string; - sourceKeyType?: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword?: string; - endpointAuthMethod: string; - endpointSSHKey?: string; - endpointKeyPassword?: string; - endpointKeyType?: string; - sourcePort: number; - endpointPort: number; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - isPinned: boolean; -} - -interface TunnelStatus { - status: string; - reason?: string; - errorType?: string; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - retryExhausted?: boolean; -} - -interface FileManagerFile { - name: string; - path: string; - type?: 'file' | 'directory'; - isSSH?: boolean; - sshSessionId?: string; -} - -interface FileManagerShortcut { - name: string; - path: string; -} +import type { + SSHHost, + SSHHostData, + TunnelConfig, + TunnelStatus, + Credential, + CredentialData, + HostInfo, + ApiResponse, + FileManagerFile, + FileManagerShortcut +} from '../types/index.js'; interface FileManagerOperation { name: string; @@ -203,10 +116,26 @@ function createApiInstance(baseURL: string): AxiosInstance { }); instance.interceptors.response.use( - (response) => response, + (response) => { + // Log successful requests in development + if (process.env.NODE_ENV === 'development') { + console.log(`✅ API ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`); + } + return response; + }, (error: AxiosError) => { + // Improved error logging + const method = error.config?.method?.toUpperCase() || 'UNKNOWN'; + const url = error.config?.url || 'UNKNOWN'; + const status = error.response?.status || 'NETWORK_ERROR'; + const message = error.response?.data?.error || (error as Error).message || 'Unknown error'; + + console.error(`❌ API ${method} ${url} - ${status}: ${message}`); + if (error.response?.status === 401) { + console.warn('🔐 Authentication failed, clearing token'); document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + localStorage.removeItem('jwt'); } return Promise.reject(error); } @@ -296,17 +225,33 @@ function handleApiError(error: unknown, operation: string): never { if (axios.isAxiosError(error)) { const status = error.response?.status; const message = error.response?.data?.error || error.message; + const code = error.response?.data?.code; + + // Enhanced error logging + console.error(`🚨 API Error in ${operation}:`, { + status, + message, + code, + url: error.config?.url, + method: error.config?.method + }); if (status === 401) { - throw new ApiError('Authentication required', 401); + throw new ApiError('Authentication required. Please log in again.', 401, 'AUTH_REQUIRED'); } else if (status === 403) { - throw new ApiError('Access denied', 403); + throw new ApiError('Access denied. You do not have permission to perform this action.', 403, 'ACCESS_DENIED'); } else if (status === 404) { - throw new ApiError('Resource not found', 404); + throw new ApiError('Resource not found. The requested item may have been deleted.', 404, 'NOT_FOUND'); + } else if (status === 409) { + throw new ApiError('Conflict. The resource already exists or is in use.', 409, 'CONFLICT'); + } else if (status === 422) { + throw new ApiError('Validation error. Please check your input and try again.', 422, 'VALIDATION_ERROR'); } else if (status && status >= 500) { - throw new ApiError('Server error occurred', status); + throw new ApiError('Server error occurred. Please try again later.', status, 'SERVER_ERROR'); + } else if (status === 0) { + throw new ApiError('Network error. Please check your connection and try again.', 0, 'NETWORK_ERROR'); } else { - throw new ApiError(message || `Failed to ${operation}`, status); + throw new ApiError(message || `Failed to ${operation}`, status, code); } } @@ -314,7 +259,9 @@ function handleApiError(error: unknown, operation: string): never { throw error; } - throw new ApiError(`Unexpected error during ${operation}: ${error instanceof Error ? error.message : 'Unknown error'}`); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`🚨 Unexpected error in ${operation}:`, error); + throw new ApiError(`Unexpected error during ${operation}: ${errorMessage}`, undefined, 'UNKNOWN_ERROR'); } // ============================================================================ @@ -602,12 +549,16 @@ export async function removeFileManagerShortcut(shortcut: FileManagerOperation): // ============================================================================ export async function connectSSH(sessionId: string, config: { + hostId?: number; ip: string; port: number; username: string; password?: string; sshKey?: string; keyPassword?: string; + authType?: string; + credentialId?: number; + userId?: string; }): Promise { try { const response = await fileManagerApi.post('/ssh/connect', { @@ -1119,15 +1070,47 @@ export async function getCredentialFolders(): Promise { } } -export async function applyCredentialToHost(credentialId: number, hostId: number): Promise { +// Get SSH host with resolved credentials +export async function getSSHHostWithCredentials(hostId: number): Promise { try { - const response = await authApi.post(`/credentials/${credentialId}/apply-to-host/${hostId}`); + const response = await sshHostApi.get(`/db/host/${hostId}/with-credentials`); + return response.data; + } catch (error) { + handleApiError(error, 'fetch SSH host with credentials'); + } +} + +// Apply credential to SSH host +export async function applyCredentialToHost(hostId: number, credentialId: number): Promise { + try { + const response = await sshHostApi.post(`/db/host/${hostId}/apply-credential`, { credentialId }); return response.data; } catch (error) { handleApiError(error, 'apply credential to host'); } } +// Remove credential from SSH host +export async function removeCredentialFromHost(hostId: number): Promise { + try { + const response = await sshHostApi.delete(`/db/host/${hostId}/credential`); + return response.data; + } catch (error) { + handleApiError(error, 'remove credential from host'); + } +} + +// Migrate host to managed credential +export async function migrateHostToCredential(hostId: number, credentialName: string): Promise { + try { + const response = await sshHostApi.post(`/db/host/${hostId}/migrate-to-credential`, { credentialName }); + return response.data; + } catch (error) { + handleApiError(error, 'migrate host to credential'); + } +} + + // ============================================================================ // SSH FOLDER MANAGEMENT // ============================================================================