From 8d4ee04285197bc099fa2a4f1d872e28ae22b791 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Mon, 16 Mar 2026 12:49:22 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20SSH=20host=20key=20verification=20?= =?UTF-8?q?=E2=80=94=20send=20verifyId,=20track=20pending=20clients,=20gua?= =?UTF-8?q?rd=20stale=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend sends verifyId with host-key-accept so backend can correlate the verification response. Backend tracks pre-ready connections in pendingClients map, destroys on error/disconnect, and guards against calling verify() after the connection has already timed out or errored (_destruct crash fix). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/terminal/ssh-connection.service.ts | 20 ++++++++++++++++--- frontend/composables/useTerminal.ts | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/src/terminal/ssh-connection.service.ts b/backend/src/terminal/ssh-connection.service.ts index a5f3d4f..55a77a8 100644 --- a/backend/src/terminal/ssh-connection.service.ts +++ b/backend/src/terminal/ssh-connection.service.ts @@ -17,6 +17,7 @@ export interface SshSession { export class SshConnectionService { private readonly logger = new Logger(SshConnectionService.name); private sessions = new Map(); + private pendingClients = new Map(); // sessionId → client (before 'ready') constructor( private credentials: CredentialsService, @@ -39,9 +40,13 @@ export class SshConnectionService { const sessionId = uuid(); const client = new Client(); + this.pendingClients.set(sessionId, client); return new Promise((resolve, reject) => { + let settled = false; + client.on('ready', () => { + this.pendingClients.delete(sessionId); client.shell({ term: 'xterm-256color' }, (err, stream) => { if (err) { client.end(); @@ -85,7 +90,10 @@ export class SshConnectionService { client.on('error', (err) => { this.logger.error(`SSH error for host ${hostId}: ${err.message}`); + settled = true; + this.pendingClients.delete(sessionId); this.disconnect(sessionId); + client.destroy(); onClose(err.message); reject(err); }); @@ -95,9 +103,7 @@ export class SshConnectionService { 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}`); - } + this.logger.log(`[SSH-DEBUG] ${msg}`); }, hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => { const fingerprint = createHash('sha256').update(key).digest('base64'); @@ -119,6 +125,7 @@ export class SshConnectionService { // No stored fingerprint — first connection, ask the user via WebSocket onHostKeyVerify(fp, true).then((accepted) => { + if (settled) return; // connection already timed out / errored — don't call back into ssh2 if (accepted) { // Persist fingerprint so future connections auto-accept this.prisma.host.update({ @@ -163,6 +170,13 @@ export class SshConnectionService { } disconnect(sessionId: string) { + // Kill pending (not yet 'ready') connections + const pending = this.pendingClients.get(sessionId); + if (pending) { + pending.destroy(); + this.pendingClients.delete(sessionId); + } + const session = this.sessions.get(sessionId); if (session) { session.stream?.close(); diff --git a/frontend/composables/useTerminal.ts b/frontend/composables/useTerminal.ts index b499aad..ce465dd 100644 --- a/frontend/composables/useTerminal.ts +++ b/frontend/composables/useTerminal.ts @@ -105,7 +105,7 @@ export function useTerminal() { break case 'host-key-verify': // Auto-accept for now — full UX in polish phase - ws!.send(JSON.stringify({ type: 'host-key-accept' })) + ws!.send(JSON.stringify({ type: 'host-key-accept', verifyId: msg.verifyId })) break case 'error': term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)