fix: SSH host key verification — send verifyId, track pending clients, guard stale callbacks

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-16 12:49:22 -04:00
parent 63315f94c4
commit 8d4ee04285
2 changed files with 18 additions and 4 deletions

View File

@ -17,6 +17,7 @@ export interface SshSession {
export class SshConnectionService {
private readonly logger = new Logger(SshConnectionService.name);
private sessions = new Map<string, SshSession>();
private pendingClients = new Map<string, Client>(); // 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}`);
}
},
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();

View File

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