diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d1d55c02..cf347b2f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,4 +1,103 @@ { + "credentials": { + "credentialsManager": "Credentials Manager", + "manageYourSSHCredentials": "Manage your SSH credentials securely", + "addCredential": "Add Credential", + "createCredential": "Create Credential", + "editCredential": "Edit Credential", + "viewCredential": "View Credential", + "duplicateCredential": "Duplicate Credential", + "deleteCredential": "Delete Credential", + "updateCredential": "Update Credential", + "credentialName": "Credential Name", + "credentialDescription": "Description", + "searchCredentials": "Search credentials...", + "selectFolder": "Select Folder", + "selectAuthType": "Select Auth Type", + "allFolders": "All Folders", + "allAuthTypes": "All Auth Types", + "uncategorized": "Uncategorized", + "totalCredentials": "Total", + "keyBased": "Key-based", + "passwordBased": "Password-based", + "folders": "Folders", + "noCredentialsMatchFilters": "No credentials match your filters", + "noCredentialsYet": "No credentials created yet", + "createFirstCredential": "Create your first credential", + "failedToFetchCredentials": "Failed to fetch credentials", + "credentialDeletedSuccessfully": "Credential deleted successfully", + "failedToDeleteCredential": "Failed to delete credential", + "confirmDeleteCredential": "Are you sure you want to delete credential \"{{name}}\"?", + "credentialCreatedSuccessfully": "Credential created successfully", + "credentialUpdatedSuccessfully": "Credential updated successfully", + "failedToSaveCredential": "Failed to save credential", + "failedToFetchCredentialDetails": "Failed to fetch credential details", + "failedToFetchHostsUsing": "Failed to fetch hosts using this credential", + "basicInfo": "Basic Info", + "authentication": "Authentication", + "organization": "Organization", + "basicInformation": "Basic Information", + "basicInformationDescription": "Enter the basic information for this credential", + "authenticationMethod": "Authentication Method", + "authenticationMethodDescription": "Choose how you want to authenticate with SSH servers", + "organizationDescription": "Organize your credentials with folders and tags", + "enterCredentialName": "Enter credential name", + "enterCredentialDescription": "Enter description (optional)", + "enterUsername": "Enter username", + "nameIsRequired": "Credential name is required", + "usernameIsRequired": "Username is required", + "authenticationType": "Authentication Type", + "passwordAuthDescription": "Use password authentication", + "sshKeyAuthDescription": "Use SSH key authentication", + "passwordIsRequired": "Password is required", + "sshKeyIsRequired": "SSH key is required", + "sshKeyType": "SSH Key Type", + "privateKey": "Private Key", + "enterPassword": "Enter password", + "enterPrivateKey": "Enter private key", + "keyPassphrase": "Key Passphrase", + "enterKeyPassphrase": "Enter key passphrase (optional)", + "keyPassphraseOptional": "Optional: leave empty if your key has no passphrase", + "leaveEmptyToKeepCurrent": "Leave empty to keep current value", + "uploadKeyFile": "Upload Key File", + "generateKeyPair": "Generate Key Pair", + "sshKeyGenerationNotImplemented": "SSH key generation feature coming soon", + "connectionTestingNotImplemented": "Connection testing feature coming soon", + "testConnection": "Test Connection", + "selectOrCreateFolder": "Select or create folder", + "noFolder": "No folder", + "orCreateNewFolder": "Or create new folder", + "addTag": "Add tag", + "saving": "Saving...", + "overview": "Overview", + "security": "Security", + "usage": "Usage", + "securityDetails": "Security Details", + "securityDetailsDescription": "View encrypted credential information", + "credentialSecured": "Credential Secured", + "credentialSecuredDescription": "All sensitive data is encrypted with AES-256", + "passwordAuthentication": "Password Authentication", + "keyAuthentication": "Key Authentication", + "keyType": "Key Type", + "securityReminder": "Security Reminder", + "securityReminderText": "Never share your credentials. All data is encrypted at rest.", + "hostsUsingCredential": "Hosts Using This Credential", + "noHostsUsingCredential": "No hosts are currently using this credential", + "timesUsed": "Times Used", + "lastUsed": "Last Used", + "connectedHosts": "Connected Hosts", + "created": "Created", + "lastModified": "Last Modified", + "usageStatistics": "Usage Statistics", + "copiedToClipboard": "{{field}} copied to clipboard", + "failedToCopy": "Failed to copy to clipboard", + "sshKey": "SSH Key", + "createCredentialDescription": "Create a new SSH credential for secure access", + "editCredentialDescription": "Update the credential information", + "listView": "List", + "folderView": "Folders", + "unknown": "Unknown" + }, "sshTools": { "title": "SSH Tools", "closeTools": "Close SSH Tools", @@ -120,6 +219,7 @@ "nav": { "home": "Home", "hosts": "Hosts", + "credentials": "Credentials", "terminal": "Terminal", "tunnels": "Tunnels", "fileManager": "File Manager", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index ed52f6fa..1a4052fd 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1,4 +1,103 @@ { + "credentials": { + "credentialsManager": "凭据管理器", + "manageYourSSHCredentials": "安全管理您的SSH凭据", + "addCredential": "添加凭据", + "createCredential": "创建凭据", + "editCredential": "编辑凭据", + "viewCredential": "查看凭据", + "duplicateCredential": "复制凭据", + "deleteCredential": "删除凭据", + "updateCredential": "更新凭据", + "credentialName": "凭据名称", + "credentialDescription": "描述", + "searchCredentials": "搜索凭据...", + "selectFolder": "选择文件夹", + "selectAuthType": "选择认证类型", + "allFolders": "所有文件夹", + "allAuthTypes": "所有认证类型", + "uncategorized": "未分类", + "totalCredentials": "总计", + "keyBased": "密钥认证", + "passwordBased": "密码认证", + "folders": "文件夹", + "noCredentialsMatchFilters": "没有符合筛选条件的凭据", + "noCredentialsYet": "还未创建凭据", + "createFirstCredential": "创建您的第一个凭据", + "failedToFetchCredentials": "获取凭据失败", + "credentialDeletedSuccessfully": "凭据删除成功", + "failedToDeleteCredential": "删除凭据失败", + "confirmDeleteCredential": "确定要删除凭据「{{name}}」吗?", + "credentialCreatedSuccessfully": "凭据创建成功", + "credentialUpdatedSuccessfully": "凭据更新成功", + "failedToSaveCredential": "保存凭据失败", + "failedToFetchCredentialDetails": "获取凭据详情失败", + "failedToFetchHostsUsing": "获取使用此凭据的主机失败", + "basicInfo": "基本信息", + "authentication": "认证方式", + "organization": "组织管理", + "basicInformation": "基本信息", + "basicInformationDescription": "输入此凭据的基本信息", + "authenticationMethod": "认证方式", + "authenticationMethodDescription": "选择如何与SSH服务器进行认证", + "organizationDescription": "使用文件夹和标签来组织您的凭据", + "enterCredentialName": "输入凭据名称", + "enterCredentialDescription": "输入描述(可选)", + "enterUsername": "输入用户名", + "nameIsRequired": "凭据名称是必需的", + "usernameIsRequired": "用户名是必需的", + "authenticationType": "认证类型", + "passwordAuthDescription": "使用密码认证", + "sshKeyAuthDescription": "使用SSH密钥认证", + "passwordIsRequired": "密码是必需的", + "sshKeyIsRequired": "SSH密钥是必需的", + "sshKeyType": "SSH密钥类型", + "privateKey": "私钥", + "enterPassword": "输入密码", + "enterPrivateKey": "输入私钥", + "keyPassphrase": "密钥密码", + "enterKeyPassphrase": "输入密钥密码(可选)", + "keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空", + "leaveEmptyToKeepCurrent": "留空以保持当前值", + "uploadKeyFile": "上传密钥文件", + "generateKeyPair": "生成密钥对", + "sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出", + "connectionTestingNotImplemented": "连接测试功能即将推出", + "testConnection": "测试连接", + "selectOrCreateFolder": "选择或创建文件夹", + "noFolder": "无文件夹", + "orCreateNewFolder": "或创建新文件夹", + "addTag": "添加标签", + "saving": "保存中...", + "overview": "概览", + "security": "安全", + "usage": "使用情况", + "securityDetails": "安全详情", + "securityDetailsDescription": "查看加密的凭据信息", + "credentialSecured": "凭据已加密", + "credentialSecuredDescription": "所有敏感数据均使用AES-256加密", + "passwordAuthentication": "密码认证", + "keyAuthentication": "密钥认证", + "keyType": "密钥类型", + "securityReminder": "安全提醒", + "securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。", + "hostsUsingCredential": "使用此凭据的主机", + "noHostsUsingCredential": "当前没有主机使用此凭据", + "timesUsed": "使用次数", + "lastUsed": "最后使用", + "connectedHosts": "连接的主机", + "created": "创建时间", + "lastModified": "最后修改", + "usageStatistics": "使用统计", + "copiedToClipboard": "{{field}}已复制到剪贴板", + "failedToCopy": "复制到剪贴板失败", + "sshKey": "SSH密钥", + "createCredentialDescription": "创建新的SSH凭据以进行安全访问", + "editCredentialDescription": "更新凭据信息", + "listView": "列表", + "folderView": "文件夹", + "unknown": "未知" + }, "sshTools": { "title": "SSH 工具", "closeTools": "关闭 SSH 工具", @@ -120,6 +219,7 @@ "nav": { "home": "首页", "hosts": "主机", + "credentials": "凭据", "terminal": "终端", "tunnels": "隧道", "fileManager": "文件管理器", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 1a8a0d7e..a56faf1d 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -3,9 +3,12 @@ import bodyParser from 'body-parser'; 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'; const app = express(); @@ -143,9 +146,26 @@ app.get('/health', (req, res) => { }); app.get('/version', async (req, res) => { - const localVersion = process.env.VERSION; + let localVersion = process.env.VERSION; + + // Fallback to package.json version if env variable not set + if (!localVersion) { + try { + const packagePath = path.resolve(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + localVersion = packageJson.version; + logger.info(`Using version from package.json: ${localVersion}`); + } catch (error) { + logger.error('Failed to read version from package.json:', error); + } + } + + // Debug logging + logger.debug(`Final version: ${localVersion}`); + logger.debug(`Working directory: ${process.cwd()}`); if (!localVersion) { + logger.error('No version information available'); return res.status(404).send('Local Version Not Set'); } @@ -235,19 +255,11 @@ app.get('/releases/rss', async (req, res) => { } }); -// Health check endpoint for Electron backend manager -app.get('/health', (req, res) => { - res.status(200).json({ - status: 'ok', - timestamp: new Date().toISOString(), - service: 'database-api', - port: PORT - }); -}); app.use('/users', userRoutes); app.use('/ssh', sshRoutes); 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); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 2dd60b79..4e08b3db 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -4,6 +4,7 @@ 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()}]`); @@ -432,6 +433,9 @@ 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'); @@ -440,15 +444,27 @@ const migrateSchema = () => { logger.success('Schema migration completed'); }; -migrateSchema(); +const initializeDatabase = async () => { + migrateSchema(); -try { - const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); - if (!row) { - sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); + // 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) { + sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); + } + } catch (e) { + logger.warn('Could not initialize default settings'); } -} catch (e) { - logger.warn('Could not initialize default settings'); -} +}; + +// Initialize database (async) +initializeDatabase().catch(error => { + logger.error('Failed to initialize database:', error); + process.exit(1); +}); 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 759efbdf..6fc5cb54 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -39,10 +39,13 @@ export const sshData = sqliteTable('ssh_data', { tags: text('tags'), pin: integer('pin', {mode: 'boolean'}).notNull().default(false), authType: text('auth_type').notNull(), + // Legacy credential fields - kept for backward compatibility password: text('password'), key: text('key', {length: 8192}), keyPassword: text('key_password'), keyType: text('key_type'), + // New credential management + credentialId: integer('credential_id').references(() => sshCredentials.id), enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), tunnelConnections: text('tunnel_connections'), @@ -84,4 +87,32 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', { userId: text('user_id').notNull().references(() => users.id), alertId: text('alert_id').notNull(), dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +// SSH Credentials Management Tables +export const sshCredentials = sqliteTable('ssh_credentials', { + id: integer('id').primaryKey({autoIncrement: true}), + userId: text('user_id').notNull().references(() => users.id), + name: text('name').notNull(), + description: text('description'), + folder: text('folder'), + tags: text('tags'), + authType: text('auth_type').notNull(), // 'password' | 'key' + 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' + usageCount: integer('usage_count').notNull().default(0), + lastUsed: text('last_used'), + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshCredentialUsage = sqliteTable('ssh_credential_usage', { + id: integer('id').primaryKey({autoIncrement: true}), + credentialId: integer('credential_id').notNull().references(() => sshCredentials.id), + hostId: integer('host_id').notNull().references(() => sshData.id), + userId: text('user_id').notNull().references(() => users.id), + usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`), }); \ No newline at end of file diff --git a/src/backend/database/migrations/001-add-credentials-tables.ts b/src/backend/database/migrations/001-add-credentials-tables.ts new file mode 100644 index 00000000..a163856e --- /dev/null +++ b/src/backend/database/migrations/001-add-credentials-tables.ts @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..a37566ee --- /dev/null +++ b/src/backend/database/migrations/migrator.ts @@ -0,0 +1,261 @@ +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/credentials.ts b/src/backend/database/routes/credentials.ts new file mode 100644 index 00000000..90160e3e --- /dev/null +++ b/src/backend/database/routes/credentials.ts @@ -0,0 +1,270 @@ +import express from 'express'; +import {credentialService} from '../../services/credentials.js'; +import type {Request, Response, NextFunction} from 'express'; +import jwt from 'jsonwebtoken'; +import chalk from 'chalk'; + +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(); + +interface JWTPayload { + userId: string; + iat?: number; + exp?: number; +} + +function isNonEmptyString(val: any): val is string { + return typeof val === 'string' && val.trim().length > 0; +} + +function authenticateJWT(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + logger.warn('Missing or invalid Authorization header'); + return res.status(401).json({error: 'Missing or invalid Authorization header'}); + } + const token = authHeader.split(' ')[1]; + const jwtSecret = process.env.JWT_SECRET || 'secret'; + try { + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + (req as any).userId = payload.userId; + next(); + } catch (err) { + logger.warn('Invalid or expired token'); + return res.status(401).json({error: 'Invalid or expired token'}); + } +} + +// Create a new credential +// POST /credentials +router.post('/', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { + name, + description, + folder, + tags, + authType, + username, + password, + key, + keyPassword, + keyType + } = req.body; + + if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { + logger.warn('Invalid credential creation data'); + return res.status(400).json({error: 'Name and username are required'}); + } + + if (!['password', 'key'].includes(authType)) { + logger.warn('Invalid auth type'); + 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 + }); + + logger.success(`Created credential: ${name}`); + res.status(201).json(credential); + } catch (err) { + logger.error('Failed to create credential', err); + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to create credential' + }); + } +}); + +// Get all credentials for the authenticated user +// 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'); + return res.status(400).json({error: 'Invalid userId'}); + } + + try { + const credentials = await credentialService.getUserCredentials(userId); + res.json(credentials); + } catch (err) { + logger.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 /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'); + return res.status(400).json({error: 'Invalid userId'}); + } + + try { + const folders = await credentialService.getCredentialsFolders(userId); + res.json(folders); + } catch (err) { + logger.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 /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'); + return res.status(400).json({error: 'Invalid request'}); + } + + try { + const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id)); + + if (!credential) { + return res.status(404).json({error: 'Credential not found'}); + } + + res.json(credential); + } catch (err) { + logger.error('Failed to fetch credential', err); + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to fetch credential' + }); + } +}); + +// Update a credential +// PUT /credentials/:id +router.put('/:id', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {id} = req.params; + const updateData = req.body; + + if (!isNonEmptyString(userId) || !id) { + logger.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); + } catch (err) { + logger.error('Failed to update credential', err); + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to update credential' + }); + } +}); + +// Delete a credential +// DELETE /credentials/:id +router.delete('/: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 deletion'); + return res.status(400).json({error: 'Invalid request'}); + } + + try { + await credentialService.deleteCredential(userId, parseInt(id)); + logger.success(`Deleted credential ID ${id}`); + res.json({message: 'Credential deleted successfully'}); + } catch (err) { + logger.error('Failed to delete credential', err); + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to delete credential' + }); + } +}); + +// Apply a credential to an SSH host (for quick application) +// POST /credentials/:id/apply-to-host/:hostId +router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {id: credentialId, hostId} = req.params; + + if (!isNonEmptyString(userId) || !credentialId || !hostId) { + logger.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}`); + 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' + }); + } +}); + +// Get hosts using a specific credential +// GET /credentials/:id/hosts +router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {id: credentialId} = req.params; + + if (!isNonEmptyString(userId) || !credentialId) { + logger.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); + } catch (err) { + logger.error('Failed to fetch hosts using credential', err); + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential' + }); + } +}); + +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 e9ad54b8..49cf82c4 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -691,6 +691,109 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r } }); +// 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'}); + } + + try { + const {sshHostService} = await import('../../services/ssh-host.js'); + const host = await sshHostService.getHostWithCredentials(userId, parseInt(id)); + + if (!host) { + return res.status(404).json({error: 'SSH host not found'}); + } + + 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'}); + } +}); + +// 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) => { diff --git a/src/backend/services/credentials.ts b/src/backend/services/credentials.ts new file mode 100644 index 00000000..2b3b79ae --- /dev/null +++ b/src/backend/services/credentials.ts @@ -0,0 +1,370 @@ +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 new file mode 100644 index 00000000..38bcaf66 --- /dev/null +++ b/src/backend/services/encryption.ts @@ -0,0 +1,133 @@ +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 new file mode 100644 index 00000000..95a13c1a --- /dev/null +++ b/src/backend/services/ssh-host.ts @@ -0,0 +1,277 @@ +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/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 00000000..b90a1ef4 --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "../../lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +