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:
parent
63315f94c4
commit
8d4ee04285
@ -17,6 +17,7 @@ export interface SshSession {
|
|||||||
export class SshConnectionService {
|
export class SshConnectionService {
|
||||||
private readonly logger = new Logger(SshConnectionService.name);
|
private readonly logger = new Logger(SshConnectionService.name);
|
||||||
private sessions = new Map<string, SshSession>();
|
private sessions = new Map<string, SshSession>();
|
||||||
|
private pendingClients = new Map<string, Client>(); // sessionId → client (before 'ready')
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private credentials: CredentialsService,
|
private credentials: CredentialsService,
|
||||||
@ -39,9 +40,13 @@ export class SshConnectionService {
|
|||||||
|
|
||||||
const sessionId = uuid();
|
const sessionId = uuid();
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
this.pendingClients.set(sessionId, client);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
client.on('ready', () => {
|
client.on('ready', () => {
|
||||||
|
this.pendingClients.delete(sessionId);
|
||||||
client.shell({ term: 'xterm-256color' }, (err, stream) => {
|
client.shell({ term: 'xterm-256color' }, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
client.end();
|
client.end();
|
||||||
@ -85,7 +90,10 @@ export class SshConnectionService {
|
|||||||
|
|
||||||
client.on('error', (err) => {
|
client.on('error', (err) => {
|
||||||
this.logger.error(`SSH error for host ${hostId}: ${err.message}`);
|
this.logger.error(`SSH error for host ${hostId}: ${err.message}`);
|
||||||
|
settled = true;
|
||||||
|
this.pendingClients.delete(sessionId);
|
||||||
this.disconnect(sessionId);
|
this.disconnect(sessionId);
|
||||||
|
client.destroy();
|
||||||
onClose(err.message);
|
onClose(err.message);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
@ -95,9 +103,7 @@ export class SshConnectionService {
|
|||||||
port: host.port,
|
port: host.port,
|
||||||
username: cred?.username || 'root',
|
username: cred?.username || 'root',
|
||||||
debug: (msg: string) => {
|
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) => {
|
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
|
||||||
const fingerprint = createHash('sha256').update(key).digest('base64');
|
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
|
// No stored fingerprint — first connection, ask the user via WebSocket
|
||||||
onHostKeyVerify(fp, true).then((accepted) => {
|
onHostKeyVerify(fp, true).then((accepted) => {
|
||||||
|
if (settled) return; // connection already timed out / errored — don't call back into ssh2
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
// Persist fingerprint so future connections auto-accept
|
// Persist fingerprint so future connections auto-accept
|
||||||
this.prisma.host.update({
|
this.prisma.host.update({
|
||||||
@ -163,6 +170,13 @@ export class SshConnectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(sessionId: string) {
|
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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.stream?.close();
|
session.stream?.close();
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export function useTerminal() {
|
|||||||
break
|
break
|
||||||
case 'host-key-verify':
|
case 'host-key-verify':
|
||||||
// Auto-accept for now — full UX in polish phase
|
// 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
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user