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 ;
2026-04-22 16:55:23 -05:00
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js" ;
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" ;
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" ;
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" ;
2026-03-08 18:02:14 -05:00
import type { ProxyNode } from "../../types/index.js" ;
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" ;
2026-03-08 18:02:14 -05:00
import { sessionManager } from "./terminal-session-manager.js" ;
2026-04-22 16:55:23 -05:00
import {
detectTmux ,
attachOrCreateTmuxSession ,
2026-05-28 22:29:20 -05:00
waitForTmuxSession ,
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
2025-11-05 10:36:16 -06:00
interface ConnectToHostData {
cols : number ;
rows : number ;
hostConfig : {
id : number ;
2026-03-08 18:02:14 -05:00
instanceId? : string ;
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 } > ;
2025-12-31 22:20:12 -06:00
useSocks5? : boolean ;
socks5Host? : string ;
socks5Port? : number ;
socks5Username? : string ;
socks5Password? : string ;
socks5ProxyChain? : unknown ;
2026-04-22 16:55:23 -05:00
portKnockSequence? : Array < {
port : number ;
protocol ?: "tcp" | "udp" ;
delay? : number ;
} > ;
2026-03-08 18:02:14 -05:00
terminalConfig ?: {
keepaliveInterval? : number ;
keepaliveCountMax? : number ;
[ key : string ] : unknown ;
};
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 ;
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 ]);
}
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 );
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 ;
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 );
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
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 ;
2025-11-05 10:36:16 -06:00
let keyboardInteractiveFinish : (( responses : string []) => void ) | null = null ;
let totpPromptSent = false ;
2025-12-31 22:20:12 -06:00
let totpTimeout : NodeJS.Timeout | null = null ;
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 ;
2026-04-22 16:55:23 -05:00
let wsAlive = true ;
ws . on ( "pong" , () => {
wsAlive = true ;
});
2025-09-12 14:42:00 -05:00
2026-03-08 18:02:14 -05:00
const wsPingInterval = setInterval (() => {
if ( ws . readyState === WebSocket . OPEN ) {
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 ;
2026-03-08 18:02:14 -05:00
ws . ping ();
}
}, 30000 );
2025-09-12 14:42:00 -05:00
ws . on ( "close" , () => {
2026-03-08 18:02:14 -05:00
clearInterval ( wsPingInterval );
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 );
}
}
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
});
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 ;
}
2025-11-05 10:36:16 -06:00
let parsed : WebSocketMessage ;
2025-09-12 14:42:00 -05:00
try {
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 ) {
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
}
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 ,
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 ;
2025-11-05 10:36:16 -06:00
}
2025-09-12 14:42:00 -05:00
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 ,
2026-04-22 16:55:23 -05:00
tmuxSessionName : s.tmuxSessionName ,
2026-03-08 18:02:14 -05:00
})),
}),
);
break ;
}
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 ;
2025-11-05 10:36:16 -06:00
}
2025-09-12 14:42:00 -05:00
case "disconnect" :
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 ;
}
2025-11-05 10:36:16 -06:00
case "input" : {
const inputData = data as string ;
2026-03-08 18:02:14 -05:00
const inputStream =
sessionManager . getSession ( currentSessionId ) ? . sshStream ?? sshStream ;
if ( inputStream ) {
2025-11-05 10:36:16 -06:00
if ( inputData === "\t" ) {
2026-03-08 18:02:14 -05:00
inputStream . write ( inputData );
2025-11-05 10:36:16 -06:00
} else if (
typeof inputData === "string" &&
inputData . startsWith ( "\x1b" )
) {
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 {
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 ,
2025-11-05 10:36:16 -06:00
dataLength : inputData.length ,
2025-10-03 00:02:10 -05:00
});
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 ;
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 ;
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 );
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 );
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 ,
}),
);
})();
}
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 ;
}
2025-11-05 10:36:16 -06:00
case "totp_response" : {
const totpData = data as TOTPResponseData ;
if ( keyboardInteractiveFinish && totpData ? . code ) {
2025-12-31 22:20:12 -06:00
if ( totpTimeout ) {
clearTimeout ( totpTimeout );
totpTimeout = null ;
}
2025-11-05 10:36:16 -06:00
const totpCode = totpData . code ;
keyboardInteractiveFinish ([ totpCode ]);
keyboardInteractiveFinish = null ;
2025-12-31 22:20:12 -06:00
totpPromptSent = false ;
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 ) {
2025-12-31 22:20:12 -06:00
if ( totpTimeout ) {
clearTimeout ( totpTimeout );
totpTimeout = null ;
}
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 ;
}
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" ;
2026-03-08 18:02:14 -05:00
(
credentialsData . hostConfig as Record < string , unknown >
). userProvidedPassword = true ;
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 ;
2025-11-05 10:36:16 -06:00
}
2026-01-24 19:49:42 -06:00
isAwaitingAuthCredentials = false ;
2026-03-08 18:02:14 -05:00
if ( currentSessionId ) {
sessionManager . destroySession ( currentSessionId );
currentSessionId = null ;
}
cleanupAuthState ();
sshConn = null ;
sshStream = null ;
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 ;
}
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 ,
2025-11-05 10:36:16 -06:00
}),
);
});
break ;
}
2026-02-12 22:28:13 -06:00
case "opkssh_start_auth" : {
const opksshData = data as { hostId : number };
try {
2026-03-10 00:09:58 -05:00
const { startOPKSSHAuth } = await import ( "./opkssh-auth.js" );
const { getRequestOrigin } =
await import ( "../utils/request-origin.js" );
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 ))
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 ;
2026-03-08 18:02:14 -05:00
hostConfig? : ConnectToHostData [ "hostConfig" ];
2026-02-12 22:28:13 -06:00
};
resetConnectionState ();
const reconnectConfig : ConnectToHostData = {
cols : completedData.cols || 80 ,
rows : completedData.rows || 24 ,
hostConfig :
completedData.hostConfig ||
2026-03-08 18:02:14 -05:00
({
id : completedData.hostId ,
ip : "" ,
port : 22 ,
username : "" ,
userId ,
} as ConnectToHostData [ "hostConfig" ]),
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 ,
});
}
});
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 ,
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 ;
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 ,
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 ,
});
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" }),
);
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" ,
});
}
}
2026-04-22 16:55:23 -05:00
// Resolve credentials server-side when frontend doesn't provide them
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 ,
2026-03-08 18:02:14 -05:00
};
const authMethodNotAvailable = false ;
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 ;
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 ,
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
);
}
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 ;
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 ,
2026-04-22 16:55:23 -05:00
};
2025-09-12 14:42:00 -05:00
}
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 ,
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 );
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" );
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 ;
}
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 ) {
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 ,
2025-11-05 10:36:16 -06:00
},
);
ws . send (
JSON . stringify ({
type : "error" ,
message :
"SSH connection was closed before terminal could be created" ,
}),
);
2026-03-08 18:02:14 -05:00
if ( currentSessionId ) {
sessionManager . destroySession ( currentSessionId );
currentSessionId = null ;
}
cleanupAuthState ( connectionTimeout );
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 ;
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." ,
}),
);
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 ,
}),
);
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 ;
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
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 ;
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 ,
});
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 ) => {
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
}),
);
}
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 ,
});
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
});
2026-04-22 16:55:23 -05:00
const autoTmux = hostConfig . terminalConfig ? . autoTmux === true ;
2025-10-01 15:40:10 -05:00
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 (() => {
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` ;
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 );
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" }),
);
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 (
2025-11-05 10:36:16 -06:00
getDb ()
. select ()
2026-03-14 20:05:05 -05:00
. from ( hosts )
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 ! ),
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
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 );
2025-11-05 10:36:16 -06:00
2026-01-24 19:49:42 -06:00
sendLog ( "error" , "error" , `Connection error: ${ err . message } ` );
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
});
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 ,
});
}
})();
2026-03-08 18:02:14 -05:00
if ( currentSessionId ) {
sessionManager . destroySession ( currentSessionId );
currentSessionId = null ;
2026-02-12 22:28:13 -06:00
}
2026-03-08 18:02:14 -05:00
cleanupAuthState ( connectionTimeout );
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 ;
2026-03-08 18:02:14 -05:00
if ( currentSessionId ) {
sessionManager . destroySession ( currentSessionId );
currentSessionId = null ;
2026-01-24 19:49:42 -06:00
}
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 ;
2026-03-08 18:02:14 -05:00
if ( currentSessionId ) {
sessionManager . destroySession ( currentSessionId );
currentSessionId = null ;
2026-01-24 19:49:42 -06:00
}
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 ,
},
);
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" )
) {
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." ;
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 }));
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 );
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 ) {
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 ,
});
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 {
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
}
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 ,
});
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 ,
2026-03-08 18:02:14 -05:00
resolvedCredentials as unknown as Parameters <
typeof sshAuthManager . handleKeyboardInteractive
> [ 5 ],
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 ;
2025-11-05 10:36:16 -06:00
},
);
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 ,
2026-04-22 16:55:23 -05:00
tryKeyboard : resolvedCredentials.authType !== "none" ,
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 ,
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 ,
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 : [
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" ,
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" ,
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
],
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" ,
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" ,
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" ],
},
};
2025-11-05 10:36:16 -06:00
if ( resolvedCredentials . authType === "none" ) {
2026-03-08 18:02:14 -05:00
// no credentials needed
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 ;
}
2026-01-26 00:28:40 -06:00
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 ;
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" );
2026-04-22 16:55:23 -05:00
const { setupOPKSSHCertAuth } = await import ( "./opkssh-cert-auth.js" );
await setupOPKSSHCertAuth ( connectConfig , sshConn , token , username );
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 ,
});
}
}
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 {
2026-04-22 16:55:23 -05:00
sshLogger . warn ( "Port knocking failed, attempting connection anyway" , {
operation : "port_knock" ,
hostId : hostConfig.id ,
});
}
}
2026-03-08 18:02:14 -05:00
const proxyConfig : SOCKS5Config | null =
2025-12-31 22:20:12 -06:00
hostConfig . useSocks5 &&
( hostConfig . socks5Host ||
( hostConfig . socks5ProxyChain &&
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 ;
2025-12-31 22:20:12 -06:00
2026-03-08 18:02:14 -05:00
const hasJumpHosts =
2025-11-17 09:46:05 -06:00
hostConfig . jumpHosts &&
hostConfig . jumpHosts . length > 0 &&
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 ;
}
}
2026-03-08 18:02:14 -05:00
if ( hasJumpHosts ) {
2025-11-17 09:46:05 -06:00
try {
const jumpClient = await createJumpHostChain (
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" ,
}),
);
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-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 ();
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" ,
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 } ` );
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 ,
2026-03-08 18:02:14 -05:00
viaProxy : !! proxyConfig ,
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" ,
}),
);
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-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 } ` );
2026-04-22 16:55:23 -05:00
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
}
2025-11-05 10:36:16 -06:00
function handleResize ( data : ResizeData ) {
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 }),
);
}
}
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 );
}
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 ;
}
2026-03-08 18:02:14 -05:00
sshStream = null ;
sshConn = null ;
lastJumpClient = null ;
2025-11-05 10:36:16 -06:00
2026-02-12 22:28:13 -06:00
resetConnectionState ();
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
}
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
});