Merge Luke and Zac
This commit is contained in:
370
src/backend/services/credentials.ts
Normal file
370
src/backend/services/credentials.ts
Normal 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();
|
||||
133
src/backend/services/encryption.ts
Normal file
133
src/backend/services/encryption.ts
Normal 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 };
|
||||
277
src/backend/services/ssh-host.ts
Normal file
277
src/backend/services/ssh-host.ts
Normal 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();
|
||||
Reference in New Issue
Block a user