fix(ssh): invalidate stale sessions after credential updates
- bind pooled SSH connections to credential fingerprints - close active terminal sessions when SSH settings change - enforce the configured authentication method - add regression coverage for credential refresh behavior
This commit is contained in:
1 parent
73b4b6255d
commit
60ae2dde51
4 files changed
+143
-23
No files matched your search
@@ -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<string, NodeSSH>();
|
||||
const connectionPromises = new Map<string, Promise<NodeSSH>>();
|
||||
type ConnectionEntry = {
|
||||
fingerprint: string;
|
||||
ssh: NodeSSH;
|
||||
};
|
||||
|
||||
type PendingConnection = {
|
||||
fingerprint: string;
|
||||
promise: Promise<NodeSSH>;
|
||||
};
|
||||
|
||||
// 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<string, ConnectionEntry>();
|
||||
const connectionPromises = new Map<string, PendingConnection>();
|
||||
const connectionGenerations = new Map<string, number>();
|
||||
|
||||
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<void> {
|
||||
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<NodeSSH> {
|
||||
// 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<NodeSSH> {
|
||||
* 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();
|
||||
|
||||
Reference in new issue
Block a user