mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 10:49:03 +00:00
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
2500 lines
77 KiB
TypeScript
2500 lines
77 KiB
TypeScript
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||
import ssh2Pkg, {
|
||
type Client as SSHClientType,
|
||
type ClientChannel,
|
||
type PseudoTtyOptions,
|
||
} from "ssh2";
|
||
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||
import axios from "axios";
|
||
import { getDb } from "../database/db/index.js";
|
||
import { hosts } from "../database/db/schema.js";
|
||
import { eq, and } from "drizzle-orm";
|
||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||
import { AuthManager } from "../utils/auth-manager.js";
|
||
import { UserCrypto } from "../utils/user-crypto.js";
|
||
import {
|
||
createSocks5Connection,
|
||
type SOCKS5Config,
|
||
} from "../utils/socks5-helper.js";
|
||
import { SSHAuthManager } from "./auth-manager.js";
|
||
import type { ProxyNode } from "../../types/index.js";
|
||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||
import { createJumpHostChain } from "./terminal-jump-hosts.js";
|
||
import { sessionManager } from "./terminal-session-manager.js";
|
||
import {
|
||
detectTmux,
|
||
attachOrCreateTmuxSession,
|
||
waitForTmuxSession,
|
||
} from "./tmux-helper.js";
|
||
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
|
||
|
||
interface ConnectToHostData {
|
||
cols: number;
|
||
rows: number;
|
||
hostConfig: {
|
||
id: number;
|
||
instanceId?: string;
|
||
ip: string;
|
||
port: number;
|
||
username: string;
|
||
password?: string;
|
||
key?: string;
|
||
keyPassword?: string;
|
||
keyType?: string;
|
||
authType?: string;
|
||
credentialId?: number;
|
||
userId?: string;
|
||
forceKeyboardInteractive?: boolean;
|
||
jumpHosts?: Array<{ hostId: number }>;
|
||
useSocks5?: boolean;
|
||
socks5Host?: string;
|
||
socks5Port?: number;
|
||
socks5Username?: string;
|
||
socks5Password?: string;
|
||
socks5ProxyChain?: unknown;
|
||
portKnockSequence?: Array<{
|
||
port: number;
|
||
protocol?: "tcp" | "udp";
|
||
delay?: number;
|
||
}>;
|
||
terminalConfig?: {
|
||
keepaliveInterval?: number;
|
||
keepaliveCountMax?: number;
|
||
[key: string]: unknown;
|
||
};
|
||
};
|
||
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;
|
||
}
|
||
|
||
const authManager = AuthManager.getInstance();
|
||
const userCrypto = UserCrypto.getInstance();
|
||
|
||
const userConnections = new Map<string, Set<WebSocket>>();
|
||
|
||
const wss = new WebSocketServer({
|
||
port: 30002,
|
||
});
|
||
|
||
wss.on("connection", async (ws: WebSocket, req) => {
|
||
let userId: string | undefined;
|
||
let sessionId: string | undefined;
|
||
|
||
try {
|
||
let token: string | undefined;
|
||
|
||
const cookieHeader = req.headers.cookie;
|
||
if (cookieHeader) {
|
||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||
if (match) token = decodeURIComponent(match[1]);
|
||
}
|
||
|
||
if (!token) {
|
||
const authHeader = req.headers.authorization;
|
||
if (authHeader?.startsWith("Bearer ")) {
|
||
token = authHeader.slice("Bearer ".length);
|
||
}
|
||
}
|
||
|
||
if (!token) {
|
||
const urlObj = new URL(req.url || "", "http://localhost");
|
||
const qp = urlObj.searchParams.get("token");
|
||
if (qp) token = qp;
|
||
}
|
||
|
||
if (!token) {
|
||
ws.close(1008, "Authentication required");
|
||
return;
|
||
}
|
||
|
||
const payload = await authManager.verifyJWTToken(token);
|
||
if (!payload?.userId || payload.pendingTOTP) {
|
||
ws.close(1008, "Authentication required");
|
||
return;
|
||
}
|
||
|
||
userId = payload.userId;
|
||
sessionId = payload.sessionId;
|
||
} 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);
|
||
sshLogger.info("Terminal WebSocket connection established", {
|
||
operation: "terminal_ws_connect",
|
||
sessionId,
|
||
userId,
|
||
});
|
||
|
||
let currentSessionId: string | null = null;
|
||
let sshConn: SSHClientType | null = null;
|
||
let sshStream: ClientChannel | null = null;
|
||
let lastJumpClient: SSHClientType | null = null;
|
||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||
let totpPromptSent = false;
|
||
let totpTimeout: NodeJS.Timeout | null = null;
|
||
let isKeyboardInteractive = false;
|
||
let keyboardInteractiveResponded = false;
|
||
let isConnecting = false;
|
||
let isConnected = false;
|
||
let isCleaningUp = false;
|
||
let cwdPending = false;
|
||
let cwdBuffer = "";
|
||
let isShellInitializing = false;
|
||
let warpgateAuthPromptSent = false;
|
||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||
let isAwaitingAuthCredentials = false;
|
||
|
||
let wsAlive = true;
|
||
|
||
ws.on("pong", () => {
|
||
wsAlive = true;
|
||
});
|
||
|
||
const wsPingInterval = setInterval(() => {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
if (!wsAlive) {
|
||
sshLogger.warn(
|
||
"WebSocket pong timeout - terminating zombie connection",
|
||
{
|
||
operation: "ws_pong_timeout",
|
||
userId,
|
||
sessionId: currentSessionId,
|
||
},
|
||
);
|
||
ws.terminate();
|
||
return;
|
||
}
|
||
wsAlive = false;
|
||
ws.ping();
|
||
}
|
||
}, 30000);
|
||
|
||
ws.on("close", () => {
|
||
clearInterval(wsPingInterval);
|
||
sshLogger.info("Terminal WebSocket disconnected", {
|
||
operation: "terminal_ws_disconnect",
|
||
sessionId,
|
||
userId,
|
||
});
|
||
const userWs = userConnections.get(userId);
|
||
if (userWs) {
|
||
userWs.delete(ws);
|
||
if (userWs.size === 0) {
|
||
userConnections.delete(userId);
|
||
}
|
||
}
|
||
|
||
if (currentSessionId) {
|
||
const session = sessionManager.getSession(currentSessionId);
|
||
if (session?.isConnected) {
|
||
sessionManager.detachWs(currentSessionId);
|
||
} else {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
}
|
||
cleanupAuthState();
|
||
});
|
||
|
||
function resetConnectionState() {
|
||
isConnecting = false;
|
||
isConnected = false;
|
||
isKeyboardInteractive = false;
|
||
keyboardInteractiveResponded = false;
|
||
keyboardInteractiveFinish = null;
|
||
totpPromptSent = false;
|
||
warpgateAuthPromptSent = false;
|
||
}
|
||
|
||
ws.on("message", async (msg: RawData) => {
|
||
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;
|
||
}
|
||
|
||
let parsed: WebSocketMessage;
|
||
try {
|
||
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
||
} catch (e) {
|
||
sshLogger.error("Invalid JSON received", e, {
|
||
operation: "websocket_message_invalid_json",
|
||
userId,
|
||
messageLength: msg.toString().length,
|
||
});
|
||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||
return;
|
||
}
|
||
|
||
const { type, data } = parsed;
|
||
|
||
switch (type) {
|
||
case "connectToHost": {
|
||
const connectData = data as ConnectToHostData;
|
||
if (connectData.hostConfig) {
|
||
connectData.hostConfig.userId = userId;
|
||
}
|
||
handleConnectToHost(connectData).catch((error) => {
|
||
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;
|
||
}
|
||
sshLogger.error("Failed to connect to host", error, {
|
||
operation: "ssh_connect",
|
||
userId,
|
||
hostId: connectData.hostConfig?.id,
|
||
ip: connectData.hostConfig?.ip,
|
||
});
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message: "Failed to connect to host: " + errMsg,
|
||
}),
|
||
);
|
||
});
|
||
break;
|
||
}
|
||
|
||
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,
|
||
tmuxSessionName: s.tmuxSessionName,
|
||
})),
|
||
}),
|
||
);
|
||
break;
|
||
}
|
||
|
||
case "resize": {
|
||
const resizeData = data as ResizeData;
|
||
handleResize(resizeData);
|
||
break;
|
||
}
|
||
|
||
case "disconnect":
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState();
|
||
sshConn = null;
|
||
sshStream = null;
|
||
break;
|
||
|
||
case "get_cwd": {
|
||
const activeStream =
|
||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||
if (!activeStream) {
|
||
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||
break;
|
||
}
|
||
cwdPending = true;
|
||
cwdBuffer = "";
|
||
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
||
break;
|
||
}
|
||
|
||
case "input": {
|
||
const inputData = data as string;
|
||
const inputStream =
|
||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||
if (inputStream) {
|
||
if (inputData === "\t") {
|
||
inputStream.write(inputData);
|
||
} else if (
|
||
typeof inputData === "string" &&
|
||
inputData.startsWith("\x1b")
|
||
) {
|
||
inputStream.write(inputData);
|
||
} else {
|
||
try {
|
||
inputStream.write(Buffer.from(inputData, "utf8"));
|
||
} catch (error) {
|
||
sshLogger.error("Error writing input to SSH stream", error, {
|
||
operation: "ssh_input_encoding",
|
||
userId,
|
||
dataLength: inputData.length,
|
||
});
|
||
inputStream.write(Buffer.from(inputData, "latin1"));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "ping":
|
||
ws.send(JSON.stringify({ type: "pong" }));
|
||
break;
|
||
|
||
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) {
|
||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||
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 {
|
||
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
|
||
attachOrCreateTmuxSession(session.sshStream, undefined, newName);
|
||
const sshConn = session.sshConn;
|
||
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,
|
||
}),
|
||
);
|
||
})();
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
case "totp_response": {
|
||
const totpData = data as TOTPResponseData;
|
||
if (keyboardInteractiveFinish && totpData?.code) {
|
||
if (totpTimeout) {
|
||
clearTimeout(totpTimeout);
|
||
totpTimeout = null;
|
||
}
|
||
const totpCode = totpData.code;
|
||
keyboardInteractiveFinish([totpCode]);
|
||
keyboardInteractiveFinish = null;
|
||
totpPromptSent = false;
|
||
} 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) {
|
||
if (totpTimeout) {
|
||
clearTimeout(totpTimeout);
|
||
totpTimeout = null;
|
||
}
|
||
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;
|
||
}
|
||
|
||
case "warpgate_auth_continue": {
|
||
if (keyboardInteractiveFinish) {
|
||
if (warpgateAuthTimeout) {
|
||
clearTimeout(warpgateAuthTimeout);
|
||
warpgateAuthTimeout = null;
|
||
}
|
||
keyboardInteractiveFinish([""]);
|
||
keyboardInteractiveFinish = null;
|
||
warpgateAuthPromptSent = false;
|
||
}
|
||
break;
|
||
}
|
||
|
||
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";
|
||
(
|
||
credentialsData.hostConfig as Record<string, unknown>
|
||
).userProvidedPassword = true;
|
||
} else if (credentialsData.sshKey) {
|
||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||
credentialsData.hostConfig.authType = "key";
|
||
} else if (credentialsData.keyPassword) {
|
||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||
}
|
||
|
||
isAwaitingAuthCredentials = false;
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState();
|
||
sshConn = null;
|
||
sshStream = null;
|
||
|
||
const reconnectData: ConnectToHostData = {
|
||
cols: credentialsData.cols,
|
||
rows: credentialsData.rows,
|
||
hostConfig: credentialsData.hostConfig,
|
||
};
|
||
|
||
handleConnectToHost(reconnectData).catch((error) => {
|
||
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;
|
||
}
|
||
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",
|
||
message: "Failed to connect with provided credentials: " + errMsg,
|
||
}),
|
||
);
|
||
});
|
||
break;
|
||
}
|
||
|
||
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");
|
||
const db = getDb();
|
||
const hostRow = await db
|
||
.select()
|
||
.from(hosts)
|
||
.where(eq(hosts.id, opksshData.hostId))
|
||
.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;
|
||
hostConfig?: ConnectToHostData["hostConfig"];
|
||
};
|
||
|
||
resetConnectionState();
|
||
|
||
const reconnectConfig: ConnectToHostData = {
|
||
cols: completedData.cols || 80,
|
||
rows: completedData.rows || 24,
|
||
hostConfig:
|
||
completedData.hostConfig ||
|
||
({
|
||
id: completedData.hostId,
|
||
ip: "",
|
||
port: 22,
|
||
username: "",
|
||
userId,
|
||
} as ConnectToHostData["hostConfig"]),
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
default:
|
||
sshLogger.warn("Unknown message type received", {
|
||
operation: "websocket_message_unknown_type",
|
||
userId,
|
||
messageType: type,
|
||
});
|
||
}
|
||
});
|
||
|
||
async function handleConnectToHost(data: ConnectToHostData) {
|
||
const { hostConfig, initialPath, executeCommand } = data;
|
||
const {
|
||
id,
|
||
ip: rawIp,
|
||
port: clientPort,
|
||
username: clientUsername,
|
||
password,
|
||
key,
|
||
keyPassword,
|
||
keyType,
|
||
authType,
|
||
credentialId,
|
||
} = hostConfig;
|
||
const clientIp = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp;
|
||
let ip = clientIp;
|
||
let port = clientPort;
|
||
let username = clientUsername;
|
||
sshLogger.info("Resolving SSH host configuration", {
|
||
operation: "terminal_host_resolve",
|
||
sessionId,
|
||
userId,
|
||
hostId: id,
|
||
});
|
||
|
||
const sendLog = (
|
||
stage: string,
|
||
level: string,
|
||
message: string,
|
||
details?: Record<string, unknown>,
|
||
) => {
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "connection_log",
|
||
data: { stage, level, message, details },
|
||
}),
|
||
);
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
if (isConnecting || isConnected) {
|
||
sshLogger.warn("Connection already in progress or established", {
|
||
operation: "ssh_connect",
|
||
hostId: id,
|
||
isConnecting,
|
||
isConnected,
|
||
});
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message: "Connection already in progress",
|
||
code: "DUPLICATE_CONNECTION",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
isConnecting = true;
|
||
sshConn = new Client();
|
||
|
||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||
|
||
const connectionTimeout = setTimeout(() => {
|
||
if (sshConn && isConnecting && !isConnected) {
|
||
sshLogger.error("SSH connection timeout", undefined, {
|
||
operation: "ssh_connect",
|
||
hostId: id,
|
||
ip,
|
||
port,
|
||
username,
|
||
});
|
||
ws.send(
|
||
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
}
|
||
}, 120000);
|
||
|
||
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",
|
||
});
|
||
}
|
||
}
|
||
|
||
// Resolve credentials server-side when frontend doesn't provide them
|
||
let resolvedCredentials = {
|
||
username,
|
||
password,
|
||
key,
|
||
keyPassword,
|
||
keyType,
|
||
authType,
|
||
certPublicKey: undefined as string | undefined,
|
||
};
|
||
const authMethodNotAvailable = false;
|
||
if (id && userId && !password && !key) {
|
||
try {
|
||
if (resolvedHostData) {
|
||
ip = resolvedHostData.ip || ip;
|
||
port = resolvedHostData.port || port;
|
||
username = resolvedHostData.username || username;
|
||
resolvedCredentials = {
|
||
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,
|
||
};
|
||
sendLog(
|
||
"auth",
|
||
"info",
|
||
"Credentials resolved from server-side host data",
|
||
);
|
||
}
|
||
} 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 {
|
||
if (resolvedHostData) {
|
||
ip = resolvedHostData.ip || ip;
|
||
port = resolvedHostData.port || port;
|
||
username = resolvedHostData.username || username;
|
||
resolvedCredentials = {
|
||
username: resolvedHostData.username || username,
|
||
password: resolvedHostData.password,
|
||
key: resolvedHostData.key,
|
||
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
|
||
keyPassword: keyPassword || resolvedHostData.keyPassword,
|
||
keyType: resolvedHostData.keyType,
|
||
authType: resolvedHostData.authType,
|
||
certPublicKey: resolvedHostData.certPublicKey as string | undefined,
|
||
};
|
||
}
|
||
} 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",
|
||
});
|
||
}
|
||
}
|
||
|
||
sshConn.on("ready", () => {
|
||
clearTimeout(connectionTimeout);
|
||
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,
|
||
});
|
||
}
|
||
sendLog("handshake", "success", "SSH handshake completed");
|
||
sendLog("auth", "success", `Authentication successful for ${username}`);
|
||
sendLog("connected", "success", "Connection established");
|
||
|
||
const hostDisplayName = `${username}@${ip}:${port}`;
|
||
const tabInstanceId = hostConfig.instanceId;
|
||
currentSessionId = sessionManager.createSession(
|
||
userId,
|
||
id,
|
||
hostDisplayName,
|
||
data.cols,
|
||
data.rows,
|
||
tabInstanceId,
|
||
);
|
||
|
||
// 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;
|
||
}
|
||
|
||
sshLogger.info("Terminal session created after SSH ready", {
|
||
operation: "terminal_session_created",
|
||
sessionId: currentSessionId,
|
||
userId,
|
||
hostId: id,
|
||
tabInstanceId,
|
||
ip,
|
||
port,
|
||
});
|
||
|
||
const conn = sshConn;
|
||
|
||
if (!conn || isCleaningUp || !sshConn) {
|
||
sshLogger.warn(
|
||
"SSH connection was cleaned up before shell could be created",
|
||
{
|
||
operation: "ssh_shell",
|
||
hostId: id,
|
||
ip,
|
||
port,
|
||
username,
|
||
isCleaningUp,
|
||
connNull: !conn,
|
||
sshConnNull: !sshConn,
|
||
},
|
||
);
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message:
|
||
"SSH connection was closed before terminal could be created",
|
||
}),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
|
||
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.",
|
||
}),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
}
|
||
}, 15000);
|
||
|
||
conn.shell(
|
||
{
|
||
rows: data.rows,
|
||
cols: data.cols,
|
||
term: "xterm-256color",
|
||
} as PseudoTtyOptions,
|
||
(err, stream) => {
|
||
shellCallbackReceived = true;
|
||
clearTimeout(shellTimeout);
|
||
isShellInitializing = false;
|
||
|
||
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,
|
||
}),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
|
||
sshStream = stream;
|
||
sshLogger.success("Terminal shell channel opened", {
|
||
operation: "terminal_shell_opened",
|
||
sessionId,
|
||
userId,
|
||
hostId: id,
|
||
termType: "xterm-256color",
|
||
});
|
||
|
||
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;
|
||
|
||
const CWD_SENTINEL = "TERMIX_CWD:";
|
||
|
||
stream.on("data", (data: Buffer) => {
|
||
try {
|
||
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;
|
||
|
||
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 }),
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
sshLogger.error("Error encoding terminal data", error, {
|
||
operation: "terminal_data_encoding",
|
||
hostId: id,
|
||
dataLength: data.length,
|
||
});
|
||
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 }),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code: number | null) => {
|
||
const session = sessionManager.getSession(boundSessionId);
|
||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||
if (code != null) {
|
||
session.attachedWs.send(
|
||
JSON.stringify({
|
||
type: "session_ended",
|
||
code,
|
||
}),
|
||
);
|
||
} else {
|
||
session.attachedWs.send(
|
||
JSON.stringify({
|
||
type: "disconnected",
|
||
message: "Connection lost",
|
||
graceful: true,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
if (boundSessionId) {
|
||
sessionManager.destroySession(boundSessionId);
|
||
if (currentSessionId === boundSessionId) {
|
||
currentSessionId = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.on("error", (err: Error) => {
|
||
sshLogger.error("SSH stream error", err, {
|
||
operation: "ssh_stream",
|
||
hostId: id,
|
||
ip,
|
||
port,
|
||
username,
|
||
});
|
||
const session = sessionManager.getSession(boundSessionId);
|
||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||
session.attachedWs.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message: "SSH stream error: " + err.message,
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
|
||
|
||
// Helper to run initialPath/executeCommand after the shell
|
||
// (or tmux session) is ready
|
||
const runPostShellCommands = (delay: number) => {
|
||
setTimeout(() => {
|
||
if (initialPath && initialPath.trim() !== "") {
|
||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
||
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) {
|
||
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);
|
||
} 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);
|
||
}
|
||
|
||
ws.send(
|
||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||
);
|
||
|
||
if (id && hostConfig.userId) {
|
||
(async () => {
|
||
try {
|
||
const hostResults = await SimpleDBOps.select(
|
||
getDb()
|
||
.select()
|
||
.from(hosts)
|
||
.where(
|
||
and(
|
||
eq(hosts.id, id),
|
||
eq(hosts.userId, hostConfig.userId!),
|
||
),
|
||
),
|
||
"ssh_data",
|
||
hostConfig.userId!,
|
||
);
|
||
|
||
const hostName =
|
||
hostResults.length > 0 && hostResults[0].name
|
||
? hostResults[0].name
|
||
: `${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",
|
||
});
|
||
}
|
||
})();
|
||
}
|
||
},
|
||
);
|
||
});
|
||
|
||
sshConn.on("error", (err: Error) => {
|
||
clearTimeout(connectionTimeout);
|
||
|
||
sendLog("error", "error", `Connection error: ${err.message}`);
|
||
|
||
sshLogger.error("SSH connection error", err, {
|
||
operation: "ssh_connect",
|
||
hostId: id,
|
||
ip,
|
||
port,
|
||
username,
|
||
authType: resolvedCredentials.authType,
|
||
warpgateAuthPromptSent,
|
||
isKeyboardInteractive,
|
||
hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish,
|
||
keyboardInteractiveResponded,
|
||
});
|
||
|
||
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,
|
||
});
|
||
}
|
||
})();
|
||
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
if (
|
||
authMethodNotAvailable &&
|
||
resolvedCredentials.authType === "none" &&
|
||
!isKeyboardInteractive
|
||
) {
|
||
sendLog(
|
||
"auth",
|
||
"error",
|
||
"Server does not support keyboard-interactive authentication",
|
||
);
|
||
isAwaitingAuthCredentials = true;
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
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;
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
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,
|
||
},
|
||
);
|
||
resetConnectionState();
|
||
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")
|
||
) {
|
||
authLogger.error("SSH authentication failed", err, {
|
||
operation: "terminal_ssh_auth_failed",
|
||
sessionId,
|
||
userId,
|
||
hostId: id,
|
||
authType: resolvedCredentials.authType,
|
||
});
|
||
sendLog("auth", "error", `Authentication failed: ${err.message}`);
|
||
} else {
|
||
sendLog("error", "error", `Connection failed: ${err.message}`);
|
||
}
|
||
|
||
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("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.";
|
||
} 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 }));
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
});
|
||
|
||
sshConn.on("close", () => {
|
||
clearTimeout(connectionTimeout);
|
||
sshLogger.info("SSH connection closed", {
|
||
operation: "terminal_ssh_disconnected",
|
||
sessionId,
|
||
userId,
|
||
hostId: id,
|
||
});
|
||
|
||
if (isAwaitingAuthCredentials) {
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
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,
|
||
});
|
||
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.",
|
||
}),
|
||
);
|
||
}
|
||
} else {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "disconnected",
|
||
message: "Connection closed",
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
});
|
||
|
||
const sshAuthManager = new SSHAuthManager({
|
||
userId,
|
||
ws,
|
||
hostId: id || 0,
|
||
isKeyboardInteractive,
|
||
keyboardInteractiveResponded,
|
||
keyboardInteractiveFinish,
|
||
totpPromptSent,
|
||
warpgateAuthPromptSent,
|
||
totpTimeout,
|
||
warpgateAuthTimeout,
|
||
totpAttempts: 0,
|
||
});
|
||
|
||
sshConn.on(
|
||
"keyboard-interactive",
|
||
(
|
||
name: string,
|
||
instructions: string,
|
||
instructionsLang: string,
|
||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||
finish: (responses: string[]) => void,
|
||
) => {
|
||
if (connectionTimeout) {
|
||
clearTimeout(connectionTimeout);
|
||
}
|
||
|
||
sshAuthManager.handleKeyboardInteractive(
|
||
name,
|
||
instructions,
|
||
instructionsLang,
|
||
prompts,
|
||
finish,
|
||
resolvedCredentials as unknown as Parameters<
|
||
typeof sshAuthManager.handleKeyboardInteractive
|
||
>[5],
|
||
);
|
||
|
||
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;
|
||
},
|
||
);
|
||
|
||
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
|
||
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
|
||
|
||
const connectConfig: Record<string, unknown> = {
|
||
host: ip,
|
||
port,
|
||
username,
|
||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||
keepaliveInterval:
|
||
typeof hostKeepaliveInterval === "number"
|
||
? hostKeepaliveInterval * 1000
|
||
: 30000,
|
||
keepaliveCountMax:
|
||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
|
||
readyTimeout: 120000,
|
||
tcpKeepAlive: true,
|
||
tcpKeepAliveInitialDelay: 30000,
|
||
timeout: 120000,
|
||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||
id,
|
||
ip,
|
||
port,
|
||
ws,
|
||
userId,
|
||
false,
|
||
),
|
||
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: [
|
||
"curve25519-sha256",
|
||
"curve25519-sha256@libssh.org",
|
||
"ecdh-sha2-nistp521",
|
||
"ecdh-sha2-nistp384",
|
||
"ecdh-sha2-nistp256",
|
||
"diffie-hellman-group-exchange-sha256",
|
||
"diffie-hellman-group18-sha512",
|
||
"diffie-hellman-group17-sha512",
|
||
"diffie-hellman-group16-sha512",
|
||
"diffie-hellman-group15-sha512",
|
||
"diffie-hellman-group14-sha256",
|
||
"diffie-hellman-group14-sha1",
|
||
"diffie-hellman-group-exchange-sha1",
|
||
"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",
|
||
],
|
||
cipher: SSH_ALGORITHMS.cipher,
|
||
hmac: [
|
||
"hmac-sha2-512-etm@openssh.com",
|
||
"hmac-sha2-256-etm@openssh.com",
|
||
"hmac-sha2-512",
|
||
"hmac-sha2-256",
|
||
"hmac-sha1",
|
||
"hmac-md5",
|
||
],
|
||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||
},
|
||
};
|
||
|
||
if (resolvedCredentials.authType === "none") {
|
||
// no credentials needed
|
||
} 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;
|
||
}
|
||
sendLog("auth", "info", "Using password authentication");
|
||
} else if (
|
||
resolvedCredentials.authType === "key" &&
|
||
resolvedCredentials.key
|
||
) {
|
||
sendLog("auth", "info", "Using SSH key authentication");
|
||
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.password) {
|
||
connectConfig.password = resolvedCredentials.password;
|
||
}
|
||
|
||
// 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),
|
||
});
|
||
}
|
||
}
|
||
} 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") {
|
||
sendLog(
|
||
"auth",
|
||
"error",
|
||
"SSH key authentication requested but no key provided",
|
||
);
|
||
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 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");
|
||
|
||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
|
||
} 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;
|
||
}
|
||
} else {
|
||
sendLog("auth", "info", "Using keyboard-interactive authentication");
|
||
sshLogger.error("No valid authentication method provided");
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message: "No valid authentication method provided",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
|
||
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);
|
||
} catch {
|
||
sshLogger.warn("Port knocking failed, attempting connection anyway", {
|
||
operation: "port_knock",
|
||
hostId: hostConfig.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
const proxyConfig: SOCKS5Config | null =
|
||
hostConfig.useSocks5 &&
|
||
(hostConfig.socks5Host ||
|
||
(hostConfig.socks5ProxyChain &&
|
||
(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;
|
||
|
||
const hasJumpHosts =
|
||
hostConfig.jumpHosts &&
|
||
hostConfig.jumpHosts.length > 0 &&
|
||
hostConfig.userId;
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
if (hasJumpHosts) {
|
||
try {
|
||
const jumpClient = await createJumpHostChain(
|
||
hostConfig.jumpHosts!,
|
||
hostConfig.userId!,
|
||
proxyConfig,
|
||
);
|
||
|
||
if (!jumpClient) {
|
||
sshLogger.error("Failed to establish jump host chain");
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "error",
|
||
message: "Failed to connect through jump hosts",
|
||
}),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
lastJumpClient = jumpClient;
|
||
|
||
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();
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
|
||
connectConfig.sock = stream;
|
||
sendLog(
|
||
"handshake",
|
||
"info",
|
||
"Starting SSH session through jump host" +
|
||
(proxyConfig ? " (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: !!proxyConfig,
|
||
});
|
||
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",
|
||
}),
|
||
);
|
||
if (currentSessionId) {
|
||
sessionManager.destroySession(currentSessionId);
|
||
currentSessionId = null;
|
||
}
|
||
cleanupAuthState(connectionTimeout);
|
||
return;
|
||
}
|
||
} 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);
|
||
} else {
|
||
sendLog("handshake", "info", "Starting SSH session");
|
||
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,
|
||
});
|
||
sshConn.connect(connectConfig);
|
||
}
|
||
}
|
||
|
||
function handleResize(data: ResizeData) {
|
||
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;
|
||
}
|
||
ws.send(
|
||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||
);
|
||
}
|
||
}
|
||
|
||
function cleanupAuthState(timeoutId?: NodeJS.Timeout) {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
if (totpTimeout) {
|
||
clearTimeout(totpTimeout);
|
||
totpTimeout = null;
|
||
}
|
||
|
||
if (warpgateAuthTimeout) {
|
||
clearTimeout(warpgateAuthTimeout);
|
||
warpgateAuthTimeout = null;
|
||
}
|
||
|
||
sshStream = null;
|
||
sshConn = null;
|
||
lastJumpClient = null;
|
||
|
||
resetConnectionState();
|
||
isCleaningUp = false;
|
||
isAwaitingAuthCredentials = false;
|
||
}
|
||
|
||
// 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
|
||
});
|