Merge Luke and Zac

This commit is contained in:
Karmaa
2025-09-07 21:23:48 -05:00
committed by LukeGus
parent 60928ae191
commit 5f6792dc0d
38 changed files with 6648 additions and 3100 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> {
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<Migration | null> {
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<void> {
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<void> {
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 };

View File

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

View File

@@ -144,6 +144,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
username,
password,
authMethod,
authType,
credentialId,
key,
keyPassword,
keyType,
@@ -160,6 +162,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
return res.status(400).json({error: 'Invalid SSH data'});
}
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = {
userId: userId,
name,
@@ -168,7 +171,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
ip,
port,
username,
authType: authMethod,
authType: effectiveAuthType,
credentialId: credentialId || null,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
@@ -177,12 +181,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
if (effectiveAuthType === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
} else if (effectiveAuthType === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
@@ -232,6 +236,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
username,
password,
authMethod,
authType,
credentialId,
key,
keyPassword,
keyType,
@@ -249,6 +255,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
return res.status(400).json({error: 'Invalid SSH data'});
}
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = {
name,
folder,
@@ -256,7 +263,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
ip,
port,
username,
authType: authMethod,
authType: effectiveAuthType,
credentialId: credentialId || null,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
@@ -265,15 +273,23 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
if (effectiveAuthType === 'password') {
if (password) {
sshDataObj.password = password;
}
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
} else if (effectiveAuthType === 'key') {
if (key) {
sshDataObj.key = key;
}
if (keyPassword !== undefined) {
sshDataObj.keyPassword = keyPassword;
}
if (keyType) {
sshDataObj.keyType = keyType;
}
sshDataObj.password = null;
}
@@ -386,6 +402,112 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
}
});
// 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<string, {
name: string;
hostCount: number;
hosts: Array<{id: number; name?: string; ip: string}>;
}> = {};
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
});
} catch (err) {
logger.error('Failed to rename SSH folder', err);
res.status(500).json({error: 'Failed to rename SSH folder'});
}
});
// Route: Delete SSH host by id (requires JWT)
// DELETE /ssh/host/:id
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
@@ -691,6 +813,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) => {

View File

@@ -274,7 +274,7 @@ router.get('/oidc-config', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!row) {
return res.status(404).json({error: 'OIDC not configured'});
return res.json(null);
}
res.json(JSON.parse((row as any).value));
} catch (err) {

View File

@@ -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<CredentialOutput> {
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<CredentialOutput[]> {
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<CredentialWithSecrets | null> {
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<CredentialInput>): Promise<CredentialOutput> {
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<void> {
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<void> {
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<string[]> {
try {
const result = await db
.select({folder: sshCredentials.folder})
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
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();

View File

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

View File

@@ -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<SSHHostWithCredentials | null> {
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<void> {
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<void> {
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<SSHHostWithCredentials[]> {
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<SSHHostWithCredentials> {
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<number> {
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();

View File

@@ -4,18 +4,10 @@ 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';
type HostRecord = {
id: number;
ip: string;
port: number;
username?: string;
authType?: 'password' | 'key' | string;
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
};
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
type HostStatus = 'online' | 'offline';
@@ -69,7 +61,7 @@ const logger = {
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(): Promise<HostRecord[]> {
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
const url = 'http://localhost:8081/ssh/db/host/internal';
try {
const resp = await fetch(url, {
@@ -79,30 +71,55 @@ async function fetchAllHosts(): Promise<HostRecord[]> {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
id: Number(h.id),
ip: String(h.ip),
port: Number(h.port) || 22,
username: h.username,
authType: h.authType,
password: h.password ?? null,
key: h.key ?? null,
keyPassword: h.keyPassword ?? null,
keyType: h.keyType ?? null,
})).filter(h => !!h.id && !!h.ip && !!h.port);
return hosts;
const rawHosts = Array.isArray(data) ? data : [];
// Resolve credentials for each host using the same logic as main SSH connections
const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const rawHost of rawHosts) {
try {
// Use the ssh-host service to properly resolve credentials
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
if (host) {
hostsWithCredentials.push(host);
}
} catch (err) {
logger.warn(`Failed to resolve credentials for host ${rawHost.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);
return [];
}
}
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
const all = await fetchAllHosts();
return all.find(h => h.id === id);
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
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) {
return undefined;
}
// Use ssh-host service to properly resolve credentials
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
} catch (err) {
logger.error(`Failed to fetch host ${id}`, err);
return undefined;
}
}
function buildSshConfig(host: HostRecord): ConnectConfig {
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
@@ -111,37 +128,41 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
algorithms: {}
} as ConnectConfig;
// Use the same authentication logic as main SSH connections
if (host.authType === 'password') {
(base as any).password = host.password || '';
} else if (host.authType === 'key') {
if (host.key) {
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.message}`);
if (host.password) {
(base as any).password = host.password;
} else {
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
}
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
(base as any).password = host.password;
} else if (host.authType === 'key') {
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'}`);
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;
}
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const client = new Client();
let settled = false;
@@ -225,7 +246,7 @@ function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: HostRecord): Promise<{
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };