Files
Termix/src/backend/ssh/terminal.ts
ZacharyZcR 301303079b Add comprehensive SSH key management and validation features
- Add support for both private and public key storage
- Implement automatic SSH key type detection for all major formats (RSA, Ed25519, ECDSA, DSA)
- Add real-time key pair validation to verify private/public key correspondence
- Enhance credential editor UI with unified key input interface supporting upload/paste
- Improve file format support including extensionless files (id_rsa, id_ed25519, etc.)
- Add comprehensive fallback detection for OpenSSH format keys
- Implement debounced API calls for better UX during real-time validation
- Update database schema with backward compatibility for existing credentials
- Add API endpoints for key detection and pair validation
- Fix SSH2 module integration issues in TypeScript environment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 23:38:30 +08:00

499 lines
14 KiB
TypeScript

import { WebSocketServer, WebSocket, type RawData } from "ws";
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { db } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
const wss = new WebSocketServer({ port: 8082 });
sshLogger.success("SSH Terminal WebSocket server started", {
operation: "server_start",
port: 8082,
});
wss.on("connection", (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on("close", () => {
cleanupSSH();
});
ws.on("message", (msg: RawData) => {
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
sshLogger.error("Invalid JSON received", e, {
operation: "websocket_message",
messageLength: msg.toString().length,
});
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
const { type, data } = parsed;
switch (type) {
case "connectToHost":
handleConnectToHost(data).catch((error) => {
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
case "resize":
handleResize(data);
break;
case "disconnect":
cleanupSSH();
break;
case "input":
if (sshStream) {
if (data === "\t") {
sshStream.write(data);
} else if (data.startsWith("\x1b")) {
sshStream.write(data);
} else {
sshStream.write(Buffer.from(data, "utf8"));
}
}
break;
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message",
messageType: type,
});
}
});
async function handleConnectToHost(data: {
cols: number;
rows: number;
hostConfig: {
id: number;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
userId?: string;
};
}) {
const { cols, rows, hostConfig } = data;
const {
id,
ip,
port,
username,
password,
key,
keyPassword,
keyType,
authType,
credentialId,
} = hostConfig;
if (!username || typeof username !== "string" || username.trim() === "") {
sshLogger.error("Invalid username provided", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid username provided" }),
);
return;
}
if (!ip || typeof ip !== "string" || ip.trim() === "") {
sshLogger.error("Invalid IP provided", undefined, {
operation: "ssh_connect",
hostId: id,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
);
return;
}
if (!port || typeof port !== "number" || port <= 0) {
sshLogger.error("Invalid port provided", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
username,
port,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid port provided" }),
);
return;
}
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
cleanupSSH(connectionTimeout);
}
}, 60000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.privateKey || credential.key, // prefer new privateKey field
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
userId: hostConfig.userId,
});
}
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (credentialId && id) {
sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
hasUserId: !!hostConfig.userId,
});
}
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
sshConn!.shell(
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
if (err) {
sshLogger.error("Shell error", err, {
operation: "ssh_shell",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
);
return;
}
sshStream = stream;
stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
});
stream.on("close", () => {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
});
setupPingInterval();
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
},
);
});
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
sshLogger.error("SSH connection error", err, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
});
let errorMessage = "SSH error: " + err.message;
if (err.message.includes("No matching key exchange algorithm")) {
errorMessage =
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching cipher")) {
errorMessage =
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching MAC")) {
errorMessage =
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage =
"SSH error: Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"SSH error: Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("ECONNRESET") ||
err.message.includes("EPIPE")
) {
errorMessage =
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"SSH error: Authentication failed. Please check your username and password/key.";
}
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
cleanupSSH(connectionTimeout);
});
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
try {
if (
!resolvedCredentials.key.includes("-----BEGIN") ||
!resolvedCredentials.key.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = resolvedCredentials.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword;
}
if (
resolvedCredentials.keyType &&
resolvedCredentials.keyType !== "auto"
) {
connectConfig.privateKeyType = resolvedCredentials.keyType;
}
} catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message);
ws.send(
JSON.stringify({
type: "error",
message: "SSH key format error: Invalid private key format",
}),
);
return;
}
} else if (resolvedCredentials.authType === "key") {
sshLogger.error("SSH key authentication requested but no key provided");
ws.send(
JSON.stringify({
type: "error",
message: "SSH key authentication requested but no key provided",
}),
);
return;
} else {
connectConfig.password = resolvedCredentials.password;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
sshLogger.error("Error closing stream: " + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
sshLogger.error("Error closing connection: " + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: any) {
sshLogger.error("SSH keepalive failed: " + e.message);
cleanupSSH();
}
}
}, 60000);
}
});