Files
Termix/src/backend/ssh/terminal.ts
T

2500 lines
77 KiB
TypeScript
Raw Normal View History

2025-09-12 14:42:00 -05:00
import { WebSocketServer, WebSocket, type RawData } from "ws";
2026-05-28 22:29:20 -05:00
import ssh2Pkg, {
type Client as SSHClientType,
type ClientChannel,
type PseudoTtyOptions,
} from "ssh2";
2026-06-04 15:16:53 -04:00
const { Client, utils: ssh2Utils } = ssh2Pkg;
+9
2026-04-22 16:55:23 -05:00
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
+7
2025-11-05 10:36:16 -06:00
import axios from "axios";
2025-10-01 15:40:10 -05:00
import { getDb } from "../database/db/index.js";
2026-06-04 15:16:53 -04:00
import { hosts } from "../database/db/schema.js";
2025-09-12 14:42:00 -05:00
import { eq, and } from "drizzle-orm";
+4
2026-02-12 22:28:13 -06:00
import { sshLogger, authLogger } from "../utils/logger.js";
2025-10-01 15:40:10 -05:00
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
+5
2026-03-08 18:02:14 -05:00
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
2026-01-24 19:49:42 -06:00
import { SSHAuthManager } from "./auth-manager.js";
+5
2026-03-08 18:02:14 -05:00
import type { ProxyNode } from "../../types/index.js";
+4
2026-02-12 22:28:13 -06:00
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
2026-06-04 15:16:53 -04:00
import { createJumpHostChain } from "./terminal-jump-hosts.js";
+5
2026-03-08 18:02:14 -05:00
import { sessionManager } from "./terminal-session-manager.js";
+9
2026-04-22 16:55:23 -05:00
import {
detectTmux,
attachOrCreateTmuxSession,
2026-05-28 22:29:20 -05:00
waitForTmuxSession,
+9
2026-04-22 16:55:23 -05:00
} from "./tmux-helper.js";
2026-06-04 15:16:53 -04:00
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
2025-08-28 00:05:27 -05:00
+7
2025-11-05 10:36:16 -06:00
interface ConnectToHostData {
cols: number;
rows: number;
hostConfig: {
id: number;
+5
2026-03-08 18:02:14 -05:00
instanceId?: string;
+7
2025-11-05 10:36:16 -06:00
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
2025-11-17 09:46:05 -06:00
jumpHosts?: Array<{ hostId: number }>;
+1
2025-12-31 22:20:12 -06:00
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
+9
2026-04-22 16:55:23 -05:00
portKnockSequence?: Array<{
port: number;
protocol?: "tcp" | "udp";
delay?: number;
}>;
+5
2026-03-08 18:02:14 -05:00
terminalConfig?: {
keepaliveInterval?: number;
keepaliveCountMax?: number;
[key: string]: unknown;
};
+7
2025-11-05 10:36:16 -06:00
};
initialPath?: string;
executeCommand?: string;
}
interface ResizeData {
cols: number;
rows: number;
}
interface TOTPResponseData {
code?: string;
}
interface WebSocketMessage {
type: string;
data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown;
code?: string;
[key: string]: unknown;
}
2025-10-01 15:40:10 -05:00
const authManager = AuthManager.getInstance();
const userCrypto = UserCrypto.getInstance();
2025-08-28 00:05:27 -05:00
2025-10-01 15:40:10 -05:00
const userConnections = new Map<string, Set<WebSocket>>();
const wss = new WebSocketServer({
port: 30002,
2025-09-12 14:42:00 -05:00
});
2025-10-01 15:40:10 -05:00
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
+4
2026-02-12 22:28:13 -06:00
let sessionId: string | undefined;
2025-10-01 15:40:10 -05:00
try {
2026-05-06 15:12:07 -05:00
let token: string | undefined;
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
+9
2026-04-22 16:55:23 -05:00
if (!token) {
2026-05-06 15:12:07 -05:00
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
+9
2026-04-22 16:55:23 -05:00
}
}
2025-10-01 15:40:10 -05:00
2026-05-28 22:29:20 -05:00
if (!token) {
const urlObj = new URL(req.url || "", "http://localhost");
const qp = urlObj.searchParams.get("token");
if (qp) token = qp;
}
2025-10-01 15:40:10 -05:00
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
2026-06-04 15:16:53 -04:00
if (!payload?.userId || payload.pendingTOTP) {
2025-10-01 15:40:10 -05:00
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
+4
2026-02-12 22:28:13 -06:00
sessionId = payload.sessionId;
2025-10-01 15:40:10 -05:00
} catch (error) {
sshLogger.error(
"WebSocket JWT verification failed during connection",
error,
{
operation: "websocket_connection_auth_error",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
const dataKey = userCrypto.getUserDataKey(userId);
if (!dataKey) {
ws.send(
JSON.stringify({
type: "error",
message: "Data locked - re-authenticate with password",
code: "DATA_LOCKED",
}),
);
ws.close(1008, "Data access required");
return;
}
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
const userWs = userConnections.get(userId)!;
userWs.add(ws);
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("Terminal WebSocket connection established", {
operation: "terminal_ws_connect",
sessionId,
userId,
});
2025-10-01 15:40:10 -05:00
+5
2026-03-08 18:02:14 -05:00
let currentSessionId: string | null = null;
2026-05-28 22:29:20 -05:00
let sshConn: SSHClientType | null = null;
2025-09-12 14:42:00 -05:00
let sshStream: ClientChannel | null = null;
2026-05-28 22:29:20 -05:00
let lastJumpClient: SSHClientType | null = null;
+7
2025-11-05 10:36:16 -06:00
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
+1
2025-12-31 22:20:12 -06:00
let totpTimeout: NodeJS.Timeout | null = null;
+7
2025-11-05 10:36:16 -06:00
let isKeyboardInteractive = false;
let keyboardInteractiveResponded = false;
2025-11-17 09:46:05 -06:00
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
2026-05-06 15:12:07 -05:00
let cwdPending = false;
let cwdBuffer = "";
2025-11-17 09:46:05 -06:00
let isShellInitializing = false;
2026-01-24 19:49:42 -06:00
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
let isAwaitingAuthCredentials = false;
+9
2026-04-22 16:55:23 -05:00
let wsAlive = true;
ws.on("pong", () => {
wsAlive = true;
});
2025-09-12 14:42:00 -05:00
+5
2026-03-08 18:02:14 -05:00
const wsPingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
+9
2026-04-22 16:55:23 -05:00
if (!wsAlive) {
sshLogger.warn(
"WebSocket pong timeout - terminating zombie connection",
{
operation: "ws_pong_timeout",
userId,
sessionId: currentSessionId,
},
);
ws.terminate();
return;
}
wsAlive = false;
+5
2026-03-08 18:02:14 -05:00
ws.ping();
}
}, 30000);
2025-09-12 14:42:00 -05:00
ws.on("close", () => {
+5
2026-03-08 18:02:14 -05:00
clearInterval(wsPingInterval);
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("Terminal WebSocket disconnected", {
operation: "terminal_ws_disconnect",
sessionId,
userId,
});
2025-10-01 15:40:10 -05:00
const userWs = userConnections.get(userId);
if (userWs) {
userWs.delete(ws);
if (userWs.size === 0) {
userConnections.delete(userId);
}
}
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
const session = sessionManager.getSession(currentSessionId);
if (session?.isConnected) {
sessionManager.detachWs(currentSessionId);
} else {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
}
cleanupAuthState();
2025-09-12 14:42:00 -05:00
});
+4
2026-02-12 22:28:13 -06:00
function resetConnectionState() {
isConnecting = false;
isConnected = false;
isKeyboardInteractive = false;
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
totpPromptSent = false;
warpgateAuthPromptSent = false;
}
ws.on("message", async (msg: RawData) => {
2025-10-01 15:40:10 -05:00
const currentDataKey = userCrypto.getUserDataKey(userId);
if (!currentDataKey) {
ws.send(
JSON.stringify({
type: "error",
message: "Data access expired - please re-authenticate",
code: "DATA_EXPIRED",
}),
);
ws.close(1008, "Data access expired");
return;
}
+7
2025-11-05 10:36:16 -06:00
let parsed: WebSocketMessage;
2025-09-12 14:42:00 -05:00
try {
+7
2025-11-05 10:36:16 -06:00
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
2025-09-12 14:42:00 -05:00
} catch (e) {
sshLogger.error("Invalid JSON received", e, {
2025-10-01 15:40:10 -05:00
operation: "websocket_message_invalid_json",
userId,
2025-09-12 14:42:00 -05:00
messageLength: msg.toString().length,
});
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
const { type, data } = parsed;
switch (type) {
+7
2025-11-05 10:36:16 -06:00
case "connectToHost": {
const connectData = data as ConnectToHostData;
if (connectData.hostConfig) {
connectData.hostConfig.userId = userId;
2025-10-01 15:40:10 -05:00
}
+7
2025-11-05 10:36:16 -06:00
handleConnectToHost(connectData).catch((error) => {
2026-05-06 15:12:07 -05:00
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
2025-09-12 14:42:00 -05:00
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
2025-10-01 15:40:10 -05:00
userId,
+7
2025-11-05 10:36:16 -06:00
hostId: connectData.hostConfig?.id,
ip: connectData.hostConfig?.ip,
2025-09-12 14:42:00 -05:00
});
ws.send(
JSON.stringify({
type: "error",
2026-05-06 15:12:07 -05:00
message: "Failed to connect to host: " + errMsg,
2025-09-12 14:42:00 -05:00
}),
);
});
break;
+7
2025-11-05 10:36:16 -06:00
}
2025-09-12 14:42:00 -05:00
+5
2026-03-08 18:02:14 -05:00
case "attachSession": {
const attachData = data as {
sessionId: string;
cols: number;
rows: number;
tabInstanceId?: string;
};
sshLogger.info("Attempting to attach session", {
operation: "terminal_attach_session",
sessionId: attachData.sessionId,
tabInstanceId: attachData.tabInstanceId,
userId,
requestedCols: attachData.cols,
requestedRows: attachData.rows,
});
const session = sessionManager.attachWs(
attachData.sessionId,
userId,
ws,
attachData.tabInstanceId,
);
if (session) {
sshLogger.success("Session attached successfully", {
operation: "terminal_attach_success",
sessionId: attachData.sessionId,
sessionCreatedAt: session.createdAt,
wasDetached: !!session.lastDetachedAt,
detachedDuration: session.lastDetachedAt
? Date.now() - session.lastDetachedAt
: 0,
});
currentSessionId = attachData.sessionId;
sshStream = session.sshStream;
sshConn = session.sshConn;
isConnecting = false;
isConnected = true;
const buffered = sessionManager.getBuffer(session);
if (buffered) {
ws.send(JSON.stringify({ type: "data", data: buffered }));
}
if (
attachData.cols !== session.cols ||
attachData.rows !== session.rows
) {
session.sshStream?.setWindow(
attachData.rows,
attachData.cols,
attachData.rows,
attachData.cols,
);
session.cols = attachData.cols;
session.rows = attachData.rows;
}
ws.send(
JSON.stringify({
type: "sessionAttached",
sessionId: attachData.sessionId,
}),
);
ws.send(
JSON.stringify({
type: "connected",
message: "Session reattached",
}),
);
} else {
sshLogger.warn(
"Session attachment failed - will create new connection",
{
operation: "terminal_attach_failed",
sessionId: attachData.sessionId,
tabInstanceId: attachData.tabInstanceId,
userId,
reason: "session_not_found_or_invalid",
},
);
ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId: attachData.sessionId,
}),
);
}
break;
}
case "listSessions": {
const sessions = sessionManager.getUserSessions(userId);
ws.send(
JSON.stringify({
type: "sessionList",
sessions: sessions.map((s) => ({
id: s.id,
hostId: s.hostId,
hostName: s.hostName,
createdAt: s.createdAt,
lastDetachedAt: s.lastDetachedAt,
+9
2026-04-22 16:55:23 -05:00
tmuxSessionName: s.tmuxSessionName,
+5
2026-03-08 18:02:14 -05:00
})),
}),
);
break;
}
+7
2025-11-05 10:36:16 -06:00
case "resize": {
const resizeData = data as ResizeData;
handleResize(resizeData);
2025-09-12 14:42:00 -05:00
break;
+7
2025-11-05 10:36:16 -06:00
}
2025-09-12 14:42:00 -05:00
case "disconnect":
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState();
sshConn = null;
sshStream = null;
2025-09-12 14:42:00 -05:00
break;
2026-05-06 15:12:07 -05:00
case "get_cwd": {
const activeStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (!activeStream) {
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
break;
}
cwdPending = true;
cwdBuffer = "";
2026-06-04 15:16:53 -04:00
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
2026-05-06 15:12:07 -05:00
break;
}
+7
2025-11-05 10:36:16 -06:00
case "input": {
const inputData = data as string;
+5
2026-03-08 18:02:14 -05:00
const inputStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (inputStream) {
+7
2025-11-05 10:36:16 -06:00
if (inputData === "\t") {
+5
2026-03-08 18:02:14 -05:00
inputStream.write(inputData);
+7
2025-11-05 10:36:16 -06:00
} else if (
typeof inputData === "string" &&
inputData.startsWith("\x1b")
) {
+5
2026-03-08 18:02:14 -05:00
inputStream.write(inputData);
2025-09-12 14:42:00 -05:00
} else {
2025-10-03 00:02:10 -05:00
try {
+5
2026-03-08 18:02:14 -05:00
inputStream.write(Buffer.from(inputData, "utf8"));
2025-10-03 00:02:10 -05:00
} catch (error) {
sshLogger.error("Error writing input to SSH stream", error, {
operation: "ssh_input_encoding",
userId,
+7
2025-11-05 10:36:16 -06:00
dataLength: inputData.length,
2025-10-03 00:02:10 -05:00
});
+5
2026-03-08 18:02:14 -05:00
inputStream.write(Buffer.from(inputData, "latin1"));
2025-10-03 00:02:10 -05:00
}
2025-09-12 14:42:00 -05:00
}
}
break;
+7
2025-11-05 10:36:16 -06:00
}
2025-09-12 14:42:00 -05:00
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
+9
2026-04-22 16:55:23 -05:00
case "tmux_attach": {
const tmuxData = data as { sessionName: string };
const session = currentSessionId
? sessionManager.getSession(currentSessionId)
: null;
if (session?.sshStream) {
const existingName = tmuxData.sessionName || undefined;
if (existingName) {
2026-05-28 22:29:20 -05:00
attachOrCreateTmuxSession(session.sshStream, existingName);
+9
2026-04-22 16:55:23 -05:00
session.tmuxSessionName = existingName;
sshLogger.info("User selected tmux session to attach", {
operation: "tmux_user_attach",
sessionName: existingName,
hostId: session.hostId,
});
ws.send(
JSON.stringify({
type: "tmux_session_attached",
sessionName: existingName,
}),
);
} else {
2026-05-28 22:29:20 -05:00
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
attachOrCreateTmuxSession(session.sshStream, undefined, newName);
+9
2026-04-22 16:55:23 -05:00
const sshConn = session.sshConn;
2026-05-28 22:29:20 -05:00
if (sshConn) {
(async () => {
const confirmed = await waitForTmuxSession(sshConn, newName);
session.tmuxSessionName = confirmed;
sshLogger.info("User requested new tmux session", {
operation: "tmux_user_create",
sessionName: confirmed,
hostId: session.hostId,
});
ws.send(
JSON.stringify({
type: "tmux_session_created",
sessionName: confirmed,
}),
);
})();
}
+9
2026-04-22 16:55:23 -05:00
}
}
break;
}
2026-05-28 22:29:20 -05:00
case "tmux_detach": {
const session = currentSessionId
? sessionManager.getSession(currentSessionId)
: null;
if (session?.sshConn && session.tmuxSessionName) {
const tmuxName = session.tmuxSessionName;
session.sshStream?.write("\x02d");
session.tmuxSessionName = null;
sshLogger.info("User detached from tmux session", {
operation: "tmux_user_detach",
sessionName: tmuxName,
hostId: session.hostId,
});
ws.send(
JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }),
);
}
break;
}
+7
2025-11-05 10:36:16 -06:00
case "totp_response": {
const totpData = data as TOTPResponseData;
if (keyboardInteractiveFinish && totpData?.code) {
+1
2025-12-31 22:20:12 -06:00
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
+7
2025-11-05 10:36:16 -06:00
const totpCode = totpData.code;
keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
+1
2025-12-31 22:20:12 -06:00
totpPromptSent = false;
+7
2025-11-05 10:36:16 -06:00
} else {
sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error",
userId,
hasCallback: !!keyboardInteractiveFinish,
hasCode: !!totpData?.code,
});
ws.send(
JSON.stringify({
type: "error",
message: "TOTP authentication state lost. Please reconnect.",
}),
);
}
break;
}
case "password_response": {
const passwordData = data as TOTPResponseData;
if (keyboardInteractiveFinish && passwordData?.code) {
+1
2025-12-31 22:20:12 -06:00
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
+7
2025-11-05 10:36:16 -06:00
const password = passwordData.code;
keyboardInteractiveFinish([password]);
keyboardInteractiveFinish = null;
} else {
sshLogger.warn(
"Password response received but no callback available",
{
operation: "password_response_error",
userId,
hasCallback: !!keyboardInteractiveFinish,
hasCode: !!passwordData?.code,
},
);
ws.send(
JSON.stringify({
type: "error",
message: "Password authentication state lost. Please reconnect.",
}),
);
}
break;
}
2026-01-24 19:49:42 -06:00
case "warpgate_auth_continue": {
if (keyboardInteractiveFinish) {
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
warpgateAuthPromptSent = false;
}
break;
}
+7
2025-11-05 10:36:16 -06:00
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
rows: number;
hostConfig: ConnectToHostData["hostConfig"];
password?: string;
sshKey?: string;
keyPassword?: string;
};
if (credentialsData.password) {
credentialsData.hostConfig.password = credentialsData.password;
credentialsData.hostConfig.authType = "password";
+5
2026-03-08 18:02:14 -05:00
(
credentialsData.hostConfig as Record<string, unknown>
).userProvidedPassword = true;
+7
2025-11-05 10:36:16 -06:00
} else if (credentialsData.sshKey) {
credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
credentialsData.hostConfig.authType = "key";
2026-05-06 15:12:07 -05:00
} else if (credentialsData.keyPassword) {
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
+7
2025-11-05 10:36:16 -06:00
}
2026-01-24 19:49:42 -06:00
isAwaitingAuthCredentials = false;
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState();
sshConn = null;
sshStream = null;
+7
2025-11-05 10:36:16 -06:00
const reconnectData: ConnectToHostData = {
cols: credentialsData.cols,
rows: credentialsData.rows,
hostConfig: credentialsData.hostConfig,
};
handleConnectToHost(reconnectData).catch((error) => {
2026-05-06 15:12:07 -05:00
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
+7
2025-11-05 10:36:16 -06:00
sshLogger.error("Failed to reconnect with credentials", error, {
operation: "ssh_reconnect_with_credentials",
userId,
hostId: credentialsData.hostConfig?.id,
ip: credentialsData.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
2026-05-06 15:12:07 -05:00
message: "Failed to connect with provided credentials: " + errMsg,
+7
2025-11-05 10:36:16 -06:00
}),
);
});
break;
}
+4
2026-02-12 22:28:13 -06:00
case "opkssh_start_auth": {
const opksshData = data as { hostId: number };
try {
const { startOPKSSHAuth } = await import("./opkssh-auth.js");
const { getRequestOrigin } =
await import("../utils/request-origin.js");
+4
2026-02-12 22:28:13 -06:00
const db = getDb();
const hostRow = await db
.select()
2026-03-14 20:05:05 -05:00
.from(hosts)
.where(eq(hosts.id, opksshData.hostId))
+4
2026-02-12 22:28:13 -06:00
.limit(1);
if (!hostRow || hostRow.length === 0) {
sshLogger.error(
`Host ${opksshData.hostId} not found for OPKSSH auth`,
{
operation: "opkssh_start_auth_host_not_found",
userId,
hostId: opksshData.hostId,
},
);
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: "",
error: "Host not found",
}),
);
break;
}
const hostname = hostRow[0].name || hostRow[0].ip;
const requestOrigin = getRequestOrigin(req);
await startOPKSSHAuth(
userId,
opksshData.hostId,
hostname,
ws,
requestOrigin,
);
} catch (error) {
sshLogger.error("Failed to start OPKSSH auth", error, {
operation: "opkssh_start_auth_error",
userId,
hostId: opksshData.hostId,
});
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: "",
error: "Failed to start OPKSSH authentication",
}),
);
}
break;
}
case "opkssh_cancel": {
const cancelData = data as { requestId: string };
try {
const { cancelAuthSession } = await import("./opkssh-auth.js");
cancelAuthSession(cancelData.requestId);
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel OPKSSH auth", error, {
operation: "opkssh_cancel_error",
userId,
});
}
break;
}
case "opkssh_browser_opened": {
break;
}
case "opkssh_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
+5
2026-03-08 18:02:14 -05:00
hostConfig?: ConnectToHostData["hostConfig"];
+4
2026-02-12 22:28:13 -06:00
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
+5
2026-03-08 18:02:14 -05:00
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
+4
2026-02-12 22:28:13 -06:00
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after OPKSSH auth", error, {
operation: "opkssh_reconnect_error",
userId,
hostId: completedData.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
}
2025-09-12 14:42:00 -05:00
default:
sshLogger.warn("Unknown message type received", {
2025-10-01 15:40:10 -05:00
operation: "websocket_message_unknown_type",
userId,
2025-09-12 14:42:00 -05:00
messageType: type,
});
}
});
+7
2025-11-05 10:36:16 -06:00
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand } = data;
2025-09-12 14:42:00 -05:00
const {
id,
+5
2026-03-08 18:02:14 -05:00
ip: rawIp,
2026-06-04 15:16:53 -04:00
port: clientPort,
username: clientUsername,
2025-09-12 14:42:00 -05:00
password,
key,
keyPassword,
keyType,
authType,
credentialId,
} = hostConfig;
2026-06-04 15:16:53 -04:00
const clientIp = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp;
let ip = clientIp;
let port = clientPort;
let username = clientUsername;
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("Resolving SSH host configuration", {
operation: "terminal_host_resolve",
sessionId,
userId,
hostId: id,
});
2025-09-12 14:42:00 -05:00
2026-01-24 19:49:42 -06:00
const sendLog = (
stage: string,
level: string,
message: string,
+5
2026-03-08 18:02:14 -05:00
details?: Record<string, unknown>,
2026-01-24 19:49:42 -06:00
) => {
ws.send(
JSON.stringify({
type: "connection_log",
data: { stage, level, message, details },
}),
);
};
2025-09-12 14:42:00 -05:00
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;
}
2025-11-17 09:46:05 -06:00
if (isConnecting || isConnected) {
sshLogger.warn("Connection already in progress or established", {
operation: "ssh_connect",
hostId: id,
isConnecting,
isConnected,
});
+1
2025-12-31 22:20:12 -06:00
ws.send(
JSON.stringify({
type: "error",
message: "Connection already in progress",
code: "DUPLICATE_CONNECTION",
}),
);
2025-11-17 09:46:05 -06:00
return;
}
isConnecting = true;
2025-09-12 14:42:00 -05:00
sshConn = new Client();
2026-01-24 19:49:42 -06:00
sendLog("dns", "info", `Starting address resolution of ${ip}`);
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
2025-09-12 14:42:00 -05:00
const connectionTimeout = setTimeout(() => {
2025-11-17 09:46:05 -06:00
if (sshConn && isConnecting && !isConnected) {
2025-09-12 14:42:00 -05:00
sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-09-12 14:42:00 -05:00
}
2026-01-24 19:49:42 -06:00
}, 120000);
2025-09-12 14:42:00 -05:00
2026-06-04 15:16:53 -04:00
let resolvedHostData:
| (Record<string, unknown> & {
ip?: string;
port?: number;
username?: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
jumpHosts?: Array<{ hostId: number }>;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
})
| null = null;
if (id && userId) {
try {
const { resolveHostById } = await import("./host-resolver.js");
resolvedHostData = (await resolveHostById(
id,
userId,
)) as unknown as typeof resolvedHostData;
if (resolvedHostData) {
if (
(!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) &&
resolvedHostData.jumpHosts &&
resolvedHostData.jumpHosts.length > 0
) {
hostConfig.jumpHosts = resolvedHostData.jumpHosts;
sendLog(
"jump",
"info",
`Loaded ${resolvedHostData.jumpHosts.length} jump host(s) from server-side host data`,
);
}
if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) {
hostConfig.useSocks5 = resolvedHostData.useSocks5;
hostConfig.socks5Host = resolvedHostData.socks5Host;
hostConfig.socks5Port = resolvedHostData.socks5Port;
hostConfig.socks5Username = resolvedHostData.socks5Username;
hostConfig.socks5Password = resolvedHostData.socks5Password;
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
}
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
}
}
} catch (error) {
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
operation: "ssh_host_data",
hostId: id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
+9
2026-04-22 16:55:23 -05:00
// Resolve credentials server-side when frontend doesn't provide them
+5
2026-03-08 18:02:14 -05:00
let resolvedCredentials = {
username,
password,
key,
keyPassword,
keyType,
authType,
2026-05-28 22:29:20 -05:00
certPublicKey: undefined as string | undefined,
+5
2026-03-08 18:02:14 -05:00
};
const authMethodNotAvailable = false;
+9
2026-04-22 16:55:23 -05:00
if (id && userId && !password && !key) {
try {
2026-06-04 15:16:53 -04:00
if (resolvedHostData) {
ip = resolvedHostData.ip || ip;
port = resolvedHostData.port || port;
username = resolvedHostData.username || username;
+9
2026-04-22 16:55:23 -05:00
resolvedCredentials = {
2026-06-04 15:16:53 -04:00
username: resolvedHostData.username || username,
password: resolvedHostData.password,
key: resolvedHostData.key,
keyPassword: keyPassword || resolvedHostData.keyPassword,
keyType: resolvedHostData.keyType,
authType: resolvedHostData.authType,
certPublicKey: resolvedHostData.certPublicKey as string | undefined,
+9
2026-04-22 16:55:23 -05:00
};
sendLog(
"auth",
"info",
"Credentials resolved from server-side host data",
2026-03-14 20:05:05 -05:00
);
}
+9
2026-04-22 16:55:23 -05:00
} catch (error) {
sshLogger.warn(`Failed to resolve host credentials for ${id}`, {
operation: "ssh_credentials",
hostId: id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (credentialId && id && userId) {
try {
2026-06-04 15:16:53 -04:00
if (resolvedHostData) {
ip = resolvedHostData.ip || ip;
port = resolvedHostData.port || port;
username = resolvedHostData.username || username;
+9
2026-04-22 16:55:23 -05:00
resolvedCredentials = {
2026-06-04 15:16:53 -04:00
username: resolvedHostData.username || username,
password: resolvedHostData.password,
key: resolvedHostData.key,
2026-05-06 15:12:07 -05:00
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
2026-06-04 15:16:53 -04:00
keyPassword: keyPassword || resolvedHostData.keyPassword,
keyType: resolvedHostData.keyType,
authType: resolvedHostData.authType,
certPublicKey: resolvedHostData.certPublicKey as string | undefined,
+9
2026-04-22 16:55:23 -05:00
};
2025-09-12 14:42:00 -05:00
}
+9
2026-04-22 16:55:23 -05:00
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
2025-09-12 14:42:00 -05:00
operation: "ssh_credentials",
hostId: id,
credentialId,
+9
2026-04-22 16:55:23 -05:00
error: error instanceof Error ? error.message : "Unknown error",
2025-09-12 14:42:00 -05:00
});
}
}
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
+4
2026-02-12 22:28:13 -06:00
sshLogger.success("SSH connection established", {
operation: "terminal_ssh_connected",
sessionId,
userId,
hostId: id,
ip,
});
if (totpPromptSent) {
authLogger.success("TOTP verification successful for SSH session", {
operation: "terminal_totp_success",
sessionId,
userId,
hostId: id,
});
}
2026-01-24 19:49:42 -06:00
sendLog("handshake", "success", "SSH handshake completed");
sendLog("auth", "success", `Authentication successful for ${username}`);
sendLog("connected", "success", "Connection established");
+5
2026-03-08 18:02:14 -05:00
const hostDisplayName = `${username}@${ip}:${port}`;
const tabInstanceId = hostConfig.instanceId;
currentSessionId = sessionManager.createSession(
userId,
id,
hostDisplayName,
data.cols,
data.rows,
tabInstanceId,
);
2026-05-28 22:05:25 -04:00
// If createSession returned an existing live session (duplicate tabInstanceId),
// close the newly-established SSH connection and attach this WS to the live session instead.
const existingSession = sessionManager.getSession(currentSessionId);
if (
existingSession &&
existingSession.sshStream &&
!existingSession.sshStream.destroyed &&
existingSession.sshConn !== sshConn
) {
sshLogger.info(
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
{
operation: "terminal_reuse_existing_session",
sessionId: currentSessionId,
tabInstanceId,
userId,
},
);
try {
sshConn?.end();
} catch {
/* ignore */
}
sshConn = null;
sshStream = existingSession.sshStream;
sshConn = existingSession.sshConn;
isConnecting = false;
isConnected = true;
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
const buffered = sessionManager.getBuffer(existingSession);
if (buffered) {
ws.send(JSON.stringify({ type: "data", data: buffered }));
}
ws.send(
JSON.stringify({
type: "sessionCreated",
sessionId: currentSessionId,
}),
);
ws.send(
JSON.stringify({
type: "sessionAttached",
sessionId: currentSessionId,
}),
);
ws.send(
JSON.stringify({ type: "connected", message: "Session reattached" }),
);
cleanupAuthState(connectionTimeout);
return;
}
+5
2026-03-08 18:02:14 -05:00
sshLogger.info("Terminal session created after SSH ready", {
operation: "terminal_session_created",
sessionId: currentSessionId,
userId,
hostId: id,
tabInstanceId,
ip,
port,
});
2025-11-17 09:46:05 -06:00
const conn = sshConn;
if (!conn || isCleaningUp || !sshConn) {
+7
2025-11-05 10:36:16 -06:00
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
{
operation: "ssh_shell",
hostId: id,
ip,
port,
username,
2025-11-17 09:46:05 -06:00
isCleaningUp,
connNull: !conn,
sshConnNull: !sshConn,
+7
2025-11-05 10:36:16 -06:00
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"SSH connection was closed before terminal could be created",
}),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
+7
2025-11-05 10:36:16 -06:00
return;
}
2025-11-17 09:46:05 -06:00
isShellInitializing = true;
isConnecting = false;
isConnected = true;
if (!sshConn) {
sshLogger.error(
"SSH connection became null right before shell creation",
{
operation: "ssh_shell",
hostId: id,
},
);
ws.send(
JSON.stringify({
type: "error",
message: "SSH connection lost during setup",
}),
);
isShellInitializing = false;
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-11-17 09:46:05 -06:00
return;
}
2026-01-24 19:49:42 -06:00
sshLogger.info("Creating shell", {
operation: "ssh_shell_start",
hostId: id,
ip,
port,
username,
});
let shellCallbackReceived = false;
const shellTimeout = setTimeout(() => {
if (!shellCallbackReceived && isShellInitializing) {
sshLogger.error("Shell creation timeout - no response from server", {
operation: "ssh_shell_timeout",
hostId: id,
ip,
port,
username,
});
isShellInitializing = false;
ws.send(
JSON.stringify({
type: "error",
message:
"Shell creation timeout. The server may not support interactive shells or the connection was interrupted.",
}),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2026-01-24 19:49:42 -06:00
}
}, 15000);
2025-11-17 09:46:05 -06:00
conn.shell(
2025-09-12 14:42:00 -05:00
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
2026-01-24 19:49:42 -06:00
shellCallbackReceived = true;
clearTimeout(shellTimeout);
2025-11-17 09:46:05 -06:00
isShellInitializing = false;
2025-09-12 14:42:00 -05:00
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,
}),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-09-12 14:42:00 -05:00
return;
}
sshStream = stream;
+4
2026-02-12 22:28:13 -06:00
sshLogger.success("Terminal shell channel opened", {
operation: "terminal_shell_opened",
sessionId,
userId,
hostId: id,
termType: "xterm-256color",
});
2025-09-12 14:42:00 -05:00
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.setSSHState(
currentSessionId,
sshConn!,
stream,
lastJumpClient,
);
sessionManager.attachWs(currentSessionId, userId, ws);
ws.send(
JSON.stringify({
type: "sessionCreated",
sessionId: currentSessionId,
}),
);
sshLogger.info("Session ready for persistence", {
operation: "session_ready",
sessionId: currentSessionId,
userId,
hostId: id,
});
}
const boundSessionId = currentSessionId;
2026-05-06 15:12:07 -05:00
const CWD_SENTINEL = "TERMIX_CWD:";
2025-09-12 14:42:00 -05:00
stream.on("data", (data: Buffer) => {
2025-10-03 00:02:10 -05:00
try {
2026-05-06 15:12:07 -05:00
let utf8String = data.toString("utf-8");
if (cwdPending) {
cwdBuffer += utf8String;
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
if (sentinelIdx !== -1) {
const afterSentinel = cwdBuffer.slice(
sentinelIdx + CWD_SENTINEL.length,
);
const newlineIdx = afterSentinel.search(/[\r\n]/);
if (newlineIdx !== -1) {
const cwd =
afterSentinel.slice(0, newlineIdx).trim() || "/";
cwdPending = false;
// Strip the sentinel line from output sent to terminal
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
const afterNewline = afterSentinel.slice(newlineIdx);
utf8String = beforeSentinel + afterNewline;
cwdBuffer = "";
const attachedWs =
sessionManager.getSession(boundSessionId)?.attachedWs ??
ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({ type: "cwd", path: cwd }),
);
}
} else {
return;
}
} else {
return;
}
}
if (!utf8String) return;
+5
2026-03-08 18:02:14 -05:00
const session = sessionManager.getSession(boundSessionId);
if (session) {
sessionManager.bufferOutput(boundSessionId!, utf8String);
if (session.attachedWs?.readyState === WebSocket.OPEN) {
session.attachedWs.send(
JSON.stringify({ type: "data", data: utf8String }),
);
}
}
2025-10-03 00:02:10 -05:00
} catch (error) {
sshLogger.error("Error encoding terminal data", error, {
operation: "terminal_data_encoding",
hostId: id,
dataLength: data.length,
});
+5
2026-03-08 18:02:14 -05:00
const fallback = data.toString("latin1");
const session = sessionManager.getSession(boundSessionId);
if (session) {
sessionManager.bufferOutput(boundSessionId!, fallback);
if (session.attachedWs?.readyState === WebSocket.OPEN) {
session.attachedWs.send(
JSON.stringify({ type: "data", data: fallback }),
);
}
}
2025-10-03 00:02:10 -05:00
}
2025-09-12 14:42:00 -05:00
});
2026-05-06 15:12:07 -05:00
stream.on("close", (code: number | null) => {
+5
2026-03-08 18:02:14 -05:00
const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
2026-05-06 15:12:07 -05:00
if (code != null) {
session.attachedWs.send(
JSON.stringify({
type: "session_ended",
code,
}),
);
} else {
session.attachedWs.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
2026-05-28 22:29:20 -05:00
graceful: true,
2026-05-06 15:12:07 -05:00
}),
);
}
+5
2026-03-08 18:02:14 -05:00
}
if (boundSessionId) {
sessionManager.destroySession(boundSessionId);
if (currentSessionId === boundSessionId) {
currentSessionId = null;
}
}
2025-09-12 14:42:00 -05:00
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
+5
2026-03-08 18:02:14 -05:00
const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
session.attachedWs.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
}
2025-09-12 14:42:00 -05:00
});
+9
2026-04-22 16:55:23 -05:00
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
2025-10-01 15:40:10 -05:00
+9
2026-04-22 16:55:23 -05:00
// Helper to run initialPath/executeCommand after the shell
// (or tmux session) is ready
const runPostShellCommands = (delay: number) => {
2025-10-01 15:40:10 -05:00
setTimeout(() => {
+9
2026-04-22 16:55:23 -05:00
if (initialPath && initialPath.trim() !== "") {
2026-06-04 15:16:53 -04:00
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
+9
2026-04-22 16:55:23 -05:00
stream.write(cdCommand);
}
if (executeCommand && executeCommand.trim() !== "") {
setTimeout(() => {
stream.write(`${executeCommand}\r`);
}, 300);
}
}, delay);
};
if (autoTmux && conn) {
(async () => {
try {
const detection = await detectTmux(conn);
if (!detection.available) {
sshLogger.warn("tmux not found on remote host", {
operation: "tmux_detection",
hostId: id,
});
ws.send(
JSON.stringify({
type: "tmux_unavailable",
message:
"tmux is not installed on the remote host. Falling back to standard shell.",
}),
);
runPostShellCommands(0);
} else if (detection.sessions.length === 0) {
2026-05-28 22:29:20 -05:00
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
attachOrCreateTmuxSession(stream, undefined, newName);
const confirmed = await waitForTmuxSession(conn, newName);
const session = sessionManager.getSession(boundSessionId);
if (session) {
session.tmuxSessionName = confirmed;
}
sshLogger.info("Created new tmux session", {
operation: "tmux_new_session",
sessionName: confirmed,
hostId: id,
});
ws.send(
JSON.stringify({
type: "tmux_session_created",
sessionName: confirmed,
}),
);
runPostShellCommands(0);
+9
2026-04-22 16:55:23 -05:00
} else if (detection.sessions.length === 1) {
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
const sessionName = detection.sessions[0].name;
const session = sessionManager.getSession(boundSessionId);
if (session) {
session.tmuxSessionName = sessionName;
}
sshLogger.info("Auto-attached to existing tmux session", {
operation: "tmux_auto_attach",
sessionName,
hostId: id,
});
ws.send(
JSON.stringify({
type: "tmux_session_attached",
sessionName,
}),
);
// Reattaching to existing session -- don't re-run
// initialPath/executeCommand since the session already
// has its own state
} else {
sshLogger.info(
"Multiple tmux sessions found, sending list to frontend",
{
operation: "tmux_sessions_available",
sessions: detection.sessions,
hostId: id,
},
);
ws.send(
JSON.stringify({
type: "tmux_sessions_available",
sessions: detection.sessions,
}),
);
// Commands deferred until user picks a session
}
} catch (error) {
sshLogger.error("tmux detection failed", error, {
operation: "tmux_detection_error",
hostId: id,
});
// Fallback: run commands in plain shell
runPostShellCommands(0);
}
})();
} else {
// No tmux -- run commands directly as before
runPostShellCommands(0);
2025-10-01 15:40:10 -05:00
}
2025-09-12 14:42:00 -05:00
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
+7
2025-11-05 10:36:16 -06:00
if (id && hostConfig.userId) {
(async () => {
try {
2026-03-14 20:05:05 -05:00
const hostResults = await SimpleDBOps.select(
+7
2025-11-05 10:36:16 -06:00
getDb()
.select()
2026-03-14 20:05:05 -05:00
.from(hosts)
+7
2025-11-05 10:36:16 -06:00
.where(
and(
2026-03-14 20:05:05 -05:00
eq(hosts.id, id),
eq(hosts.userId, hostConfig.userId!),
+7
2025-11-05 10:36:16 -06:00
),
),
"ssh_data",
hostConfig.userId!,
);
const hostName =
2026-03-14 20:05:05 -05:00
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
+7
2025-11-05 10:36:16 -06:00
: `${username}@${ip}:${port}`;
await axios.post(
"http://localhost:30006/activity/log",
{
type: "terminal",
hostId: id,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
},
},
);
} catch (error) {
sshLogger.warn("Failed to log terminal activity", {
operation: "activity_log_error",
userId: hostConfig.userId,
hostId: id,
error:
error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
2025-09-12 14:42:00 -05:00
},
);
});
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
+7
2025-11-05 10:36:16 -06:00
2026-01-24 19:49:42 -06:00
sendLog("error", "error", `Connection error: ${err.message}`);
+7
2025-11-05 10:36:16 -06:00
2025-09-12 14:42:00 -05:00
sshLogger.error("SSH connection error", err, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
2026-01-24 19:49:42 -06:00
warpgateAuthPromptSent,
isKeyboardInteractive,
hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish,
keyboardInteractiveResponded,
2025-09-12 14:42:00 -05:00
});
+4
2026-02-12 22:28:13 -06:00
if (
resolvedCredentials.authType === "opkssh" &&
err.message.includes("All configured authentication methods failed")
) {
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
operation: "opkssh_auth_failed",
hostId: id,
userId,
error: err.message,
});
(async () => {
try {
const { invalidateOPKSSHToken } = await import("./opkssh-auth.js");
await invalidateOPKSSHToken(userId, id, "SSH auth failed");
} catch (invalidateError) {
sshLogger.error("Failed to invalidate OPKSSH token", {
operation: "opkssh_token_invalidation_error",
userId,
hostId: id,
error: invalidateError,
});
}
})();
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
+4
2026-02-12 22:28:13 -06:00
}
+5
2026-03-08 18:02:14 -05:00
cleanupAuthState(connectionTimeout);
+4
2026-02-12 22:28:13 -06:00
sendLog(
"auth",
"error",
"OPKSSH certificate authentication failed. Please authenticate again.",
);
ws.send(
JSON.stringify({
type: "opkssh_auth_required",
hostId: id,
message:
"OPKSSH authentication failed or expired. Please authenticate again.",
}),
);
return;
}
2026-05-06 15:12:07 -05:00
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
sendLog(
"auth",
"error",
"SSH key is encrypted but no passphrase was provided",
);
isAwaitingAuthCredentials = true;
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
2026-01-24 19:49:42 -06:00
if (
authMethodNotAvailable &&
resolvedCredentials.authType === "none" &&
!isKeyboardInteractive
) {
sendLog(
"auth",
"error",
"Server does not support keyboard-interactive authentication",
);
isAwaitingAuthCredentials = true;
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
2026-01-24 19:49:42 -06:00
}
+5
2026-03-08 18:02:14 -05:00
cleanupAuthState(connectionTimeout);
2026-01-24 19:49:42 -06:00
ws.send(
JSON.stringify({
type: "auth_method_not_available",
message:
"The server does not support keyboard-interactive authentication. Please provide credentials.",
}),
);
return;
}
if (
resolvedCredentials.authType === "none" &&
err.message.includes("All configured authentication methods failed") &&
!isKeyboardInteractive &&
!keyboardInteractiveResponded
) {
isAwaitingAuthCredentials = true;
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
2026-01-24 19:49:42 -06:00
}
+5
2026-03-08 18:02:14 -05:00
cleanupAuthState(connectionTimeout);
2026-01-24 19:49:42 -06:00
ws.send(
JSON.stringify({
type: "auth_method_not_available",
message:
"The server does not support keyboard-interactive authentication. Please provide credentials.",
}),
);
return;
}
if (
isKeyboardInteractive &&
keyboardInteractiveFinish &&
err.message.includes("All configured authentication methods failed")
) {
sshLogger.warn(
"Authentication error during keyboard-interactive - SKIPPING cleanup, waiting for user response",
{
operation: "ssh_error_during_keyboard_interactive_skip_cleanup",
hostId: id,
error: err.message,
},
);
+4
2026-02-12 22:28:13 -06:00
resetConnectionState();
2026-01-24 19:49:42 -06:00
return;
}
sshLogger.error("Proceeding with cleanup after error", {
operation: "ssh_error_cleanup",
hostId: id,
error: err.message,
});
if (
err.message.includes("authentication") ||
err.message.includes("Authentication")
) {
+4
2026-02-12 22:28:13 -06:00
authLogger.error("SSH authentication failed", err, {
operation: "terminal_ssh_auth_failed",
sessionId,
userId,
hostId: id,
authType: resolvedCredentials.authType,
});
2026-01-24 19:49:42 -06:00
sendLog("auth", "error", `Authentication failed: ${err.message}`);
} else {
sendLog("error", "error", `Connection failed: ${err.message}`);
}
2025-09-12 14:42:00 -05:00
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.";
+5
2026-03-08 18:02:14 -05:00
} else if (err.message.includes("ENETUNREACH")) {
const isIPv6 = ip && ip.includes(":");
errorMessage = isIPv6
? "SSH error: Network unreachable. IPv6 may not be available in this environment. If running in Docker, enable IPv6 in the Docker daemon and network configuration."
: "SSH error: Network unreachable. Check your network configuration and routing.";
2025-09-12 14:42:00 -05:00
} 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 }));
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-09-12 14:42:00 -05:00
});
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("SSH connection closed", {
operation: "terminal_ssh_disconnected",
sessionId,
userId,
hostId: id,
});
2026-01-24 19:49:42 -06:00
if (isAwaitingAuthCredentials) {
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2026-01-24 19:49:42 -06:00
return;
}
if (isShellInitializing || (isConnected && !sshStream)) {
sshLogger.warn("SSH connection closed during shell initialization", {
operation: "ssh_close_during_init",
hostId: id,
ip,
port,
username,
isShellInitializing,
hasStream: !!sshStream,
});
+5
2026-03-08 18:02:14 -05:00
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message:
"Connection closed during shell initialization. The server may have rejected the shell request.",
}),
);
}
2026-05-06 15:12:07 -05:00
} else {
+5
2026-03-08 18:02:14 -05:00
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection closed",
}),
);
}
2026-01-24 19:49:42 -06:00
}
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-09-12 14:42:00 -05:00
});
2026-01-24 19:49:42 -06:00
const sshAuthManager = new SSHAuthManager({
userId,
ws,
hostId: id || 0,
isKeyboardInteractive,
keyboardInteractiveResponded,
keyboardInteractiveFinish,
totpPromptSent,
warpgateAuthPromptSent,
totpTimeout,
warpgateAuthTimeout,
totpAttempts: 0,
});
+7
2025-11-05 10:36:16 -06:00
sshConn.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
2026-01-24 19:49:42 -06:00
if (connectionTimeout) {
clearTimeout(connectionTimeout);
}
sshAuthManager.handleKeyboardInteractive(
name,
instructions,
instructionsLang,
prompts,
finish,
+5
2026-03-08 18:02:14 -05:00
resolvedCredentials as unknown as Parameters<
typeof sshAuthManager.handleKeyboardInteractive
>[5],
+7
2025-11-05 10:36:16 -06:00
);
2026-01-24 19:49:42 -06:00
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
keyboardInteractiveResponded =
sshAuthManager.context.keyboardInteractiveResponded;
keyboardInteractiveFinish =
sshAuthManager.context.keyboardInteractiveFinish;
totpPromptSent = sshAuthManager.context.totpPromptSent;
warpgateAuthPromptSent = sshAuthManager.context.warpgateAuthPromptSent;
totpTimeout = sshAuthManager.context.totpTimeout;
warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout;
+7
2025-11-05 10:36:16 -06:00
},
);
+5
2026-03-08 18:02:14 -05:00
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
const connectConfig: Record<string, unknown> = {
2025-09-12 14:42:00 -05:00
host: ip,
port,
username,
+9
2026-04-22 16:55:23 -05:00
tryKeyboard: resolvedCredentials.authType !== "none",
+5
2026-03-08 18:02:14 -05:00
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
2026-05-28 22:05:25 -04:00
? hostKeepaliveInterval * 1000
2026-06-04 15:16:53 -04:00
: 30000,
+5
2026-03-08 18:02:14 -05:00
keepaliveCountMax:
2026-06-04 15:16:53 -04:00
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
2026-01-24 19:49:42 -06:00
readyTimeout: 120000,
2025-09-12 14:42:00 -05:00
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
2026-01-24 19:49:42 -06:00
timeout: 120000,
+4
2026-02-12 22:28:13 -06:00
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
id,
ip,
port,
ws,
userId,
false,
),
2025-09-12 14:42:00 -05:00
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: [
+7
2025-11-05 10:36:16 -06:00
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
+4
2026-02-12 22:28:13 -06:00
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
2025-09-12 14:42:00 -05:00
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
+7
2025-11-05 10:36:16 -06:00
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
2025-09-12 14:42:00 -05:00
],
+9
2026-04-22 16:55:23 -05:00
cipher: SSH_ALGORITHMS.cipher,
2025-10-01 15:40:10 -05:00
hmac: [
"hmac-sha2-512-etm@openssh.com",
+7
2025-11-05 10:36:16 -06:00
"hmac-sha2-256-etm@openssh.com",
2025-10-01 15:40:10 -05:00
"hmac-sha2-512",
+7
2025-11-05 10:36:16 -06:00
"hmac-sha2-256",
2025-10-01 15:40:10 -05:00
"hmac-sha1",
"hmac-md5",
],
2025-09-12 14:42:00 -05:00
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
+7
2025-11-05 10:36:16 -06:00
if (resolvedCredentials.authType === "none") {
+5
2026-03-08 18:02:14 -05:00
// no credentials needed
+7
2025-11-05 10:36:16 -06:00
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
"Password authentication requested but no password provided",
);
ws.send(
JSON.stringify({
type: "error",
message:
"Password authentication requested but no password provided",
}),
);
return;
}
if (!hostConfig.forceKeyboardInteractive) {
connectConfig.password = resolvedCredentials.password;
}
2026-01-24 19:49:42 -06:00
sendLog("auth", "info", "Using password authentication");
2025-10-01 15:40:10 -05:00
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
) {
2026-01-24 19:49:42 -06:00
sendLog("auth", "info", "Using SSH key authentication");
2025-09-12 14:42:00 -05:00
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;
}
2026-05-06 15:12:07 -05:00
if (resolvedCredentials.password) {
connectConfig.password = resolvedCredentials.password;
}
2026-05-28 22:29:20 -05:00
// Apply CA-signed certificate if one is stored in the credential
if (
resolvedCredentials.certPublicKey &&
resolvedCredentials.certPublicKey.trim()
) {
try {
const { setupCACertAuth } = await import("./opkssh-cert-auth.js");
await setupCACertAuth(
connectConfig,
sshConn,
connectConfig.privateKey as Buffer,
resolvedCredentials.certPublicKey,
username,
resolvedCredentials.keyPassword,
);
sendLog("auth", "info", "CA certificate authentication configured");
sshLogger.info("CA cert auth configured", {
operation: "ca_cert_auth_configured",
userId,
hostId: id,
});
} catch (certError) {
sendLog(
"auth",
"warning",
"CA certificate setup failed falling back to key-only auth",
);
sshLogger.warn("CA cert auth setup failed", {
operation: "ca_cert_auth_setup_failed",
userId,
hostId: id,
error:
certError instanceof Error
? certError.message
: String(certError),
});
}
}
2025-09-12 14:42:00 -05:00
} 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") {
2026-01-24 19:49:42 -06:00
sendLog(
"auth",
"error",
"SSH key authentication requested but no key provided",
);
2025-09-12 14:42:00 -05:00
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;
+4
2026-02-12 22:28:13 -06:00
} else if (resolvedCredentials.authType === "opkssh") {
sendLog("auth", "info", "Using OPKSSH certificate authentication");
try {
const { getOPKSSHToken } = await import("./opkssh-auth.js");
const token = await getOPKSSHToken(userId, id);
if (!token) {
sendLog(
"auth",
"info",
"No valid OPKSSH token found, requesting authentication",
);
ws.send(
JSON.stringify({
type: "opkssh_auth_required",
hostId: id,
}),
);
return;
}
sendLog("auth", "info", "Using cached OPKSSH certificate");
+9
2026-04-22 16:55:23 -05:00
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
+4
2026-02-12 22:28:13 -06:00
} catch (opksshError) {
sshLogger.error("OPKSSH authentication error", opksshError, {
operation: "opkssh_auth_error",
userId,
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message:
"OPKSSH authentication failed: " +
(opksshError instanceof Error
? opksshError.message
: "Unknown error"),
}),
);
return;
}
2025-09-12 14:42:00 -05:00
} else {
2026-01-24 19:49:42 -06:00
sendLog("auth", "info", "Using keyboard-interactive authentication");
2025-10-01 15:40:10 -05:00
sshLogger.error("No valid authentication method provided");
ws.send(
JSON.stringify({
type: "error",
message: "No valid authentication method provided",
}),
);
return;
2025-09-12 14:42:00 -05:00
}
2026-05-28 22:29:20 -05:00
if (
hostConfig.terminalConfig?.agentForwarding &&
connectConfig.privateKey
) {
try {
const parsed = ssh2Utils.parseKey(
connectConfig.privateKey as Buffer,
connectConfig.passphrase as string | undefined,
);
if (parsed && !(parsed instanceof Error)) {
connectConfig.agent = new MemoryAgent(parsed);
connectConfig.agentForward = true;
sendLog("auth", "info", "SSH agent forwarding enabled");
}
} catch {
sshLogger.warn("Failed to set up agent forwarding", {
operation: "agent_forward_setup",
hostId: id,
});
}
}
+9
2026-04-22 16:55:23 -05:00
if (
hostConfig.portKnockSequence &&
hostConfig.portKnockSequence.length > 0
) {
try {
sshLogger.info(
`Port knocking ${hostConfig.ip} (${hostConfig.portKnockSequence.length} ports)`,
{ operation: "port_knock", hostId: hostConfig.id },
);
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
2026-05-06 15:12:07 -05:00
} catch {
+9
2026-04-22 16:55:23 -05:00
sshLogger.warn("Port knocking failed, attempting connection anyway", {
operation: "port_knock",
hostId: hostConfig.id,
});
}
}
+5
2026-03-08 18:02:14 -05:00
const proxyConfig: SOCKS5Config | null =
+1
2025-12-31 22:20:12 -06:00
hostConfig.useSocks5 &&
(hostConfig.socks5Host ||
(hostConfig.socks5ProxyChain &&
+5
2026-03-08 18:02:14 -05:00
(hostConfig.socks5ProxyChain as ProxyNode[]).length > 0))
? {
useSocks5: hostConfig.useSocks5,
socks5Host: hostConfig.socks5Host,
socks5Port: hostConfig.socks5Port,
socks5Username: hostConfig.socks5Username,
socks5Password: hostConfig.socks5Password,
socks5ProxyChain: hostConfig.socks5ProxyChain as ProxyNode[],
}
: null;
+1
2025-12-31 22:20:12 -06:00
+5
2026-03-08 18:02:14 -05:00
const hasJumpHosts =
2025-11-17 09:46:05 -06:00
hostConfig.jumpHosts &&
hostConfig.jumpHosts.length > 0 &&
+5
2026-03-08 18:02:14 -05:00
hostConfig.userId;
2026-05-28 22:29:20 -05:00
// Cloudflare Tunnel: connect via WebSocket proxy
const cfConfig = hostConfig.terminalConfig as
| Record<string, unknown>
| undefined;
if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) {
try {
const WebSocket = (await import("ws")).default;
const cfHostname = (cfConfig.cfTunnelHostname as string) || ip;
const wsUrl = `wss://${cfHostname}/cdn-cgi/access/ssh-connect`;
const cfWs = new WebSocket(wsUrl, {
headers: {
"CF-Access-Client-Id": cfConfig.cfAccessClientId as string,
"CF-Access-Client-Secret": cfConfig.cfAccessClientSecret as string,
},
});
await new Promise<void>((resolve, reject) => {
cfWs.on("open", () => resolve());
cfWs.on("error", (err) => reject(err));
setTimeout(
() => reject(new Error("Cloudflare tunnel timeout")),
30000,
);
});
const { Duplex } = await import("stream");
const duplexStream = new Duplex({
read() {},
write(chunk, _encoding, callback) {
cfWs.send(chunk, callback);
},
});
cfWs.on("message", (data) => duplexStream.push(data));
cfWs.on("close", () => duplexStream.push(null));
connectConfig.sock =
duplexStream as unknown as typeof connectConfig.sock;
sendLog("handshake", "info", "Connected via Cloudflare Tunnel");
} catch (cfError) {
sshLogger.error("Cloudflare tunnel connection failed", cfError, {
operation: "cf_tunnel_connect",
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Cloudflare tunnel connection failed: " +
(cfError instanceof Error ? cfError.message : "Unknown error"),
}),
);
cleanupAuthState(connectionTimeout);
return;
}
}
+5
2026-03-08 18:02:14 -05:00
if (hasJumpHosts) {
2025-11-17 09:46:05 -06:00
try {
const jumpClient = await createJumpHostChain(
+5
2026-03-08 18:02:14 -05:00
hostConfig.jumpHosts!,
hostConfig.userId!,
proxyConfig,
2025-11-17 09:46:05 -06:00
);
if (!jumpClient) {
sshLogger.error("Failed to establish jump host chain");
ws.send(
JSON.stringify({
type: "error",
message: "Failed to connect through jump hosts",
}),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-11-17 09:46:05 -06:00
return;
}
+5
2026-03-08 18:02:14 -05:00
lastJumpClient = jumpClient;
2025-11-17 09:46:05 -06:00
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
if (err) {
sshLogger.error("Failed to forward through jump host", err, {
operation: "ssh_jump_forward",
hostId: id,
ip,
port,
});
ws.send(
JSON.stringify({
type: "error",
message: "Failed to forward through jump host: " + err.message,
}),
);
jumpClient.end();
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-11-17 09:46:05 -06:00
return;
}
connectConfig.sock = stream;
2026-01-24 19:49:42 -06:00
sendLog(
"handshake",
"info",
+5
2026-03-08 18:02:14 -05:00
"Starting SSH session through jump host" +
(proxyConfig ? " (via proxy)" : ""),
2026-01-24 19:49:42 -06:00
);
sendLog("auth", "info", `Authenticating as ${username}`);
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("Initiating SSH connection", {
operation: "terminal_ssh_connect_attempt",
sessionId,
userId,
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
+5
2026-03-08 18:02:14 -05:00
viaProxy: !!proxyConfig,
+4
2026-02-12 22:28:13 -06:00
});
2025-11-17 09:46:05 -06:00
sshConn.connect(connectConfig);
});
} catch (error) {
sshLogger.error("Jump host error", error, {
operation: "ssh_jump_host",
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message: "Failed to connect through jump hosts",
}),
);
+5
2026-03-08 18:02:14 -05:00
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
2025-11-17 09:46:05 -06:00
return;
}
+5
2026-03-08 18:02:14 -05:00
} else if (proxyConfig) {
try {
const proxySocket = await createSocks5Connection(ip, port, proxyConfig);
if (proxySocket) {
connectConfig.sock = proxySocket;
}
} catch (proxyError) {
sshLogger.error("Proxy connection failed", proxyError, {
operation: "proxy_connect",
hostId: id,
proxyHost: hostConfig.socks5Host,
proxyPort: hostConfig.socks5Port || 1080,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Proxy connection failed: " +
(proxyError instanceof Error
? proxyError.message
: "Unknown error"),
}),
);
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
return;
}
sendLog("handshake", "info", "Starting SSH session (via proxy)");
sendLog("auth", "info", `Authenticating as ${username}`);
sshLogger.info("Initiating SSH connection", {
operation: "terminal_ssh_connect_attempt",
sessionId,
userId,
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
viaProxy: true,
});
sshConn.connect(connectConfig);
2025-11-17 09:46:05 -06:00
} else {
2026-01-24 19:49:42 -06:00
sendLog("handshake", "info", "Starting SSH session");
sendLog("auth", "info", `Authenticating as ${username}`);
+9
2026-04-22 16:55:23 -05:00
+4
2026-02-12 22:28:13 -06:00
sshLogger.info("Initiating SSH connection", {
operation: "terminal_ssh_connect_attempt",
sessionId,
userId,
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
});
2025-11-17 09:46:05 -06:00
sshConn.connect(connectConfig);
}
2025-09-12 14:42:00 -05:00
}
+7
2025-11-05 10:36:16 -06:00
function handleResize(data: ResizeData) {
+5
2026-03-08 18:02:14 -05:00
const resizeStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (resizeStream && resizeStream.setWindow) {
resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols);
const session = sessionManager.getSession(currentSessionId);
if (session) {
session.cols = data.cols;
session.rows = data.rows;
}
2025-09-12 14:42:00 -05:00
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
+5
2026-03-08 18:02:14 -05:00
function cleanupAuthState(timeoutId?: NodeJS.Timeout) {
2025-09-12 14:42:00 -05:00
if (timeoutId) {
clearTimeout(timeoutId);
}
+1
2025-12-31 22:20:12 -06:00
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
2025-09-12 14:42:00 -05:00
}
2026-01-24 19:49:42 -06:00
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
}
+5
2026-03-08 18:02:14 -05:00
sshStream = null;
sshConn = null;
lastJumpClient = null;
+7
2025-11-05 10:36:16 -06:00
+4
2026-02-12 22:28:13 -06:00
resetConnectionState();
+1
2025-12-31 22:20:12 -06:00
isCleaningUp = false;
2026-01-24 19:49:42 -06:00
isAwaitingAuthCredentials = false;
2025-09-12 14:42:00 -05:00
}
+1
2025-12-31 22:20:12 -06:00
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
// It was causing ^@ characters to appear in terminals with echoctl enabled.
// SSH-level keepalive is configured via connectConfig (keepaliveInterval,
// keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring
// without producing visible output on the terminal.
//
// See: https://github.com/Termix-SSH/Support/issues/232
// See: https://github.com/Termix-SSH/Support/issues/309
2025-08-07 02:20:27 -05:00
});