2025-12-31 22:20:12 -06:00
import express from "express" ;
2026-04-22 16:55:23 -05:00
import { createCorsMiddleware } from "../utils/cors-config.js" ;
2025-12-31 22:20:12 -06:00
import cookieParser from "cookie-parser" ;
import axios from "axios" ;
import { Client as SSHClient } from "ssh2" ;
2026-05-28 22:05:25 -04:00
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js" ;
2025-12-31 22:20:12 -06:00
import { getDb } from "../database/db/index.js" ;
2026-03-14 20:05:05 -05:00
import { hosts , sshCredentials } from "../database/db/schema.js" ;
2025-12-31 22:20:12 -06:00
import { eq , and } from "drizzle-orm" ;
import { logger } from "../utils/logger.js" ;
import { SimpleDBOps } from "../utils/simple-db-ops.js" ;
import { AuthManager } from "../utils/auth-manager.js" ;
2026-04-22 16:55:23 -05:00
import type { AuthenticatedRequest } from "../../types/index.js" ;
2026-03-08 18:02:14 -05:00
import {
createSocks5Connection ,
type SOCKS5Config ,
} from "../utils/socks5-helper.js" ;
import type { SSHHost , ProxyNode } from "../../types/index.js" ;
2026-01-24 19:49:42 -06:00
import type { LogEntry , ConnectionStage } from "../../types/connection-log.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 { registerDockerContainerRoutes } from "./docker-container-routes.js" ;
2025-12-31 22:20:12 -06:00
2026-02-12 22:28:13 -06:00
const sshLogger = logger ;
2025-12-31 22:20:12 -06:00
2026-01-24 19:49:42 -06:00
function createConnectionLog (
type : "info" | "success" | "warning" | "error" ,
stage : ConnectionStage ,
message : string ,
2026-03-08 18:02:14 -05:00
details? : Record < string , unknown >,
2026-01-24 19:49:42 -06:00
) : Omit < LogEntry , "id" | "timestamp" > {
return {
type ,
stage ,
message ,
details ,
};
}
2025-12-31 22:20:12 -06:00
interface SSHSession {
client : SSHClient ;
isConnected : boolean ;
lastActive : number ;
timeout? : NodeJS.Timeout ;
activeOperations : number ;
hostId? : number ;
2026-04-22 16:55:23 -05:00
userId? : string ;
2025-12-31 22:20:12 -06:00
}
interface PendingTOTPSession {
client : SSHClient ;
finish : ( responses : string []) => void ;
2026-03-08 18:02:14 -05:00
config : Record < string , unknown >;
2025-12-31 22:20:12 -06:00
createdAt : number ;
sessionId : string ;
hostId? : number ;
ip? : string ;
port? : number ;
username? : string ;
userId? : string ;
prompts? : Array < { prompt : string ; echo : boolean } > ;
totpPromptIndex? : number ;
resolvedPassword? : string ;
totpAttempts : number ;
2026-01-24 19:49:42 -06:00
isWarpgate? : boolean ;
2025-12-31 22:20:12 -06:00
}
const sshSessions : Record < string , SSHSession > = {};
const pendingTOTPSessions : Record < string , PendingTOTPSession > = {};
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000 ;
setInterval (() => {
const now = Date . now ();
Object . keys ( pendingTOTPSessions ). forEach (( sessionId ) => {
const session = pendingTOTPSessions [ sessionId ];
if ( now - session . createdAt > 180000 ) {
try {
session . client . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2025-12-31 22:20:12 -06:00
delete pendingTOTPSessions [ sessionId ];
}
});
}, 60000 );
function cleanupSession ( sessionId : string ) {
const session = sshSessions [ sessionId ];
if ( session ) {
if ( session . activeOperations > 0 ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn (
2025-12-31 22:20:12 -06:00
`Deferring session cleanup for ${ sessionId } - ${ session . activeOperations } active operations` ,
{
operation : "cleanup_deferred" ,
sessionId ,
activeOperations : session.activeOperations ,
},
);
scheduleSessionCleanup ( sessionId );
return ;
}
try {
session . client . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2025-12-31 22:20:12 -06:00
clearTimeout ( session . timeout );
delete sshSessions [ sessionId ];
}
}
function scheduleSessionCleanup ( sessionId : string ) {
const session = sshSessions [ sessionId ];
if ( session ) {
if ( session . timeout ) clearTimeout ( session . timeout );
session . timeout = setTimeout (() => {
cleanupSession ( sessionId );
}, SESSION_IDLE_TIMEOUT );
}
}
2026-03-08 18:02:14 -05:00
interface JumpHostConfig {
id : number ;
ip : string ;
port : number ;
username : string ;
password? : string ;
key? : string ;
keyPassword? : string ;
keyType? : string ;
authType? : string ;
credentialId? : number ;
[ key : string ] : unknown ;
}
2025-12-31 22:20:12 -06:00
async function resolveJumpHost (
hostId : number ,
userId : string ,
2026-03-08 18:02:14 -05:00
) : Promise < JumpHostConfig | null > {
2025-12-31 22:20:12 -06:00
try {
2026-03-14 20:05:05 -05:00
const hostResults = await SimpleDBOps . select (
2026-05-28 22:29:20 -05:00
getDb (). select (). from ( hosts ). where ( eq ( hosts . id , hostId )),
2025-12-31 22:20:12 -06:00
"ssh_data" ,
userId ,
);
2026-03-14 20:05:05 -05:00
if ( hostResults . length === 0 ) {
2025-12-31 22:20:12 -06:00
return null ;
}
2026-03-14 20:05:05 -05:00
const host = hostResults [ 0 ];
2026-05-28 22:29:20 -05:00
const ownerId = ( host . userId || userId ) as string ;
2025-12-31 22:20:12 -06:00
if ( host . credentialId ) {
2026-05-28 22:29:20 -05:00
if ( userId !== ownerId ) {
try {
const { SharedCredentialManager } =
await import ( "../utils/shared-credential-manager.js" );
const sharedCredManager = SharedCredentialManager . getInstance ();
const sharedCred = await sharedCredManager . getSharedCredentialForUser (
hostId ,
userId ,
);
if ( sharedCred ) {
return {
... host ,
password : sharedCred.password ,
key : sharedCred.key ,
keyPassword : sharedCred.keyPassword ,
keyType : sharedCred.keyType ,
authType : sharedCred.key
? "key"
: sharedCred . password
? "password"
: "none" ,
} as JumpHostConfig ;
}
} catch {
// fall through to owner credential lookup
}
}
2025-12-31 22:20:12 -06:00
const credentials = await SimpleDBOps . select (
getDb ()
. select ()
. from ( sshCredentials )
. where (
and (
eq ( sshCredentials . id , host . credentialId as number ),
2026-05-28 22:29:20 -05:00
eq ( sshCredentials . userId , ownerId ),
2025-12-31 22:20:12 -06:00
),
),
"ssh_credentials" ,
2026-05-28 22:29:20 -05:00
ownerId ,
2025-12-31 22:20:12 -06:00
);
if ( credentials . length > 0 ) {
const credential = credentials [ 0 ];
return {
... host ,
2026-03-08 18:02:14 -05:00
password : credential.password as string | undefined ,
2026-05-28 22:29:20 -05:00
key : ( credential . key || credential . privateKey ) as string | undefined ,
2026-03-08 18:02:14 -05:00
keyPassword : credential.keyPassword as string | undefined ,
keyType : credential.keyType as string | undefined ,
authType : credential.authType as string | undefined ,
} as JumpHostConfig ;
2025-12-31 22:20:12 -06:00
}
}
2026-03-08 18:02:14 -05:00
return host as JumpHostConfig ;
2025-12-31 22:20:12 -06:00
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Failed to resolve jump host" , error , {
2025-12-31 22:20:12 -06:00
operation : "resolve_jump_host" ,
hostId ,
userId ,
});
return null ;
}
}
async function createJumpHostChain (
jumpHosts : Array < { hostId : number } > ,
userId : string ,
2026-03-08 18:02:14 -05:00
socks5Config? : SOCKS5Config | null ,
2025-12-31 22:20:12 -06:00
) : Promise < SSHClient | null > {
if ( ! jumpHosts || jumpHosts . length === 0 ) {
return null ;
}
let currentClient : SSHClient | null = null ;
const clients : SSHClient [] = [];
try {
const jumpHostConfigs = await Promise . all (
jumpHosts . map (( jh ) => resolveJumpHost ( jh . hostId , userId )),
);
2026-03-08 18:02:14 -05:00
const totalHops = jumpHostConfigs . length ;
2025-12-31 22:20:12 -06:00
for ( let i = 0 ; i < jumpHostConfigs . length ; i ++ ) {
if ( ! jumpHostConfigs [ i ]) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( `Jump host ${ i + 1 } not found` , undefined , {
2025-12-31 22:20:12 -06:00
operation : "jump_host_chain" ,
hostId : jumpHosts [ i ]. hostId ,
2026-03-08 18:02:14 -05:00
hopIndex : i ,
totalHops ,
2025-12-31 22:20:12 -06:00
});
clients . forEach (( c ) => c . end ());
return null ;
}
}
2026-03-08 18:02:14 -05:00
let proxySocket : import ( "net" ). Socket | null = null ;
if ( socks5Config ? . useSocks5 ) {
const firstHop = jumpHostConfigs [ 0 ] ! ;
proxySocket = await createSocks5Connection (
firstHop . ip ,
firstHop . port || 22 ,
socks5Config ,
);
}
2025-12-31 22:20:12 -06:00
for ( let i = 0 ; i < jumpHostConfigs . length ; i ++ ) {
2026-03-08 18:02:14 -05:00
const jumpHostConfig = jumpHostConfigs [ i ] ! ;
2025-12-31 22:20:12 -06:00
const jumpClient = new SSHClient ();
clients . push ( jumpClient );
2026-02-12 22:28:13 -06:00
const jumpHostVerifier = await SSHHostKeyVerifier . createHostVerifier (
jumpHostConfig . id ,
jumpHostConfig . ip ,
jumpHostConfig . port || 22 ,
null ,
userId ,
true ,
);
2025-12-31 22:20:12 -06:00
const connected = await new Promise < boolean >(( resolve ) => {
const timeout = setTimeout (() => {
resolve ( false );
}, 30000 );
jumpClient . on ( "ready" , () => {
clearTimeout ( timeout );
resolve ( true );
});
jumpClient . on ( "error" , ( err ) => {
clearTimeout ( timeout );
2026-03-08 18:02:14 -05:00
sshLogger . error (
`Jump host ${ i + 1 } / ${ totalHops } connection failed` ,
err ,
{
operation : "jump_host_connect" ,
hostId : jumpHostConfig.id ,
ip : jumpHostConfig.ip ,
hopIndex : i ,
totalHops ,
previousHop :
i > 0
? jumpHostConfigs [ i - 1 ] ? . ip
: proxySocket
? "proxy"
: "direct" ,
usedProxySocket : i === 0 && !! proxySocket ,
},
);
2025-12-31 22:20:12 -06:00
resolve ( false );
});
2026-03-08 18:02:14 -05:00
const connectConfig : Record < string , unknown > = {
host : jumpHostConfig.ip?.replace ( /^\[|\]$/g , "" ) || jumpHostConfig . ip ,
2025-12-31 22:20:12 -06:00
port : jumpHostConfig.port || 22 ,
username : jumpHostConfig.username ,
2026-05-28 22:05:25 -04:00
tryKeyboard : jumpHostConfig.authType !== "none" ,
readyTimeout : 60000 ,
2026-02-12 22:28:13 -06:00
hostVerifier : jumpHostVerifier ,
2026-05-28 22:05:25 -04:00
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" ],
},
2025-12-31 22:20:12 -06:00
};
if ( jumpHostConfig . authType === "password" && jumpHostConfig . password ) {
connectConfig . password = jumpHostConfig . password ;
} else if ( jumpHostConfig . authType === "key" && jumpHostConfig . key ) {
const cleanKey = jumpHostConfig . key
. trim ()
. replace ( /\r\n/g , "\n" )
. replace ( /\r/g , "\n" );
connectConfig . privateKey = Buffer . from ( cleanKey , "utf8" );
if ( jumpHostConfig . keyPassword ) {
connectConfig . passphrase = jumpHostConfig . keyPassword ;
}
}
2026-05-28 22:05:25 -04:00
jumpClient . on (
"keyboard-interactive" ,
(
_name : string ,
_instructions : string ,
_lang : string ,
prompts : Array < { prompt : string ; echo : boolean } > ,
finish : ( responses : string []) => void ,
) => {
const responses = prompts . map (( p ) => {
if ( /password/i . test ( p . prompt ) && jumpHostConfig . password ) {
return jumpHostConfig . password as string ;
}
return "" ;
});
finish ( responses );
},
);
2025-12-31 22:20:12 -06:00
if ( currentClient ) {
currentClient . forwardOut (
"127.0.0.1" ,
0 ,
jumpHostConfig . ip ,
jumpHostConfig . port || 22 ,
( err , stream ) => {
if ( err ) {
clearTimeout ( timeout );
resolve ( false );
return ;
}
connectConfig . sock = stream ;
jumpClient . connect ( connectConfig );
},
);
2026-03-08 18:02:14 -05:00
} else if ( proxySocket ) {
connectConfig . sock = proxySocket ;
jumpClient . connect ( connectConfig );
2025-12-31 22:20:12 -06:00
} else {
jumpClient . connect ( connectConfig );
}
});
if ( ! connected ) {
clients . forEach (( c ) => c . end ());
return null ;
}
currentClient = jumpClient ;
}
return currentClient ;
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Failed to create jump host chain" , error , {
2025-12-31 22:20:12 -06:00
operation : "jump_host_chain" ,
});
clients . forEach (( c ) => c . end ());
return null ;
}
}
async function executeDockerCommand (
session : SSHSession ,
command : string ,
2026-02-12 22:28:13 -06:00
sessionId? : string ,
userId? : string ,
hostId? : number ,
2025-12-31 22:20:12 -06:00
) : Promise < string > {
2026-02-12 22:28:13 -06:00
const startTime = Date . now ();
sshLogger . info ( "Executing Docker command" , {
operation : "docker_command_exec" ,
sessionId ,
userId ,
hostId ,
command : command.split ( " " )[ 1 ],
});
2025-12-31 22:20:12 -06:00
return new Promise (( resolve , reject ) => {
session . client . exec ( command , ( err , stream ) => {
if ( err ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker command execution error" , err , {
2025-12-31 22:20:12 -06:00
operation : "execute_docker_command" ,
2026-02-12 22:28:13 -06:00
sessionId ,
userId ,
hostId ,
command : command.split ( " " )[ 1 ],
2025-12-31 22:20:12 -06:00
});
return reject ( err );
}
let stdout = "" ;
let stderr = "" ;
stream . on ( "close" , ( code : number ) => {
if ( code !== 0 ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker command failed" , undefined , {
2025-12-31 22:20:12 -06:00
operation : "execute_docker_command" ,
2026-02-12 22:28:13 -06:00
sessionId ,
userId ,
hostId ,
command : command.split ( " " )[ 1 ],
2025-12-31 22:20:12 -06:00
exitCode : code ,
stderr ,
});
reject ( new Error ( stderr || `Command exited with code ${ code } ` ));
} else {
2026-02-12 22:28:13 -06:00
sshLogger . success ( "Docker command completed" , {
operation : "docker_command_success" ,
sessionId ,
userId ,
hostId ,
command : command.split ( " " )[ 1 ],
duration : Date.now () - startTime ,
});
2025-12-31 22:20:12 -06:00
resolve ( stdout );
}
});
stream . on ( "data" , ( data : Buffer ) => {
stdout += data . toString ();
});
stream . stderr . on ( "data" , ( data : Buffer ) => {
stderr += data . toString ();
});
stream . on ( "error" , ( streamErr : Error ) => {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker command stream error" , streamErr , {
2025-12-31 22:20:12 -06:00
operation : "execute_docker_command" ,
2026-02-12 22:28:13 -06:00
sessionId ,
userId ,
hostId ,
command : command.split ( " " )[ 1 ],
2025-12-31 22:20:12 -06:00
});
reject ( streamErr );
});
});
});
}
const app = express ();
2026-04-22 16:55:23 -05:00
app . use ( createCorsMiddleware ([ "GET" , "POST" , "PUT" , "DELETE" , "OPTIONS" ]));
2025-12-31 22:20:12 -06:00
app . use ( cookieParser ());
app . use ( express . json ({ limit : "100mb" }));
app . use ( express . urlencoded ({ limit : "100mb" , extended : true }));
2026-03-08 18:02:14 -05:00
app . use (( _req , res , next ) => {
res . setHeader ( "Cache-Control" , "no-store" );
next ();
});
2025-12-31 22:20:12 -06:00
const authManager = AuthManager . getInstance ();
app . use ( authManager . createAuthMiddleware ());
2026-04-22 16:55:23 -05:00
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/ ;
const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/ ;
2026-06-04 15:16:53 -04:00
function getRequestUserId ( req : express.Request ) : string | undefined {
return ( req as AuthenticatedRequest ). userId ;
}
2026-04-22 16:55:23 -05:00
app . param ( "containerId" , ( req , res , next , value ) => {
if ( ! CONTAINER_ID_RE . test ( value )) {
return res . status ( 400 ). json ({ error : "Invalid container ID" });
}
next ();
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/ssh/connect:
* post:
* summary: Establish SSH session for Docker
* description: Establishes an SSH session to a host for Docker operations.
* tags:
* - Docker
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: SSH connection established.
* 400:
* description: Missing sessionId or hostId.
* 401:
* description: Authentication required.
* 403:
* description: Docker is not enabled for this host.
* 404:
* description: Host not found.
* 500:
* description: SSH connection failed.
*/
2025-12-31 22:20:12 -06:00
app . post ( "/docker/ssh/connect" , async ( req , res ) => {
const {
sessionId ,
hostId ,
userProvidedPassword ,
userProvidedSshKey ,
userProvidedKeyPassword ,
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
socks5ProxyChain ,
} = req . body ;
2026-06-04 15:16:53 -04:00
const userId = getRequestUserId ( req );
2025-12-31 22:20:12 -06:00
2026-01-24 19:49:42 -06:00
const connectionLogs : Array < Omit < LogEntry , "id" | "timestamp" >> = [];
2025-12-31 22:20:12 -06:00
if ( ! userId ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker SSH connection rejected: no authenticated user" , {
operation : "docker_connect_auth" ,
sessionId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_connecting" ,
"Authentication required" ,
),
);
return res
. status ( 401 )
. json ({ error : "Authentication required" , connectionLogs });
2025-12-31 22:20:12 -06:00
}
if ( ! SimpleDBOps . isUserDataUnlocked ( userId )) {
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog ( "error" , "docker_connecting" , "Session expired" ),
);
2025-12-31 22:20:12 -06:00
return res . status ( 401 ). json ({
error : "Session expired - please log in again" ,
code : "SESSION_EXPIRED" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
if ( ! sessionId || ! hostId ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Missing Docker SSH connection parameters" , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
sessionId ,
hasHostId : !! hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_connecting" ,
"Missing connection parameters" ,
),
);
return res
. status ( 400 )
. json ({ error : "Missing sessionId or hostId" , connectionLogs });
2025-12-31 22:20:12 -06:00
}
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"info" ,
"docker_connecting" ,
"Initiating Docker SSH connection" ,
),
);
2025-12-31 22:20:12 -06:00
try {
2026-03-14 20:05:05 -05:00
const hostResults = await SimpleDBOps . select (
getDb (). select (). from ( hosts ). where ( eq ( hosts . id , hostId )),
2025-12-31 22:20:12 -06:00
"ssh_data" ,
userId ,
);
2026-03-14 20:05:05 -05:00
if ( hostResults . length === 0 ) {
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog ( "error" , "docker_connecting" , "Host not found" ),
);
return res . status ( 404 ). json ({ error : "Host not found" , connectionLogs });
2025-12-31 22:20:12 -06:00
}
2026-03-14 20:05:05 -05:00
const host = hostResults [ 0 ] as unknown as SSHHost ;
2025-12-31 22:20:12 -06:00
if ( host . userId !== userId ) {
2026-03-14 20:05:05 -05:00
const { PermissionManager } =
await import ( "../utils/permission-manager.js" );
2025-12-31 22:20:12 -06:00
const permissionManager = PermissionManager . getInstance ();
const accessInfo = await permissionManager . canAccessHost (
userId ,
hostId ,
"execute" ,
);
if ( ! accessInfo . hasAccess ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "User does not have access to host" , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
hostId ,
userId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_connecting" ,
"Access denied to host" ,
),
);
return res . status ( 403 ). json ({ error : "Access denied" , connectionLogs });
2025-12-31 22:20:12 -06:00
}
}
if ( typeof host . jumpHosts === "string" && host . jumpHosts ) {
try {
host . jumpHosts = JSON . parse ( host . jumpHosts );
} catch ( e ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Failed to parse jump hosts" , e , {
2025-12-31 22:20:12 -06:00
hostId : host.id ,
});
host . jumpHosts = [];
}
}
2026-05-28 22:05:25 -04:00
if ( typeof host . terminalConfig === "string" && host . terminalConfig ) {
try {
host . terminalConfig = JSON . parse ( host . terminalConfig as string );
} catch {
host . terminalConfig = undefined ;
}
}
2025-12-31 22:20:12 -06:00
if ( ! host . enableDocker ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Docker not enabled for host" , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
hostId ,
userId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_connecting" ,
"Docker is not enabled for this host" ,
),
);
2025-12-31 22:20:12 -06:00
return res . status ( 403 ). json ({
error :
"Docker is not enabled for this host. Enable it in Host Settings." ,
code : "DOCKER_DISABLED" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"info" ,
"docker_auth" ,
"Resolving authentication credentials" ,
),
);
2025-12-31 22:20:12 -06:00
if ( sshSessions [ sessionId ]) {
cleanupSession ( sessionId );
}
if ( pendingTOTPSessions [ sessionId ]) {
try {
pendingTOTPSessions [ sessionId ]. client . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2025-12-31 22:20:12 -06:00
delete pendingTOTPSessions [ sessionId ];
}
2026-03-08 18:02:14 -05:00
let resolvedCredentials : {
password? : string ;
sshKey? : string ;
keyPassword? : string ;
authType? : string ;
} = {
2025-12-31 22:20:12 -06:00
password : host.password ,
sshKey : host.key ,
keyPassword : host.keyPassword ,
authType : host.authType ,
};
if ( userProvidedPassword ) {
resolvedCredentials . password = userProvidedPassword ;
}
if ( userProvidedSshKey ) {
resolvedCredentials . sshKey = userProvidedSshKey ;
resolvedCredentials . authType = "key" ;
}
if ( userProvidedKeyPassword ) {
resolvedCredentials . keyPassword = userProvidedKeyPassword ;
}
if ( host . credentialId ) {
const ownerId = host . userId ;
if ( userId !== ownerId ) {
try {
2026-03-14 20:05:05 -05:00
const { SharedCredentialManager } =
await import ( "../utils/shared-credential-manager.js" );
2025-12-31 22:20:12 -06:00
const sharedCredManager = SharedCredentialManager . getInstance ();
const sharedCred = await sharedCredManager . getSharedCredentialForUser (
host . id ,
userId ,
);
if ( sharedCred ) {
resolvedCredentials = {
password : sharedCred.password ,
sshKey : sharedCred.key ,
keyPassword : sharedCred.keyPassword ,
authType : sharedCred.authType ,
};
}
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Failed to resolve shared credential" , error , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
hostId ,
userId ,
});
}
} else {
const credentials = await SimpleDBOps . select (
getDb ()
. select ()
. from ( sshCredentials )
. where (
and (
eq ( sshCredentials . id , host . credentialId as number ),
eq ( sshCredentials . userId , userId ),
),
),
"ssh_credentials" ,
userId ,
);
if ( credentials . length > 0 ) {
const credential = credentials [ 0 ];
resolvedCredentials = {
2026-03-08 18:02:14 -05:00
password : credential.password as string | undefined ,
2026-05-28 22:29:20 -05:00
sshKey : ( credential . key || credential . privateKey ) as
| string
| undefined ,
2026-03-08 18:02:14 -05:00
keyPassword : credential.keyPassword as string | undefined ,
authType : credential.authType as string | undefined ,
2025-12-31 22:20:12 -06:00
};
}
}
}
const client = new SSHClient ();
2026-03-08 18:02:14 -05:00
const config : Record < string , unknown > = {
host : host.ip?.replace ( /^\[|\]$/g , "" ) || host . ip ,
2025-12-31 22:20:12 -06:00
port : host.port || 22 ,
username : host.username ,
tryKeyboard : true ,
2026-05-28 22:05:25 -04:00
keepaliveInterval :
typeof host . terminalConfig ? . keepaliveInterval === "number"
? host . terminalConfig . keepaliveInterval * 1000
: 60000 ,
keepaliveCountMax :
typeof host . terminalConfig ? . keepaliveCountMax === "number"
? host.terminalConfig.keepaliveCountMax
: 5 ,
2025-12-31 22:20:12 -06:00
readyTimeout : 60000 ,
tcpKeepAlive : true ,
tcpKeepAliveInitialDelay : 30000 ,
2026-02-12 22:28:13 -06:00
hostVerifier : await SSHHostKeyVerifier . createHostVerifier (
hostId ,
host . ip ,
host . port || 22 ,
null ,
userId ,
false ,
),
2025-12-31 22:20:12 -06:00
};
if ( resolvedCredentials . authType === "none" ) {
2026-03-08 18:02:14 -05:00
// no credentials needed
2025-12-31 22:20:12 -06:00
} else if ( resolvedCredentials . authType === "password" ) {
if ( resolvedCredentials . password ) {
config . password = resolvedCredentials . password ;
}
2026-02-12 22:28:13 -06:00
} else if ( resolvedCredentials . authType === "opkssh" ) {
try {
const { getOPKSSHToken } = await import ( "./opkssh-auth.js" );
const token = await getOPKSSHToken ( userId , hostId );
if ( ! token ) {
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_auth" ,
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours." ,
),
);
return res . status ( 401 ). json ({
error :
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours." ,
requiresOPKSSHAuth : true ,
connectionLogs ,
});
}
2026-04-22 16:55:23 -05:00
const { setupOPKSSHCertAuth } = await import ( "./opkssh-cert-auth.js" );
await setupOPKSSHCertAuth (
config as import ( "ssh2" ). ConnectConfig ,
client ,
token ,
host . username ,
);
2026-02-12 22:28:13 -06:00
connectionLogs . push (
createConnectionLog (
"info" ,
"docker_auth" ,
"Using OPKSSH certificate authentication" ,
),
);
} catch ( opksshError ) {
sshLogger . error ( "OPKSSH authentication error for Docker" , {
operation : "docker_connect" ,
sessionId ,
hostId ,
error :
opksshError instanceof Error
? opksshError . message
: "Unknown error" ,
});
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_auth" ,
`OPKSSH authentication failed: ${ opksshError instanceof Error ? opksshError . message : "Unknown error" } ` ,
),
);
return res . status ( 500 ). json ({
error : "OPKSSH authentication failed" ,
connectionLogs ,
});
}
2025-12-31 22:20:12 -06:00
} else if (
resolvedCredentials . authType === "key" &&
resolvedCredentials . sshKey
) {
try {
if (
! resolvedCredentials . sshKey . includes ( "-----BEGIN" ) ||
! resolvedCredentials . sshKey . includes ( "-----END" )
) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Invalid SSH key format" , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
sessionId ,
hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_auth" ,
"Invalid SSH private key format" ,
),
);
2025-12-31 22:20:12 -06:00
return res . status ( 400 ). json ({
error : "Invalid private key format" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
const cleanKey = resolvedCredentials . sshKey
. trim ()
. replace ( /\r\n/g , "\n" )
. replace ( /\r/g , "\n" );
config . privateKey = Buffer . from ( cleanKey , "utf8" );
if ( resolvedCredentials . keyPassword ) {
config . passphrase = resolvedCredentials . keyPassword ;
}
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "SSH key processing error" , error , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
sessionId ,
hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_auth" ,
"SSH key processing error" ,
),
);
2025-12-31 22:20:12 -06:00
return res . status ( 400 ). json ({
error : "SSH key format error: Invalid private key format" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
} else if ( resolvedCredentials . authType === "key" ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "SSH key authentication requested but no key provided" , {
operation : "docker_connect" ,
sessionId ,
hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_auth" ,
"SSH key authentication requested but no key provided" ,
),
);
2025-12-31 22:20:12 -06:00
return res . status ( 400 ). json ({
error : "SSH key authentication requested but no key provided" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
let responseSent = false ;
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog ( "info" , "dns" , `Resolving DNS for ${ host . ip } ` ),
);
connectionLogs . push (
createConnectionLog (
"info" ,
"tcp" ,
`Connecting to ${ host . ip } : ${ host . port || 22 } ` ,
),
);
connectionLogs . push (
createConnectionLog ( "info" , "handshake" , "Initiating SSH handshake" ),
);
if ( resolvedCredentials . authType === "password" ) {
connectionLogs . push (
createConnectionLog ( "info" , "auth" , "Authenticating with password" ),
);
} else if ( resolvedCredentials . authType === "key" ) {
connectionLogs . push (
createConnectionLog ( "info" , "auth" , "Authenticating with SSH key" ),
);
} else if ( resolvedCredentials . authType === "none" ) {
connectionLogs . push (
createConnectionLog (
"info" ,
"auth" ,
"Attempting keyboard-interactive authentication" ,
),
);
}
2025-12-31 22:20:12 -06:00
client . on ( "ready" , () => {
if ( responseSent ) return ;
responseSent = true ;
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"success" ,
"connected" ,
"SSH connection established successfully" ,
),
);
2025-12-31 22:20:12 -06:00
sshSessions [ sessionId ] = {
client ,
isConnected : true ,
lastActive : Date.now (),
activeOperations : 0 ,
hostId ,
2026-04-22 16:55:23 -05:00
userId ,
2025-12-31 22:20:12 -06:00
};
scheduleSessionCleanup ( sessionId );
2026-01-24 19:49:42 -06:00
res . json ({
success : true ,
message : "SSH connection established" ,
connectionLogs ,
});
2025-12-31 22:20:12 -06:00
});
client . on ( "error" , ( err ) => {
2026-01-24 19:49:42 -06:00
if ( responseSent ) {
2026-02-12 22:28:13 -06:00
sshLogger . error (
2026-01-24 19:49:42 -06:00
"Docker SSH connection error after response sent" ,
err ,
{
operation : "docker_connect_after_response" ,
sessionId ,
hostId ,
userId ,
},
);
if ( pendingTOTPSessions [ sessionId ]) {
delete pendingTOTPSessions [ sessionId ];
}
return ;
}
2025-12-31 22:20:12 -06:00
responseSent = true ;
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker SSH connection failed" , err , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
sessionId ,
hostId ,
userId ,
});
2026-05-06 15:12:07 -05:00
let errorStage : ConnectionStage ;
2026-01-24 19:49:42 -06:00
if (
err . message . includes ( "ENOTFOUND" ) ||
err . message . includes ( "getaddrinfo" )
) {
errorStage = "dns" ;
connectionLogs . push (
createConnectionLog (
"error" ,
errorStage ,
`DNS resolution failed: ${ err . message } ` ,
),
);
} else if (
err . message . includes ( "ECONNREFUSED" ) ||
err . message . includes ( "ETIMEDOUT" )
) {
errorStage = "tcp" ;
connectionLogs . push (
createConnectionLog (
"error" ,
errorStage ,
`TCP connection failed: ${ err . message } ` ,
),
);
} else if (
err . message . includes ( "handshake" ) ||
err . message . includes ( "key exchange" )
) {
errorStage = "handshake" ;
connectionLogs . push (
createConnectionLog (
"error" ,
errorStage ,
`SSH handshake failed: ${ err . message } ` ,
),
);
} else if (
err . message . includes ( "authentication" ) ||
err . message . includes ( "Authentication" )
) {
errorStage = "auth" ;
connectionLogs . push (
createConnectionLog (
"error" ,
errorStage ,
`Authentication failed: ${ err . message } ` ,
),
);
2026-02-12 22:28:13 -06:00
} else if ( err . message . includes ( "verification failed" )) {
errorStage = "handshake" ;
connectionLogs . push (
createConnectionLog (
"error" ,
errorStage ,
`SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.` ,
),
);
2026-01-24 19:49:42 -06:00
} else {
connectionLogs . push (
createConnectionLog (
"error" ,
"error" ,
`SSH connection failed: ${ err . message } ` ,
),
);
}
2025-12-31 22:20:12 -06:00
if (
resolvedCredentials . authType === "none" &&
( err . message . includes ( "authentication" ) ||
err . message . includes ( "All configured authentication methods failed" ))
) {
res . json ({
status : "auth_required" ,
reason : "no_keyboard" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
} else {
res . status ( 500 ). json ({
success : false ,
message : err.message || "SSH connection failed" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
});
client . on ( "close" , () => {
if ( sshSessions [ sessionId ]) {
sshSessions [ sessionId ]. isConnected = false ;
cleanupSession ( sessionId );
}
2026-01-24 19:49:42 -06:00
if ( pendingTOTPSessions [ sessionId ]) {
delete pendingTOTPSessions [ sessionId ];
}
2025-12-31 22:20:12 -06:00
});
client . 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
const promptTexts = prompts . map (( p ) => p . prompt );
const warpgatePattern = /warpgate\s+authentication/i ;
const isWarpgate =
warpgatePattern . test ( name ) ||
warpgatePattern . test ( instructions ) ||
promptTexts . some (( p ) => warpgatePattern . test ( p ));
if ( isWarpgate ) {
const fullText = ` ${ name } \ n ${ instructions } \ n ${ promptTexts . join ( "\n" ) } ` ;
const urlMatch = fullText . match ( /https?:\/\/[^\s\n]+/i );
const keyMatch = fullText . match (
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i ,
);
if ( urlMatch ) {
if ( responseSent ) return ;
responseSent = true ;
pendingTOTPSessions [ sessionId ] = {
client ,
finish ,
config ,
createdAt : Date.now (),
sessionId ,
hostId ,
ip : host.ip ,
port : host.port || 22 ,
username : host.username ,
userId ,
prompts ,
totpPromptIndex : - 1 ,
resolvedPassword : resolvedCredentials.password ,
totpAttempts : 0 ,
isWarpgate : true ,
};
connectionLogs . push (
createConnectionLog (
"info" ,
"docker_auth" ,
"Warpgate authentication required" ,
),
);
res . json ({
requires_warpgate : true ,
sessionId ,
url : urlMatch [ 0 ],
securityKey : keyMatch ? keyMatch [ 1 ] : "N/A" ,
connectionLogs ,
});
return ;
}
}
2025-12-31 22:20:12 -06:00
const totpPromptIndex = prompts . findIndex (( p ) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i . test (
p . prompt ,
),
);
if ( totpPromptIndex !== - 1 ) {
if ( responseSent ) {
const responses = prompts . map (( p ) => {
if ( /password/i . test ( p . prompt ) && resolvedCredentials . password ) {
return resolvedCredentials . password ;
}
return "" ;
});
finish ( responses );
return ;
}
responseSent = true ;
if ( pendingTOTPSessions [ sessionId ]) {
const responses = prompts . map (( p ) => {
if ( /password/i . test ( p . prompt ) && resolvedCredentials . password ) {
return resolvedCredentials . password ;
}
return "" ;
});
finish ( responses );
return ;
}
pendingTOTPSessions [ sessionId ] = {
client ,
finish ,
config ,
createdAt : Date.now (),
sessionId ,
hostId ,
ip : host.ip ,
port : host.port || 22 ,
username : host.username ,
userId ,
prompts ,
totpPromptIndex ,
resolvedPassword : resolvedCredentials.password ,
totpAttempts : 0 ,
};
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"info" ,
"docker_auth" ,
"TOTP verification required" ,
),
);
2025-12-31 22:20:12 -06:00
res . json ({
requires_totp : true ,
sessionId ,
prompt : prompts [ totpPromptIndex ]. prompt ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
} else {
const passwordPromptIndex = prompts . findIndex (( p ) =>
/password/i . test ( p . prompt ),
);
if (
resolvedCredentials . authType === "none" &&
passwordPromptIndex !== - 1
) {
if ( responseSent ) return ;
responseSent = true ;
client . end ();
res . json ({
status : "auth_required" ,
reason : "no_keyboard" ,
});
return ;
}
const hasStoredPassword =
resolvedCredentials . password &&
resolvedCredentials . authType !== "none" ;
if ( ! hasStoredPassword && passwordPromptIndex !== - 1 ) {
if ( responseSent ) {
const responses = prompts . map (( p ) => {
if (
/password/i . test ( p . prompt ) &&
resolvedCredentials . password
) {
return resolvedCredentials . password ;
}
return "" ;
});
finish ( responses );
return ;
}
responseSent = true ;
if ( pendingTOTPSessions [ sessionId ]) {
const responses = prompts . map (( p ) => {
if (
/password/i . test ( p . prompt ) &&
resolvedCredentials . password
) {
return resolvedCredentials . password ;
}
return "" ;
});
finish ( responses );
return ;
}
pendingTOTPSessions [ sessionId ] = {
client ,
finish ,
config ,
createdAt : Date.now (),
sessionId ,
hostId ,
ip : host.ip ,
port : host.port || 22 ,
username : host.username ,
userId ,
prompts ,
totpPromptIndex : passwordPromptIndex ,
resolvedPassword : resolvedCredentials.password ,
totpAttempts : 0 ,
};
res . json ({
requires_totp : true ,
sessionId ,
prompt : prompts [ passwordPromptIndex ]. prompt ,
isPassword : true ,
});
return ;
}
const responses = prompts . map (( p ) => {
if ( /password/i . test ( p . prompt ) && resolvedCredentials . password ) {
return resolvedCredentials . password ;
}
return "" ;
});
finish ( responses );
}
},
);
2026-03-08 18:02:14 -05:00
const proxyConfig : SOCKS5Config | null =
2025-12-31 22:20:12 -06:00
useSocks5 &&
2026-03-08 18:02:14 -05:00
( socks5Host ||
( socks5ProxyChain && ( socks5ProxyChain as ProxyNode []). length > 0 ))
? {
2025-12-31 22:20:12 -06:00
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
2026-03-08 18:02:14 -05:00
socks5ProxyChain : socks5ProxyChain as ProxyNode [],
}
: null ;
const hasJumpHosts = host . jumpHosts && host . jumpHosts . length > 0 ;
if ( hasJumpHosts ) {
try {
if ( proxyConfig ) {
connectionLogs . push (
createConnectionLog (
"info" ,
"proxy" ,
"Connecting via proxy + jump hosts" ,
),
);
}
connectionLogs . push (
createConnectionLog (
"info" ,
"jump" ,
`Connecting via ${ host . jumpHosts ! . length } jump host(s)` ,
),
);
const jumpClient = await createJumpHostChain (
host . jumpHosts as Array < { hostId : number } > ,
userId ,
proxyConfig ,
2025-12-31 22:20:12 -06:00
);
2026-03-08 18:02:14 -05:00
if ( ! jumpClient ) {
connectionLogs . push (
createConnectionLog (
"error" ,
"jump" ,
"Failed to establish jump host chain" ,
),
);
return res . status ( 500 ). json ({
error : "Failed to establish jump host chain" ,
connectionLogs ,
});
2025-12-31 22:20:12 -06:00
}
2026-03-08 18:02:14 -05:00
jumpClient . forwardOut (
"127.0.0.1" ,
0 ,
host . ip ,
host . port || 22 ,
( err , stream ) => {
if ( err ) {
sshLogger . error ( "Failed to forward through jump host" , err , {
operation : "docker_jump_forward" ,
sessionId ,
hostId ,
});
connectionLogs . push (
createConnectionLog (
"error" ,
"jump" ,
`Failed to forward through jump host: ${ err . message } ` ,
),
);
jumpClient . end ();
if ( ! responseSent ) {
responseSent = true ;
return res . status ( 500 ). json ({
error : "Failed to forward through jump host: " + err . message ,
connectionLogs ,
});
}
return ;
}
config . sock = stream ;
client . connect ( config );
},
);
} catch ( jumpError ) {
sshLogger . error ( "Jump host connection failed" , jumpError , {
operation : "docker_jump_connect" ,
2025-12-31 22:20:12 -06:00
sessionId ,
hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
2026-03-08 18:02:14 -05:00
"jump" ,
`Jump host connection failed: ${ jumpError instanceof Error ? jumpError . message : "Unknown error" } ` ,
2026-01-24 19:49:42 -06:00
),
);
2025-12-31 22:20:12 -06:00
if ( ! responseSent ) {
responseSent = true ;
return res . status ( 500 ). json ({
error :
2026-03-08 18:02:14 -05:00
"Jump host connection failed: " +
( jumpError instanceof Error
? jumpError . message
2025-12-31 22:20:12 -06:00
: "Unknown error" ),
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
return ;
}
2026-03-08 18:02:14 -05:00
} else if ( proxyConfig ) {
2026-01-24 19:49:42 -06:00
connectionLogs . push (
2026-03-08 18:02:14 -05:00
createConnectionLog ( "info" , "proxy" , "Connecting via proxy" ),
2026-01-24 19:49:42 -06:00
);
2026-03-08 18:02:14 -05:00
try {
const proxySocket = await createSocks5Connection (
host . ip ,
host . port || 22 ,
proxyConfig ,
);
if ( proxySocket ) {
config . sock = proxySocket ;
}
client . connect ( config );
} catch ( proxyError ) {
sshLogger . error ( "Proxy connection failed" , proxyError , {
operation : "docker_proxy_connect" ,
sessionId ,
hostId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
2026-03-08 18:02:14 -05:00
"proxy" ,
`Proxy connection failed: ${ proxyError instanceof Error ? proxyError . message : "Unknown error" } ` ,
2026-01-24 19:49:42 -06:00
),
);
2026-03-08 18:02:14 -05:00
if ( ! responseSent ) {
responseSent = true ;
return res . status ( 500 ). json ({
error :
"Proxy connection failed: " +
( proxyError instanceof Error
? proxyError . message
: "Unknown error" ),
connectionLogs ,
});
}
return ;
2025-12-31 22:20:12 -06:00
}
} else {
client . connect ( config );
}
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker SSH connection error" , error , {
2025-12-31 22:20:12 -06:00
operation : "docker_connect" ,
sessionId ,
hostId ,
userId ,
});
2026-01-24 19:49:42 -06:00
connectionLogs . push (
createConnectionLog (
"error" ,
"docker_connecting" ,
`Connection error: ${ error instanceof Error ? error . message : "Unknown error" } ` ,
),
);
2025-12-31 22:20:12 -06:00
res . status ( 500 ). json ({
success : false ,
message : error instanceof Error ? error . message : "Unknown error" ,
2026-01-24 19:49:42 -06:00
connectionLogs ,
2025-12-31 22:20:12 -06:00
});
}
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/ssh/disconnect:
* post:
* summary: Disconnect SSH session
* description: Closes an active SSH session for Docker operations.
* tags:
* - Docker
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* responses:
* 200:
* description: SSH session disconnected.
* 400:
* description: Session ID is required.
*/
2025-12-31 22:20:12 -06:00
app . post ( "/docker/ssh/disconnect" , async ( req , res ) => {
const { sessionId } = req . body ;
if ( ! sessionId ) {
return res . status ( 400 ). json ({ error : "Session ID is required" });
}
cleanupSession ( sessionId );
res . json ({ success : true , message : "SSH session disconnected" });
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/ssh/connect-totp:
* post:
* summary: Verify TOTP and complete connection
* description: Verifies the TOTP code and completes the SSH connection.
* tags:
* - Docker
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* totpCode:
* type: string
* responses:
* 200:
* description: TOTP verified, SSH connection established.
* 400:
* description: Session ID and TOTP code required.
* 401:
* description: Invalid TOTP code.
* 404:
* description: TOTP session expired.
*/
2025-12-31 22:20:12 -06:00
app . post ( "/docker/ssh/connect-totp" , async ( req , res ) => {
const { sessionId , totpCode } = req . body ;
2026-06-04 15:16:53 -04:00
const userId = getRequestUserId ( req );
2025-12-31 22:20:12 -06:00
if ( ! userId ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "TOTP verification rejected: no authenticated user" , {
2025-12-31 22:20:12 -06:00
operation : "docker_totp_auth" ,
sessionId ,
});
return res . status ( 401 ). json ({ error : "Authentication required" });
}
if ( ! sessionId || ! totpCode ) {
return res . status ( 400 ). json ({ error : "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions [ sessionId ];
if ( ! session ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "TOTP session not found or expired" , {
2025-12-31 22:20:12 -06:00
operation : "docker_totp_verify" ,
sessionId ,
userId ,
availableSessions : Object.keys ( pendingTOTPSessions ),
});
return res
. status ( 404 )
. json ({ error : "TOTP session expired. Please reconnect." });
}
if ( Date . now () - session . createdAt > 180000 ) {
delete pendingTOTPSessions [ sessionId ];
try {
session . client . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "TOTP session timeout before code submission" , {
2025-12-31 22:20:12 -06:00
operation : "docker_totp_verify" ,
sessionId ,
userId ,
age : Date.now () - session . createdAt ,
});
return res
. status ( 408 )
. json ({ error : "TOTP session timeout. Please reconnect." });
}
const responses = ( session . prompts || []). map (( p , index ) => {
if ( index === session . totpPromptIndex ) {
return totpCode ;
}
if ( /password/i . test ( p . prompt ) && session . resolvedPassword ) {
return session . resolvedPassword ;
}
return "" ;
});
let responseSent = false ;
2026-03-08 18:02:14 -05:00
const responseTimeout = setTimeout (() => {
if ( ! responseSent ) {
responseSent = true ;
delete pendingTOTPSessions [ sessionId ];
sshLogger . warn ( "TOTP verification timeout" , {
operation : "docker_totp_verify" ,
sessionId ,
userId ,
});
res . status ( 408 ). json ({ error : "TOTP verification timeout" });
}
}, 60000 );
2025-12-31 22:20:12 -06:00
session . client . once ( "ready" , () => {
if ( responseSent ) return ;
responseSent = true ;
clearTimeout ( responseTimeout );
delete pendingTOTPSessions [ sessionId ];
setTimeout (() => {
sshSessions [ sessionId ] = {
client : session.client ,
isConnected : true ,
lastActive : Date.now (),
activeOperations : 0 ,
hostId : session.hostId ,
2026-04-22 16:55:23 -05:00
userId ,
2025-12-31 22:20:12 -06:00
};
scheduleSessionCleanup ( sessionId );
res . json ({
status : "success" ,
message : "TOTP verified, SSH connection established" ,
});
if ( session . hostId && session . userId ) {
( async () => {
try {
2026-03-14 20:05:05 -05:00
const hostResults = await SimpleDBOps . select (
2025-12-31 22:20:12 -06:00
getDb ()
. select ()
2026-03-14 20:05:05 -05:00
. from ( hosts )
2025-12-31 22:20:12 -06:00
. where (
and (
2026-03-14 20:05:05 -05:00
eq ( hosts . id , session . hostId ! ),
eq ( hosts . userId , session . userId ! ),
2025-12-31 22:20:12 -06:00
),
),
"ssh_data" ,
session . userId ! ,
);
const hostName =
2026-03-14 20:05:05 -05:00
hostResults . length > 0 && hostResults [ 0 ]. name
? hostResults [ 0 ]. name
2025-12-31 22:20:12 -06:00
: ` ${ session . username } @ ${ session . ip } : ${ session . port } ` ;
await axios . post (
"http://localhost:30006/activity/log" ,
{
type : "docker" ,
hostId : session.hostId ,
hostName ,
},
{
headers : {
Authorization : `Bearer ${ await authManager . generateJWTToken ( session . userId ! ) } ` ,
},
},
);
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Failed to log Docker activity (TOTP)" , {
2025-12-31 22:20:12 -06:00
operation : "activity_log_error" ,
userId : session.userId ,
hostId : session.hostId ,
error : error instanceof Error ? error . message : "Unknown error" ,
});
}
})();
}
}, 200 );
});
session . client . once ( "error" , ( err ) => {
if ( responseSent ) return ;
responseSent = true ;
clearTimeout ( responseTimeout );
delete pendingTOTPSessions [ sessionId ];
2026-02-12 22:28:13 -06:00
sshLogger . error ( "TOTP verification failed" , {
2025-12-31 22:20:12 -06:00
operation : "docker_totp_verify" ,
sessionId ,
userId ,
error : err.message ,
});
res . status ( 401 ). json ({ status : "error" , message : "Invalid TOTP code" });
});
session . finish ( responses );
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/ssh/connect-warpgate:
* post:
* summary: Complete Warpgate authentication
* description: Submits empty response to complete Warpgate authentication after user completes browser auth.
* tags:
* - Docker
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* properties:
* sessionId:
* type: string
* description: Session ID from initial connection attempt
* responses:
* 200:
* description: Warpgate authentication completed successfully.
* 401:
* description: Authentication failed or unauthorized.
* 404:
* description: Warpgate session expired.
*/
app . post ( "/docker/ssh/connect-warpgate" , async ( req , res ) => {
const { sessionId } = req . body ;
2026-06-04 15:16:53 -04:00
const userId = getRequestUserId ( req );
2026-01-24 19:49:42 -06:00
if ( ! userId ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Warpgate verification rejected: no authenticated user" , {
operation : "docker_warpgate_auth" ,
sessionId ,
});
2026-01-24 19:49:42 -06:00
return res . status ( 401 ). json ({ error : "Authentication required" });
}
if ( ! sessionId ) {
return res . status ( 400 ). json ({ error : "Session ID required" });
}
const session = pendingTOTPSessions [ sessionId ];
if ( ! session ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Warpgate session not found or expired" , {
2026-01-24 19:49:42 -06:00
operation : "docker_warpgate_verify" ,
sessionId ,
userId ,
availableSessions : Object.keys ( pendingTOTPSessions ),
});
return res
. status ( 404 )
. json ({ error : "Warpgate session expired. Please reconnect." });
}
if ( ! session . isWarpgate ) {
return res . status ( 400 ). json ({ error : "Session is not a Warpgate session" });
}
if ( Date . now () - session . createdAt > 300000 ) {
delete pendingTOTPSessions [ sessionId ];
try {
session . client . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Warpgate session timeout before completion" , {
2026-01-24 19:49:42 -06:00
operation : "docker_warpgate_verify" ,
sessionId ,
userId ,
age : Date.now () - session . createdAt ,
});
return res
. status ( 408 )
. json ({ error : "Warpgate session timeout. Please reconnect." });
}
let responseSent = false ;
2026-03-08 18:02:14 -05:00
const responseTimeout = setTimeout (() => {
if ( ! responseSent ) {
responseSent = true ;
delete pendingTOTPSessions [ sessionId ];
sshLogger . warn ( "Warpgate verification timeout" , {
operation : "docker_warpgate_verify" ,
sessionId ,
userId ,
});
res . status ( 408 ). json ({ error : "Warpgate verification timeout" });
}
}, 60000 );
2026-01-24 19:49:42 -06:00
session . client . once ( "ready" , () => {
if ( responseSent ) return ;
responseSent = true ;
clearTimeout ( responseTimeout );
delete pendingTOTPSessions [ sessionId ];
setTimeout (() => {
sshSessions [ sessionId ] = {
client : session.client ,
isConnected : true ,
lastActive : Date.now (),
activeOperations : 0 ,
hostId : session.hostId ,
2026-04-22 16:55:23 -05:00
userId ,
2026-01-24 19:49:42 -06:00
};
scheduleSessionCleanup ( sessionId );
res . json ({
status : "success" ,
message : "Warpgate verified, SSH connection established" ,
});
if ( session . hostId && session . userId ) {
( async () => {
try {
2026-03-14 20:05:05 -05:00
const hostResults = await SimpleDBOps . select (
2026-01-24 19:49:42 -06:00
getDb ()
. select ()
2026-03-14 20:05:05 -05:00
. from ( hosts )
2026-01-24 19:49:42 -06:00
. where (
and (
2026-03-14 20:05:05 -05:00
eq ( hosts . id , session . hostId ! ),
eq ( hosts . userId , session . userId ! ),
2026-01-24 19:49:42 -06:00
),
),
"ssh_data" ,
session . userId ! ,
);
const hostName =
2026-03-14 20:05:05 -05:00
hostResults . length > 0 && hostResults [ 0 ]. name
? hostResults [ 0 ]. name
2026-01-24 19:49:42 -06:00
: ` ${ session . username } @ ${ session . ip } : ${ session . port } ` ;
await axios . post (
"http://localhost:30006/activity/log" ,
{
type : "docker" ,
hostId : session.hostId ,
hostName ,
},
{
headers : {
Authorization : `Bearer ${ await authManager . generateJWTToken ( session . userId ! ) } ` ,
},
},
);
} catch ( error ) {
2026-02-12 22:28:13 -06:00
sshLogger . warn ( "Failed to log Docker activity (Warpgate)" , {
2026-01-24 19:49:42 -06:00
operation : "activity_log_error" ,
userId : session.userId ,
hostId : session.hostId ,
error : error instanceof Error ? error . message : "Unknown error" ,
});
}
})();
}
}, 200 );
});
session . client . once ( "error" , ( err ) => {
if ( responseSent ) return ;
responseSent = true ;
clearTimeout ( responseTimeout );
delete pendingTOTPSessions [ sessionId ];
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Warpgate verification failed" , {
2026-01-24 19:49:42 -06:00
operation : "docker_warpgate_verify" ,
sessionId ,
userId ,
error : err.message ,
});
res
. status ( 401 )
. json ({ status : "error" , message : "Warpgate authentication failed" });
});
session . finish ([ "" ]);
});
/**
* @openapi
* /docker/ssh/keepalive:
* post:
* summary: Keep SSH session alive
* description: Keeps an active SSH session alive.
* tags:
* - Docker
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* responses:
* 200:
* description: Session keepalive successful.
* 400:
* description: Session ID is required or session not found.
*/
2025-12-31 22:20:12 -06:00
app . post ( "/docker/ssh/keepalive" , async ( req , res ) => {
const { sessionId } = req . body ;
2026-06-04 15:16:53 -04:00
const userId = getRequestUserId ( req );
2025-12-31 22:20:12 -06:00
if ( ! sessionId ) {
return res . status ( 400 ). json ({ error : "Session ID is required" });
}
const session = sshSessions [ sessionId ];
if ( ! session || ! session . isConnected ) {
return res . status ( 400 ). json ({
error : "SSH session not found or not connected" ,
connected : false ,
});
}
2026-04-22 16:55:23 -05:00
if ( session . userId && session . userId !== userId ) {
return res . status ( 403 ). json ({ error : "Session access denied" });
}
2025-12-31 22:20:12 -06:00
session . lastActive = Date . now ();
scheduleSessionCleanup ( sessionId );
res . json ({
success : true ,
connected : true ,
message : "Session keepalive successful" ,
lastActive : session.lastActive ,
});
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/ssh/status:
* get:
* summary: Check SSH session status
* description: Checks the status of an active SSH session.
* tags:
* - Docker
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Session status.
* 400:
* description: Session ID is required.
*/
2025-12-31 22:20:12 -06:00
app . get ( "/docker/ssh/status" , async ( req , res ) => {
const sessionId = req . query . sessionId as string ;
if ( ! sessionId ) {
return res . status ( 400 ). json ({ error : "Session ID is required" });
}
const isConnected = !! sshSessions [ sessionId ] ? . isConnected ;
res . json ({ success : true , connected : isConnected });
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /docker/validate/{sessionId}:
* get:
* summary: Validate Docker availability
* description: Validates if Docker is available on the host.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Docker availability status.
* 400:
* description: SSH session not found or not connected.
* 500:
* description: Validation failed.
*/
2025-12-31 22:20:12 -06:00
app . get ( "/docker/validate/:sessionId" , async ( req , res ) => {
const { sessionId } = req . params ;
2026-06-04 15:16:53 -04:00
const userId = getRequestUserId ( req );
2025-12-31 22:20:12 -06:00
if ( ! userId ) {
return res . status ( 401 ). json ({ error : "Authentication required" });
}
2026-01-24 19:49:42 -06:00
if ( pendingTOTPSessions [ sessionId ]) {
return res . status ( 400 ). json ({
error : "Connection pending authentication" ,
code : "AUTH_PENDING" ,
});
}
2025-12-31 22:20:12 -06:00
const session = sshSessions [ sessionId ];
if ( ! session || ! session . isConnected ) {
return res . status ( 400 ). json ({
error : "SSH session not found or not connected" ,
});
}
session . lastActive = Date . now ();
session . activeOperations ++ ;
try {
try {
const versionOutput = await executeDockerCommand (
session ,
"docker --version" ,
2026-02-12 22:28:13 -06:00
sessionId ,
userId ,
session . hostId ,
2025-12-31 22:20:12 -06:00
);
const versionMatch = versionOutput . match ( /Docker version ([^\s,]+)/ );
const version = versionMatch ? versionMatch [ 1 ] : "unknown" ;
try {
2026-02-12 22:28:13 -06:00
await executeDockerCommand (
session ,
"docker ps >/dev/null 2>&1" ,
sessionId ,
userId ,
session . hostId ,
);
2025-12-31 22:20:12 -06:00
session . activeOperations -- ;
return res . json ({
available : true ,
version ,
});
} catch ( daemonError ) {
session . activeOperations -- ;
const errorMsg =
daemonError instanceof Error ? daemonError . message : "" ;
if ( errorMsg . includes ( "Cannot connect to the Docker daemon" )) {
return res . json ({
available : false ,
error :
"Docker daemon is not running. Start it with: sudo systemctl start docker" ,
code : "DAEMON_NOT_RUNNING" ,
});
}
if ( errorMsg . includes ( "permission denied" )) {
return res . json ({
available : false ,
error :
"Permission denied. Add your user to the docker group: sudo usermod -aG docker $USER" ,
code : "PERMISSION_DENIED" ,
});
}
return res . json ({
available : false ,
error : errorMsg ,
code : "DOCKER_ERROR" ,
});
}
2026-03-08 18:02:14 -05:00
} catch {
2025-12-31 22:20:12 -06:00
session . activeOperations -- ;
return res . json ({
available : false ,
error :
"Docker is not installed on this host. Please install Docker to use this feature." ,
code : "NOT_INSTALLED" ,
});
}
} catch ( error ) {
session . activeOperations -- ;
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Docker validation error" , error , {
2025-12-31 22:20:12 -06:00
operation : "docker_validate" ,
sessionId ,
userId ,
});
res . status ( 500 ). json ({
available : false ,
error : error instanceof Error ? error . message : "Validation failed" ,
});
}
});
2026-06-04 15:16:53 -04:00
registerDockerContainerRoutes ( app , {
sshSessions ,
pendingTOTPSessions ,
getRequestUserId ,
executeDockerCommand ,
dockerTimestampPattern : DOCKER_TIMESTAMP_RE ,
2025-12-31 22:20:12 -06:00
});
const PORT = 30007 ;
app . listen ( PORT , async () => {
try {
await authManager . initialize ();
} catch ( err ) {
2026-02-12 22:28:13 -06:00
sshLogger . error ( "Failed to initialize Docker backend" , err , {
2025-12-31 22:20:12 -06:00
operation : "startup" ,
});
}
});
process . on ( "SIGINT" , () => {
Object . keys ( sshSessions ). forEach (( sessionId ) => {
cleanupSession ( sessionId );
});
process . exit ( 0 );
});
process . on ( "SIGTERM" , () => {
Object . keys ( sshSessions ). forEach (( sessionId ) => {
cleanupSession ( sessionId );
});
process . exit ( 0 );
});