diff --git a/src/server/routes/servers.ts b/src/server/routes/servers.ts index c5bd3be..f158a5e 100644 --- a/src/server/routes/servers.ts +++ b/src/server/routes/servers.ts @@ -11,6 +11,7 @@ import { userCanAccessServer, } from "../services/server-access.service"; import * as ssh from "../services/ssh.service"; +import { closeTerminalSessionsForServer } from "./terminal"; function serializeMetric( metric: { @@ -308,6 +309,23 @@ export async function serverRoutes(app: FastifyInstance) { }); } + const currentServer = await prisma.server.findFirst({ + where: { id, organizationId: req.organizationId! }, + }); + if (!currentServer) { + return reply + .status(404) + .send({ success: false, error: "Server not found" }); + } + + const sshConfigChanged = + (rest.ip !== undefined && rest.ip !== currentServer.ip) || + (rest.sshPort !== undefined && rest.sshPort !== currentServer.sshPort) || + (rest.username !== undefined && rest.username !== currentServer.username) || + (rest.authType !== undefined && rest.authType !== currentServer.authType) || + sshKey !== undefined || + password !== undefined; + const server = await prisma.server.update({ where: { id }, data: { @@ -317,6 +335,11 @@ export async function serverRoutes(app: FastifyInstance) { }, }); + if (sshConfigChanged) { + closeTerminalSessionsForServer(id); + ssh.closeConnection(id); + } + await auditLog({ userId: req.userId, organizationId: req.organizationId, diff --git a/src/server/routes/terminal.ts b/src/server/routes/terminal.ts index e05b4fb..62548f6 100644 --- a/src/server/routes/terminal.ts +++ b/src/server/routes/terminal.ts @@ -89,14 +89,35 @@ function detachSocket(session: TerminalSession) { session.attachedSocket = undefined; } -function destroySession(sessionId: string) { +function destroySession(sessionId: string, message?: string) { const session = terminalSessions.get(sessionId); if (!session) return; + const attachedSocket = session.attachedSocket; detachSocket(session); session.shellStream?.end?.(); session.shellStream?.close?.(); session.shellStream?.destroy?.(); terminalSessions.delete(sessionId); + + if (attachedSocket?.readyState === 1) { + if (message) { + attachedSocket.send(JSON.stringify({ type: "error", data: message })); + } + attachedSocket.close(); + } +} + +export function closeTerminalSessionsForServer(serverId: string): number { + let closedSessions = 0; + for (const [sessionId, session] of terminalSessions.entries()) { + if (session.serverId !== serverId) continue; + destroySession( + sessionId, + "SSH settings changed. Open a new terminal session to use the updated account.", + ); + closedSessions += 1; + } + return closedSessions; } function pruneExpiredSessions() { diff --git a/src/server/services/ssh-services/connection.ts b/src/server/services/ssh-services/connection.ts index ac3e9d1..78483f7 100644 --- a/src/server/services/ssh-services/connection.ts +++ b/src/server/services/ssh-services/connection.ts @@ -2,25 +2,46 @@ import { NodeSSH } from "node-ssh"; import { Server } from "@prisma/client"; import { decrypt } from "../../lib/crypto"; -// Pool: serverId → active SSH connection -const pool = new Map(); -const connectionPromises = new Map>(); +type ConnectionEntry = { + fingerprint: string; + ssh: NodeSSH; +}; + +type PendingConnection = { + fingerprint: string; + promise: Promise; +}; + +// A server can keep the same id while its SSH account changes. Keep the +// credential fingerprint beside the connection so a stale account is never +// reused after an update. +const pool = new Map(); +const connectionPromises = new Map(); +const connectionGenerations = new Map(); + +function getConnectionFingerprint(server: Server): string { + return JSON.stringify([ + server.ip, + server.sshPort, + server.username, + server.authType, + server.sshKeyEnc, + server.passwordEnc, + ]); +} function attachConnectionLifecycle(serverId: string, ssh: NodeSSH): void { const connection = ssh.connection; if (!connection) return; - connection.on("error", () => { - closeConnection(serverId); - }); + const removeIfCurrent = () => { + if (pool.get(serverId)?.ssh !== ssh) return; + pool.delete(serverId); + }; - connection.on("close", () => { - closeConnection(serverId); - }); - - connection.on("end", () => { - closeConnection(serverId); - }); + connection.on("error", removeIfCurrent); + connection.on("close", removeIfCurrent); + connection.on("end", removeIfCurrent); } type SshConnectOptions = { @@ -48,7 +69,10 @@ async function connectSsh( ): Promise { const baseOpts = buildConnectionOptions(server, options); - if (server.authType === "SSH_KEY" && server.sshKeyEnc) { + if (server.authType === "SSH_KEY") { + if (!server.sshKeyEnc) { + throw new Error("No SSH key configured for this server"); + } await ssh.connect({ ...baseOpts, privateKey: decrypt(server.sshKeyEnc), @@ -56,7 +80,10 @@ async function connectSsh( return; } - if (server.passwordEnc) { + if (server.authType === "PASSWORD") { + if (!server.passwordEnc) { + throw new Error("No SSH password configured for this server"); + } await ssh.connect({ ...baseOpts, password: decrypt(server.passwordEnc), @@ -71,25 +98,41 @@ async function connectSsh( * Get or create an SSH connection for a server */ export async function getConnection(server: Server): Promise { - // Return existing connection if alive + const fingerprint = getConnectionFingerprint(server); + + // Return an existing connection only when it was created from the exact + // same host and credentials. const existing = pool.get(server.id); - if (existing?.isConnected()) return existing; + if (existing?.fingerprint === fingerprint && existing.ssh.isConnected()) { + return existing.ssh; + } + if (existing) closeConnection(server.id); const pending = connectionPromises.get(server.id); - if (pending) return pending; + if (pending?.fingerprint === fingerprint) return pending.promise; + if (pending) closeConnection(server.id); + + const generation = connectionGenerations.get(server.id) ?? 0; const connectPromise = (async () => { const ssh = new NodeSSH(); await connectSsh(ssh, server); + if ((connectionGenerations.get(server.id) ?? 0) !== generation) { + ssh.dispose(); + throw new Error("SSH configuration changed while connecting; retry the request"); + } + attachConnectionLifecycle(server.id, ssh); - pool.set(server.id, ssh); + pool.set(server.id, { fingerprint, ssh }); return ssh; })().finally(() => { - connectionPromises.delete(server.id); + if (connectionPromises.get(server.id)?.promise === connectPromise) { + connectionPromises.delete(server.id); + } }); - connectionPromises.set(server.id, connectPromise); + connectionPromises.set(server.id, { fingerprint, promise: connectPromise }); return connectPromise; } @@ -97,9 +140,13 @@ export async function getConnection(server: Server): Promise { * Close and remove a connection from the pool */ export function closeConnection(serverId: string): void { - const conn = pool.get(serverId); + const conn = pool.get(serverId)?.ssh; pool.delete(serverId); connectionPromises.delete(serverId); + connectionGenerations.set( + serverId, + (connectionGenerations.get(serverId) ?? 0) + 1, + ); try { conn?.dispose(); diff --git a/tests/backend/ssh-credential-refresh.test.ts b/tests/backend/ssh-credential-refresh.test.ts new file mode 100644 index 0000000..ad139d2 --- /dev/null +++ b/tests/backend/ssh-credential-refresh.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("server SSH updates invalidate terminal sessions and pooled connections", () => { + const source = readFileSync("src/server/routes/servers.ts", "utf8"); + const updateRoute = source.slice( + source.indexOf('// PUT /servers/:id'), + source.indexOf('// DELETE /servers/:id'), + ); + + assert.match(updateRoute, /const sshConfigChanged =/); + assert.match(updateRoute, /closeTerminalSessionsForServer\(id\)/); + assert.match(updateRoute, /ssh\.closeConnection\(id\)/); +}); + +test("pooled SSH connections are bound to their credential fingerprint", () => { + const source = readFileSync( + "src/server/services/ssh-services/connection.ts", + "utf8", + ); + + assert.match(source, /function getConnectionFingerprint/); + assert.match(source, /server\.username/); + assert.match(source, /server\.authType/); + assert.match(source, /server\.sshKeyEnc/); + assert.match(source, /server\.passwordEnc/); + assert.match(source, /existing\?\.fingerprint === fingerprint/); +});