205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Client, ClientChannel, utils } from 'ssh2';
|
|
import { createHash } from 'crypto';
|
|
import { CredentialsService } from '../vault/credentials.service';
|
|
import { HostsService } from '../connections/hosts.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
export interface SshSession {
|
|
id: string;
|
|
hostId: number;
|
|
client: Client;
|
|
stream: ClientChannel | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SshConnectionService {
|
|
private readonly logger = new Logger(SshConnectionService.name);
|
|
private sessions = new Map<string, SshSession>();
|
|
|
|
constructor(
|
|
private credentials: CredentialsService,
|
|
private hosts: HostsService,
|
|
private prisma: PrismaService,
|
|
) {}
|
|
|
|
async connect(
|
|
hostId: number,
|
|
onData: (data: string) => void,
|
|
onClose: (reason: string) => void,
|
|
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
|
|
): Promise<string> {
|
|
const host = await this.hosts.findOne(hostId);
|
|
const cred = host.credentialId
|
|
? await this.credentials.decryptForConnection(host.credentialId)
|
|
: null;
|
|
|
|
const sessionId = uuid();
|
|
const client = new Client();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.on('ready', () => {
|
|
client.shell({ term: 'xterm-256color' }, (err, stream) => {
|
|
if (err) {
|
|
client.end();
|
|
return reject(err);
|
|
}
|
|
const session: SshSession = { id: sessionId, hostId, client, stream };
|
|
this.sessions.set(sessionId, session);
|
|
|
|
stream.on('data', (data: Buffer) => onData(data.toString('utf-8')));
|
|
stream.on('close', () => {
|
|
this.disconnect(sessionId);
|
|
onClose('Session ended');
|
|
});
|
|
|
|
// Update lastConnectedAt and create connection log
|
|
this.hosts.touchLastConnected(hostId);
|
|
this.prisma.connectionLog.create({
|
|
data: { hostId, protocol: host.protocol },
|
|
}).catch(() => {});
|
|
|
|
resolve(sessionId);
|
|
});
|
|
});
|
|
|
|
client.on('error', (err) => {
|
|
this.logger.error(`SSH error for host ${hostId}: ${err.message}`);
|
|
this.disconnect(sessionId);
|
|
onClose(err.message);
|
|
reject(err);
|
|
});
|
|
|
|
const connectConfig: any = {
|
|
host: host.hostname,
|
|
port: host.port,
|
|
username: cred?.username || 'root',
|
|
debug: (msg: string) => {
|
|
if (msg.includes('auth') || msg.includes('Auth') || msg.includes('key') || msg.includes('Key')) {
|
|
this.logger.log(`[SSH-DEBUG] ${msg}`);
|
|
}
|
|
},
|
|
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
|
|
const fingerprint = createHash('sha256').update(key).digest('base64');
|
|
const fp = `SHA256:${fingerprint}`;
|
|
|
|
if (host.hostFingerprint === fp) {
|
|
verify(true); // known host — accept silently
|
|
return;
|
|
}
|
|
|
|
// Unknown or changed fingerprint — ask the user via WebSocket
|
|
const isNew = !host.hostFingerprint;
|
|
onHostKeyVerify(fp, isNew).then((accepted) => {
|
|
if (accepted) {
|
|
// Persist fingerprint so future connections auto-accept
|
|
this.prisma.host.update({
|
|
where: { id: hostId },
|
|
data: { hostFingerprint: fp },
|
|
}).catch(() => {});
|
|
}
|
|
verify(accepted);
|
|
});
|
|
},
|
|
};
|
|
|
|
if (cred?.sshKey) {
|
|
connectConfig.privateKey = cred.sshKey.privateKey;
|
|
if (cred.sshKey.passphrase) {
|
|
connectConfig.passphrase = cred.sshKey.passphrase;
|
|
}
|
|
this.logger.log(`[SSH] Using key auth for ${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`);
|
|
this.logger.log(`[SSH] Key starts with: ${cred.sshKey.privateKey.substring(0, 40)}...`);
|
|
this.logger.log(`[SSH] Key length: ${cred.sshKey.privateKey.length}, has passphrase: ${!!cred.sshKey.passphrase}`);
|
|
|
|
// Verify ssh2 can parse the key
|
|
try {
|
|
const parsed = utils.parseKey(cred.sshKey.privateKey, cred.sshKey.passphrase || undefined);
|
|
if (parsed instanceof Error) {
|
|
this.logger.error(`[SSH] Key parse FAILED: ${parsed.message}`);
|
|
} else {
|
|
const keyInfo = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
this.logger.log(`[SSH] Key parsed OK — type: ${keyInfo.type}, comment: ${keyInfo.comment || 'none'}`);
|
|
this.logger.log(`[SSH] Public key fingerprint: ${keyInfo.getPublicSSH?.()?.toString('base64')?.substring(0, 40) || 'N/A'}`);
|
|
}
|
|
} catch (e: any) {
|
|
this.logger.error(`[SSH] Key parse threw: ${e.message}`);
|
|
}
|
|
} else if (cred?.password) {
|
|
connectConfig.password = cred.password;
|
|
this.logger.log(`[SSH] Using password auth for ${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`);
|
|
} else {
|
|
this.logger.warn(`[SSH] No auth method available for host ${hostId}`);
|
|
}
|
|
|
|
client.connect(connectConfig);
|
|
});
|
|
}
|
|
|
|
write(sessionId: string, data: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session?.stream) {
|
|
session.stream.write(data);
|
|
}
|
|
}
|
|
|
|
resize(sessionId: string, cols: number, rows: number) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session?.stream) {
|
|
session.stream.setWindow(rows, cols, 0, 0);
|
|
}
|
|
}
|
|
|
|
disconnect(sessionId: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.stream?.close();
|
|
session.client.end();
|
|
this.sessions.delete(sessionId);
|
|
this.sftpChannels.delete(sessionId);
|
|
|
|
// Update connection log with disconnect time
|
|
this.prisma.connectionLog.updateMany({
|
|
where: { hostId: session.hostId, disconnectedAt: null },
|
|
data: { disconnectedAt: new Date() },
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
getSession(sessionId: string): SshSession | undefined {
|
|
return this.sessions.get(sessionId);
|
|
}
|
|
|
|
private sftpChannels = new Map<string, any>();
|
|
|
|
getSftpChannel(sessionId: string): Promise<any> {
|
|
const logger = this.logger;
|
|
const cached = this.sftpChannels.get(sessionId);
|
|
if (cached) {
|
|
return Promise.resolve(cached);
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
logger.error(`[SFTP] Session ${sessionId} not found in sessions map (${this.sessions.size} active sessions)`);
|
|
return reject(new Error('Session not found'));
|
|
}
|
|
logger.log(`[SFTP] Requesting SFTP subsystem on session ${sessionId}`);
|
|
session.client.sftp((err, sftp) => {
|
|
if (err) {
|
|
logger.error(`[SFTP] client.sftp() callback error: ${err.message}`);
|
|
return reject(err);
|
|
}
|
|
logger.log(`[SFTP] SFTP subsystem opened successfully for session ${sessionId}`);
|
|
sftp.on('close', () => {
|
|
this.sftpChannels.delete(sessionId);
|
|
logger.log(`[SFTP] Channel closed for session ${sessionId}`);
|
|
});
|
|
this.sftpChannels.set(sessionId, sftp);
|
|
resolve(sftp);
|
|
});
|
|
});
|
|
}
|
|
}
|