2025-12-31 22:20:12 -06:00
import express , { type Response } from "express" ;
2025-09-12 14:42:00 -05:00
import cors from "cors" ;
2025-10-01 15:40:10 -05:00
import cookieParser from "cookie-parser" ;
2025-09-12 14:42:00 -05:00
import { Client } from "ssh2" ;
import { ChildProcess } from "child_process" ;
import axios from "axios" ;
2025-10-01 15:40:10 -05:00
import { getDb } from "../database/db/index.js" ;
2025-09-12 14:42:00 -05:00
import { sshCredentials } from "../database/db/schema.js" ;
2026-03-08 18:02:14 -05:00
import { eq } from "drizzle-orm" ;
2025-09-12 14:42:00 -05:00
import type {
SSHHost ,
TunnelConfig ,
TunnelStatus ,
VerificationData ,
ErrorType ,
2025-12-31 22:20:12 -06:00
AuthenticatedRequest ,
2025-09-12 14:42:00 -05:00
} from "../../types/index.js" ;
import { CONNECTION_STATES } from "../../types/index.js" ;
2026-03-08 18:02:14 -05:00
import { tunnelLogger } from "../utils/logger.js" ;
2025-10-01 15:40:10 -05:00
import { SystemCrypto } from "../utils/system-crypto.js" ;
import { SimpleDBOps } from "../utils/simple-db-ops.js" ;
import { DataCrypto } from "../utils/data-crypto.js" ;
2025-12-31 22:20:12 -06:00
import { createSocks5Connection } from "../utils/socks5-helper.js" ;
import { AuthManager } from "../utils/auth-manager.js" ;
import { PermissionManager } from "../utils/permission-manager.js" ;
2026-03-08 18:02:14 -05:00
import { withConnection } from "./ssh-connection-pool.js" ;
2025-08-07 02:20:27 -05:00
const app = express ();
2025-09-12 14:42:00 -05:00
app . use (
cors ({
2025-10-01 15:40:10 -05:00
origin : ( origin , callback ) => {
if ( ! origin ) return callback ( null , true );
2026-02-12 22:28:13 -06:00
const allowedOrigins = [ "http://localhost:5173" , "http://127.0.0.1:5173" ];
2025-10-01 15:40:10 -05:00
2025-11-05 10:36:16 -06:00
if ( allowedOrigins . includes ( origin )) {
return callback ( null , true );
}
2025-10-01 15:40:10 -05:00
if ( origin . startsWith ( "https://" )) {
return callback ( null , true );
}
if ( origin . startsWith ( "http://" )) {
return callback ( null , true );
}
callback ( new Error ( "Not allowed by CORS" ));
},
credentials : true ,
2025-09-12 14:42:00 -05:00
methods : [ "GET" , "POST" , "PUT" , "DELETE" , "OPTIONS" ],
allowedHeaders : [
"Origin" ,
"X-Requested-With" ,
"Content-Type" ,
"Accept" ,
"Authorization" ,
"User-Agent" ,
"X-Electron-App" ,
],
}),
);
2025-10-01 15:40:10 -05:00
app . use ( cookieParser ());
2025-08-07 02:20:27 -05:00
app . use ( express . json ());
2026-03-08 18:02:14 -05:00
app . use (( _req , res , next ) => {
res . setHeader ( "Cache-Control" , "no-store" );
next ();
});
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
const authManager = AuthManager . getInstance ();
const permissionManager = PermissionManager . getInstance ();
const authenticateJWT = authManager . createAuthMiddleware ();
2025-08-07 02:20:27 -05:00
const activeTunnels = new Map < string , Client >();
const retryCounters = new Map < string , number >();
const connectionStatus = new Map < string , TunnelStatus >();
const tunnelVerifications = new Map < string , VerificationData >();
const manualDisconnects = new Set < string >();
const verificationTimers = new Map < string , NodeJS.Timeout >();
const activeRetryTimers = new Map < string , NodeJS.Timeout >();
const countdownIntervals = new Map < string , NodeJS.Timeout >();
const retryExhaustedTunnels = new Set < string >();
2025-10-01 15:40:10 -05:00
const cleanupInProgress = new Set < string >();
const tunnelConnecting = new Set < string >();
2025-08-07 02:20:27 -05:00
const tunnelConfigs = new Map < string , TunnelConfig >();
const activeTunnelProcesses = new Map < string , ChildProcess >();
2025-12-31 22:20:12 -06:00
const pendingTunnelOperations = new Map < string , Promise < void >>();
2025-08-07 02:20:27 -05:00
function broadcastTunnelStatus ( tunnelName : string , status : TunnelStatus ) : void {
2025-09-12 14:42:00 -05:00
if (
status . status === CONNECTION_STATES . CONNECTED &&
activeRetryTimers . has ( tunnelName )
) {
return ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if (
retryExhaustedTunnels . has ( tunnelName ) &&
status . status === CONNECTION_STATES . FAILED
) {
status . reason = "Max retries exhausted" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
connectionStatus . set ( tunnelName , status );
2025-08-07 02:20:27 -05:00
}
function getAllTunnelStatus () : Record < string , TunnelStatus > {
2025-09-12 14:42:00 -05:00
const tunnelStatus : Record < string , TunnelStatus > = {};
connectionStatus . forEach (( status , key ) => {
tunnelStatus [ key ] = status ;
});
return tunnelStatus ;
2025-08-07 02:20:27 -05:00
}
function classifyError ( errorMessage : string ) : ErrorType {
2025-09-12 14:42:00 -05:00
if ( ! errorMessage ) return "UNKNOWN" ;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
const message = errorMessage . toLowerCase ();
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if (
message . includes ( "closed by remote host" ) ||
message . includes ( "connection reset by peer" ) ||
message . includes ( "connection refused" ) ||
message . includes ( "broken pipe" )
) {
return "NETWORK_ERROR" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if (
message . includes ( "authentication failed" ) ||
message . includes ( "permission denied" ) ||
message . includes ( "incorrect password" )
) {
return "AUTHENTICATION_FAILED" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if (
message . includes ( "connect etimedout" ) ||
message . includes ( "timeout" ) ||
message . includes ( "timed out" ) ||
message . includes ( "keepalive timeout" )
) {
return "TIMEOUT" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if (
message . includes ( "bind: address already in use" ) ||
message . includes ( "failed for listen port" ) ||
message . includes ( "port forwarding failed" )
) {
return "CONNECTION_FAILED" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( message . includes ( "permission" ) || message . includes ( "access denied" )) {
return "CONNECTION_FAILED" ;
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
return "UNKNOWN" ;
2025-08-07 02:20:27 -05:00
}
function getTunnelMarker ( tunnelName : string ) {
2025-09-12 14:42:00 -05:00
return `TUNNEL_MARKER_ ${ tunnelName . replace ( /[^a-zA-Z0-9]/g , "_" ) } ` ;
2025-08-07 02:20:27 -05:00
}
2025-12-31 22:20:12 -06:00
function normalizeTunnelName (
hostId : number ,
tunnelIndex : number ,
displayName : string ,
sourcePort : number ,
endpointHost : string ,
endpointPort : number ,
) : string {
return ` ${ hostId } :: ${ tunnelIndex } :: ${ displayName } :: ${ sourcePort } :: ${ endpointHost } :: ${ endpointPort } ` ;
}
function parseTunnelName ( tunnelName : string ) : {
hostId? : number ;
tunnelIndex? : number ;
displayName : string ;
sourcePort : string ;
endpointHost : string ;
endpointPort : string ;
isLegacyFormat : boolean ;
} {
const parts = tunnelName . split ( "::" );
if ( parts . length === 6 ) {
return {
hostId : parseInt ( parts [ 0 ]),
tunnelIndex : parseInt ( parts [ 1 ]),
displayName : parts [ 2 ],
sourcePort : parts [ 3 ],
endpointHost : parts [ 4 ],
endpointPort : parts [ 5 ],
isLegacyFormat : false ,
};
}
tunnelLogger . warn ( `Legacy tunnel name format: ${ tunnelName } ` );
const legacyParts = tunnelName . split ( "_" );
return {
displayName : legacyParts [ 0 ] || "unknown" ,
sourcePort : legacyParts [ legacyParts . length - 3 ] || "0" ,
endpointHost : legacyParts [ legacyParts . length - 2 ] || "unknown" ,
endpointPort : legacyParts [ legacyParts . length - 1 ] || "0" ,
isLegacyFormat : true ,
};
}
function validateTunnelConfig (
tunnelName : string ,
tunnelConfig : TunnelConfig ,
) : boolean {
const parsed = parseTunnelName ( tunnelName );
if ( parsed . isLegacyFormat ) {
return true ;
}
return (
parsed . hostId === tunnelConfig . sourceHostId &&
parsed . tunnelIndex === tunnelConfig . tunnelIndex &&
String ( parsed . sourcePort ) === String ( tunnelConfig . sourcePort ) &&
parsed . endpointHost === tunnelConfig . endpointHost &&
String ( parsed . endpointPort ) === String ( tunnelConfig . endpointPort )
);
}
async function cleanupTunnelResources (
2025-10-01 15:40:10 -05:00
tunnelName : string ,
forceCleanup = false ,
2025-12-31 22:20:12 -06:00
) : Promise < void > {
2025-10-01 15:40:10 -05:00
if ( cleanupInProgress . has ( tunnelName )) {
return ;
}
if ( ! forceCleanup && tunnelConnecting . has ( tunnelName )) {
return ;
}
cleanupInProgress . add ( tunnelName );
2025-09-12 14:42:00 -05:00
const tunnelConfig = tunnelConfigs . get ( tunnelName );
if ( tunnelConfig ) {
2025-12-31 22:20:12 -06:00
await new Promise < void >(( resolve ) => {
killRemoteTunnelByMarker ( tunnelConfig , tunnelName , ( err ) => {
cleanupInProgress . delete ( tunnelName );
if ( err ) {
tunnelLogger . error (
`Failed to kill remote tunnel for ' ${ tunnelName } ': ${ err . message } ` ,
);
}
resolve ();
});
2025-08-07 02:20:27 -05:00
});
2025-10-01 15:40:10 -05:00
} else {
cleanupInProgress . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( activeTunnelProcesses . has ( tunnelName )) {
try {
const proc = activeTunnelProcesses . get ( tunnelName );
if ( proc ) {
proc . kill ( "SIGTERM" );
}
} catch ( e ) {
tunnelLogger . error (
`Error while killing local ssh process for tunnel ' ${ tunnelName } '` ,
e ,
);
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
activeTunnelProcesses . delete ( tunnelName );
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( activeTunnels . has ( tunnelName )) {
try {
const conn = activeTunnels . get ( tunnelName );
if ( conn ) {
conn . end ();
}
} catch ( e ) {
tunnelLogger . error (
`Error while closing SSH2 Client for tunnel ' ${ tunnelName } '` ,
e ,
);
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
activeTunnels . delete ( tunnelName );
}
if ( tunnelVerifications . has ( tunnelName )) {
const verification = tunnelVerifications . get ( tunnelName );
if ( verification ? . timeout ) clearTimeout ( verification . timeout );
try {
verification ? . conn . end ();
2026-02-12 22:28:13 -06:00
} catch ( error ) {
tunnelLogger . error ( "Error during tunnel cleanup" , error , {
operation : "tunnel_cleanup_error" ,
tunnelName ,
});
}
2025-09-12 14:42:00 -05:00
tunnelVerifications . delete ( tunnelName );
}
const timerKeys = [
tunnelName ,
` ${ tunnelName } _confirm` ,
` ${ tunnelName } _retry` ,
` ${ tunnelName } _verify_retry` ,
` ${ tunnelName } _ping` ,
];
timerKeys . forEach (( key ) => {
if ( verificationTimers . has ( key )) {
clearTimeout ( verificationTimers . get ( key ) ! );
verificationTimers . delete ( key );
}
});
if ( activeRetryTimers . has ( tunnelName )) {
clearTimeout ( activeRetryTimers . get ( tunnelName ) ! );
activeRetryTimers . delete ( tunnelName );
}
if ( countdownIntervals . has ( tunnelName )) {
clearInterval ( countdownIntervals . get ( tunnelName ) ! );
countdownIntervals . delete ( tunnelName );
}
2025-08-07 02:20:27 -05:00
}
function resetRetryState ( tunnelName : string ) : void {
2025-09-12 14:42:00 -05:00
retryCounters . delete ( tunnelName );
retryExhaustedTunnels . delete ( tunnelName );
2025-10-01 15:40:10 -05:00
cleanupInProgress . delete ( tunnelName );
tunnelConnecting . delete ( tunnelName );
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( activeRetryTimers . has ( tunnelName )) {
clearTimeout ( activeRetryTimers . get ( tunnelName ) ! );
activeRetryTimers . delete ( tunnelName );
}
if ( countdownIntervals . has ( tunnelName )) {
clearInterval ( countdownIntervals . get ( tunnelName ) ! );
countdownIntervals . delete ( tunnelName );
}
[ "" , "_confirm" , "_retry" , "_verify_retry" , "_ping" ]. forEach (( suffix ) => {
const timerKey = ` ${ tunnelName }${ suffix } ` ;
if ( verificationTimers . has ( timerKey )) {
clearTimeout ( verificationTimers . get ( timerKey ) ! );
verificationTimers . delete ( timerKey );
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
});
2025-08-07 02:20:27 -05:00
}
2025-12-31 22:20:12 -06:00
async function handleDisconnect (
2025-09-12 14:42:00 -05:00
tunnelName : string ,
tunnelConfig : TunnelConfig | null ,
shouldRetry = true ,
2025-12-31 22:20:12 -06:00
) : Promise < void > {
2025-09-12 14:42:00 -05:00
if ( tunnelVerifications . has ( tunnelName )) {
try {
const verification = tunnelVerifications . get ( tunnelName );
if ( verification ? . timeout ) clearTimeout ( verification . timeout );
verification ? . conn . end ();
2026-02-12 22:28:13 -06:00
} catch ( error ) {
tunnelLogger . error ( "Error during tunnel cleanup" , error , {
operation : "tunnel_cleanup_error" ,
tunnelName ,
});
}
2025-09-12 14:42:00 -05:00
tunnelVerifications . delete ( tunnelName );
}
2025-12-31 22:20:12 -06:00
while ( cleanupInProgress . has ( tunnelName )) {
await new Promise (( resolve ) => setTimeout ( resolve , 100 ));
}
await cleanupTunnelResources ( tunnelName );
2025-09-12 14:42:00 -05:00
if ( manualDisconnects . has ( tunnelName )) {
resetRetryState ( tunnelName );
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.DISCONNECTED ,
manualDisconnect : true ,
});
return ;
}
if ( retryExhaustedTunnels . has ( tunnelName )) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : "Max retries already exhausted" ,
});
return ;
}
if ( activeRetryTimers . has ( tunnelName )) {
return ;
}
if ( shouldRetry && tunnelConfig ) {
const maxRetries = tunnelConfig . maxRetries || 3 ;
const retryInterval = tunnelConfig . retryInterval || 5000 ;
let retryCount = retryCounters . get ( tunnelName ) || 0 ;
retryCount = retryCount + 1 ;
if ( retryCount > maxRetries ) {
tunnelLogger . error ( `All ${ maxRetries } retries failed for ${ tunnelName } ` );
retryExhaustedTunnels . add ( tunnelName );
activeTunnels . delete ( tunnelName );
retryCounters . delete ( tunnelName );
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
retryExhausted : true ,
reason : `Max retries exhausted` ,
});
return ;
}
retryCounters . set ( tunnelName , retryCount );
if ( retryCount <= maxRetries ) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.RETRYING ,
retryCount : retryCount ,
maxRetries : maxRetries ,
nextRetryIn : retryInterval / 1000 ,
});
if ( activeRetryTimers . has ( tunnelName )) {
clearTimeout ( activeRetryTimers . get ( tunnelName ) ! );
activeRetryTimers . delete ( tunnelName );
}
const initialNextRetryIn = Math . ceil ( retryInterval / 1000 );
let currentNextRetryIn = initialNextRetryIn ;
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.WAITING ,
retryCount : retryCount ,
maxRetries : maxRetries ,
nextRetryIn : currentNextRetryIn ,
});
const countdownInterval = setInterval (() => {
currentNextRetryIn -- ;
if ( currentNextRetryIn > 0 ) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.WAITING ,
retryCount : retryCount ,
maxRetries : maxRetries ,
nextRetryIn : currentNextRetryIn ,
});
}
}, 1000 );
countdownIntervals . set ( tunnelName , countdownInterval );
const timer = setTimeout (() => {
clearInterval ( countdownInterval );
countdownIntervals . delete ( tunnelName );
activeRetryTimers . delete ( tunnelName );
if ( ! manualDisconnects . has ( tunnelName )) {
activeTunnels . delete ( tunnelName );
connectSSHTunnel ( tunnelConfig , retryCount ). catch (( error ) => {
tunnelLogger . error (
`Failed to connect tunnel ${ tunnelConfig . name } : ${ error instanceof Error ? error . message : "Unknown error" } ` ,
);
});
}
}, retryInterval );
activeRetryTimers . set ( tunnelName , timer );
}
} else {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
});
activeTunnels . delete ( tunnelName );
}
}
function setupPingInterval ( tunnelName : string ) : void {
const pingKey = ` ${ tunnelName } _ping` ;
if ( verificationTimers . has ( pingKey )) {
clearInterval ( verificationTimers . get ( pingKey ) ! );
verificationTimers . delete ( pingKey );
}
const pingInterval = setInterval (() => {
const currentStatus = connectionStatus . get ( tunnelName );
if ( currentStatus ? . status === CONNECTION_STATES . CONNECTED ) {
if ( ! activeTunnels . has ( tunnelName )) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.DISCONNECTED ,
reason : "Tunnel connection lost" ,
});
clearInterval ( pingInterval );
verificationTimers . delete ( pingKey );
}
} else {
clearInterval ( pingInterval );
verificationTimers . delete ( pingKey );
}
}, 120000 );
verificationTimers . set ( pingKey , pingInterval );
}
async function connectSSHTunnel (
tunnelConfig : TunnelConfig ,
retryAttempt = 0 ,
) : Promise < void > {
const tunnelName = tunnelConfig . name ;
const tunnelMarker = getTunnelMarker ( tunnelName );
2026-02-12 22:28:13 -06:00
tunnelLogger . info ( "Tunnel creation request received" , {
operation : "tunnel_create_request" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
tunnelType : tunnelConfig.tunnelType || "remote" ,
sourcePort : tunnelConfig.sourcePort ,
endpointHost : tunnelConfig.endpointHost ,
endpointPort : tunnelConfig.endpointPort ,
});
2025-09-12 14:42:00 -05:00
if ( manualDisconnects . has ( tunnelName )) {
return ;
}
2025-10-01 15:40:10 -05:00
tunnelConnecting . add ( tunnelName );
cleanupTunnelResources ( tunnelName , true );
2025-09-12 14:42:00 -05:00
if ( retryAttempt === 0 ) {
retryExhaustedTunnels . delete ( tunnelName );
retryCounters . delete ( tunnelName );
}
const currentStatus = connectionStatus . get ( tunnelName );
if ( ! currentStatus || currentStatus . status !== CONNECTION_STATES . WAITING ) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.CONNECTING ,
retryCount : retryAttempt > 0 ? retryAttempt : undefined ,
});
}
if (
! tunnelConfig ||
! tunnelConfig . sourceIP ||
! tunnelConfig . sourceUsername ||
! tunnelConfig . sourceSSHPort
) {
2026-01-24 19:49:42 -06:00
const missingFields = [];
if ( ! tunnelConfig ) missingFields . push ( "tunnelConfig" );
if ( ! tunnelConfig ? . sourceIP ) missingFields . push ( "sourceIP" );
if ( ! tunnelConfig ? . sourceUsername ) missingFields . push ( "sourceUsername" );
if ( ! tunnelConfig ? . sourceSSHPort ) missingFields . push ( "sourceSSHPort" );
tunnelLogger . error ( "Invalid tunnel connection details" , undefined , {
operation : "tunnel_connect_validation_failed" ,
2025-09-12 14:42:00 -05:00
tunnelName ,
2026-01-24 19:49:42 -06:00
missingFields : missingFields.join ( ", " ),
2025-09-12 14:42:00 -05:00
hasSourceIP : !! tunnelConfig ? . sourceIP ,
hasSourceUsername : !! tunnelConfig ? . sourceUsername ,
hasSourceSSHPort : !! tunnelConfig ? . sourceSSHPort ,
});
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : "Missing required connection details" ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
return ;
}
let resolvedSourceCredentials = {
password : tunnelConfig.sourcePassword ,
sshKey : tunnelConfig.sourceSSHKey ,
keyPassword : tunnelConfig.sourceKeyPassword ,
keyType : tunnelConfig.sourceKeyType ,
authMethod : tunnelConfig.sourceAuthMethod ,
};
2025-12-31 22:20:12 -06:00
const effectiveUserId =
tunnelConfig . requestingUserId || tunnelConfig . sourceUserId ;
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( tunnelConfig . sourceCredentialId && effectiveUserId ) {
try {
if (
tunnelConfig . requestingUserId &&
tunnelConfig . requestingUserId !== tunnelConfig . sourceUserId
) {
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 ();
if ( tunnelConfig . sourceHostId ) {
const sharedCred = await sharedCredManager . getSharedCredentialForUser (
tunnelConfig . sourceHostId ,
tunnelConfig . requestingUserId ,
);
if ( sharedCred ) {
resolvedSourceCredentials = {
password : sharedCred.password ,
sshKey : sharedCred.key ,
keyPassword : sharedCred.keyPassword ,
keyType : sharedCred.keyType ,
authMethod : sharedCred.authType ,
};
} else {
const errorMessage = `Cannot connect tunnel ' ${ tunnelName } ': shared credentials not available` ;
2026-01-24 19:49:42 -06:00
tunnelLogger . error ( errorMessage , undefined , {
operation : "tunnel_shared_credentials_unavailable" ,
tunnelName ,
requestingUserId : tunnelConfig.requestingUserId ,
sourceUserId : tunnelConfig.sourceUserId ,
sourceHostId : tunnelConfig.sourceHostId ,
});
2025-12-31 22:20:12 -06:00
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : errorMessage ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-12-31 22:20:12 -06:00
return ;
}
}
} else {
const userDataKey = DataCrypto . getUserDataKey ( effectiveUserId );
if ( userDataKey ) {
const credentials = await SimpleDBOps . select (
getDb ()
. select ()
. from ( sshCredentials )
. where ( eq ( sshCredentials . id , tunnelConfig . sourceCredentialId )),
"ssh_credentials" ,
effectiveUserId ,
);
if ( credentials . length > 0 ) {
const credential = credentials [ 0 ];
resolvedSourceCredentials = {
password : credential.password as string | undefined ,
2026-03-08 18:02:14 -05:00
sshKey : credential.privateKey as string | undefined ,
keyPassword : credential.keyPassword as string | undefined ,
keyType : credential.keyType as string | undefined ,
authMethod : credential.authType as string ,
2025-12-31 22:20:12 -06:00
};
}
2025-10-01 15:40:10 -05:00
}
2025-09-12 14:42:00 -05:00
}
} catch ( error ) {
2025-12-31 22:20:12 -06:00
tunnelLogger . warn ( "Failed to resolve source credentials" , {
2025-09-12 14:42:00 -05:00
operation : "tunnel_connect" ,
tunnelName ,
credentialId : tunnelConfig.sourceCredentialId ,
error : error instanceof Error ? error . message : "Unknown error" ,
});
}
}
let resolvedEndpointCredentials = {
password : tunnelConfig.endpointPassword ,
sshKey : tunnelConfig.endpointSSHKey ,
keyPassword : tunnelConfig.endpointKeyPassword ,
keyType : tunnelConfig.endpointKeyType ,
authMethod : tunnelConfig.endpointAuthMethod ,
};
2025-10-01 15:40:10 -05:00
if (
resolvedEndpointCredentials . authMethod === "password" &&
! resolvedEndpointCredentials . password
) {
const errorMessage = `Cannot connect tunnel ' ${ tunnelName } ': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.` ;
2026-01-24 19:49:42 -06:00
tunnelLogger . error ( errorMessage , undefined , {
operation : "tunnel_endpoint_password_unavailable" ,
tunnelName ,
endpointHost : ` ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } ` ,
endpointAuthMethod : resolvedEndpointCredentials.authMethod ,
});
2025-10-01 15:40:10 -05:00
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : errorMessage ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-10-01 15:40:10 -05:00
return ;
}
if (
resolvedEndpointCredentials . authMethod === "key" &&
! resolvedEndpointCredentials . sshKey
) {
const errorMessage = `Cannot connect tunnel ' ${ tunnelName } ': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.` ;
2026-01-24 19:49:42 -06:00
tunnelLogger . error ( errorMessage , undefined , {
operation : "tunnel_endpoint_key_unavailable" ,
tunnelName ,
endpointHost : ` ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } ` ,
endpointAuthMethod : resolvedEndpointCredentials.authMethod ,
});
2025-10-01 15:40:10 -05:00
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : errorMessage ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-10-01 15:40:10 -05:00
return ;
}
2025-09-12 14:42:00 -05:00
if ( tunnelConfig . endpointCredentialId && tunnelConfig . endpointUserId ) {
try {
2025-10-01 15:40:10 -05:00
const userDataKey = DataCrypto . getUserDataKey (
tunnelConfig . endpointUserId ,
);
if ( userDataKey ) {
const credentials = await SimpleDBOps . select (
getDb ()
. select ()
. from ( sshCredentials )
2025-12-31 22:20:12 -06:00
. where ( eq ( sshCredentials . id , tunnelConfig . endpointCredentialId )),
2025-10-01 15:40:10 -05:00
"ssh_credentials" ,
tunnelConfig . endpointUserId ,
2025-09-12 14:42:00 -05:00
);
2025-10-01 15:40:10 -05:00
if ( credentials . length > 0 ) {
const credential = credentials [ 0 ];
resolvedEndpointCredentials = {
2025-11-05 10:36:16 -06:00
password : credential.password as string | undefined ,
2026-03-08 18:02:14 -05:00
sshKey : credential.privateKey as string | undefined ,
keyPassword : credential.keyPassword as string | undefined ,
keyType : credential.keyType as string | undefined ,
authMethod : credential.authType as string ,
2025-10-01 15:40:10 -05:00
};
} else {
tunnelLogger . warn ( "No endpoint credentials found in database" , {
operation : "tunnel_connect" ,
tunnelName ,
credentialId : tunnelConfig.endpointCredentialId ,
});
}
2025-09-12 14:42:00 -05:00
}
} catch ( error ) {
tunnelLogger . warn (
`Failed to resolve endpoint credentials for tunnel ${ tunnelName } : ${ error instanceof Error ? error . message : "Unknown error" } ` ,
);
}
} else if ( tunnelConfig . endpointCredentialId ) {
tunnelLogger . warn ( "Missing userId for endpoint credential resolution" , {
operation : "tunnel_connect" ,
tunnelName ,
credentialId : tunnelConfig.endpointCredentialId ,
hasUserId : !! tunnelConfig . endpointUserId ,
});
}
const conn = new Client ();
const connectionTimeout = setTimeout (() => {
if ( conn ) {
if ( activeRetryTimers . has ( tunnelName )) {
return ;
}
2026-01-24 19:49:42 -06:00
tunnelLogger . error (
`Tunnel connection timeout after 60 seconds for ' ${ tunnelName } '` ,
undefined ,
{
operation : "tunnel_connection_timeout" ,
tunnelName ,
sourceHost : ` ${ tunnelConfig . sourceUsername } @ ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
endpointHost : ` ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } ` ,
retryAttempt ,
usingSocks5 : tunnelConfig.useSocks5 || false ,
},
);
2025-09-12 14:42:00 -05:00
try {
conn . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2025-09-12 14:42:00 -05:00
activeTunnels . delete ( tunnelName );
if ( ! activeRetryTimers . has ( tunnelName )) {
handleDisconnect (
tunnelName ,
tunnelConfig ,
! manualDisconnects . has ( tunnelName ),
);
}
}
}, 60000 );
conn . on ( "error" , ( err ) => {
clearTimeout ( connectionTimeout );
2026-01-24 19:49:42 -06:00
const errorType = classifyError ( err . message );
tunnelLogger . error ( `Tunnel connection failed for ' ${ tunnelName } '` , err , {
operation : "tunnel_connect_error" ,
tunnelName ,
errorType ,
errorMessage : err.message ,
sourceHost : ` ${ tunnelConfig . sourceUsername } @ ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
endpointHost : ` ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } ` ,
tunnelType : tunnelConfig.tunnelType || "remote" ,
sourcePort : tunnelConfig.sourcePort ,
retryAttempt ,
usingSocks5 : tunnelConfig.useSocks5 || false ,
authMethod : tunnelConfig.sourceAuthMethod ,
});
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
tunnelConnecting . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
if ( activeRetryTimers . has ( tunnelName )) {
return ;
}
if ( ! manualDisconnects . has ( tunnelName )) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
errorType : errorType ,
reason : err.message ,
});
}
activeTunnels . delete ( tunnelName );
const shouldNotRetry =
errorType === "AUTHENTICATION_FAILED" ||
errorType === "CONNECTION_FAILED" ||
manualDisconnects . has ( tunnelName );
handleDisconnect ( tunnelName , tunnelConfig , ! shouldNotRetry );
});
conn . on ( "close" , () => {
clearTimeout ( connectionTimeout );
2025-10-01 15:40:10 -05:00
tunnelConnecting . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
if ( activeRetryTimers . has ( tunnelName )) {
return ;
}
if ( ! manualDisconnects . has ( tunnelName )) {
const currentStatus = connectionStatus . get ( tunnelName );
if ( ! currentStatus || currentStatus . status !== CONNECTION_STATES . FAILED ) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.DISCONNECTED ,
});
}
if ( ! activeRetryTimers . has ( tunnelName )) {
handleDisconnect (
tunnelName ,
tunnelConfig ,
! manualDisconnects . has ( tunnelName ),
);
}
}
});
conn . on ( "ready" , () => {
clearTimeout ( connectionTimeout );
2026-02-12 22:28:13 -06:00
tunnelLogger . info ( "Creating new SSH connection for tunnel" , {
operation : "tunnel_connection_create" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
});
2025-09-12 14:42:00 -05:00
const isAlreadyVerifying = tunnelVerifications . has ( tunnelName );
if ( isAlreadyVerifying ) {
return ;
}
2026-01-24 19:49:42 -06:00
const tunnelType = tunnelConfig . tunnelType || "remote" ;
const tunnelFlag = tunnelType === "local" ? "-L" : "-R" ;
const portMapping =
tunnelType === "local"
? ` ${ tunnelConfig . sourcePort } : ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } `
: ` ${ tunnelConfig . endpointPort } :localhost: ${ tunnelConfig . sourcePort } ` ;
2025-09-12 14:42:00 -05:00
let tunnelCmd : string ;
if (
resolvedEndpointCredentials . authMethod === "key" &&
resolvedEndpointCredentials . sshKey
) {
const keyFilePath = `/tmp/tunnel_key_ ${ tunnelName . replace ( /[^a-zA-Z0-9]/g , "_" ) } ` ;
2026-01-24 19:49:42 -06:00
tunnelCmd = `echo ' ${ resolvedEndpointCredentials . sshKey } ' > ${ keyFilePath } && chmod 600 ${ keyFilePath } && exec -a " ${ tunnelMarker } " ssh -i ${ keyFilePath } -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${ tunnelFlag } ${ portMapping } ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } && rm -f ${ keyFilePath } ` ;
2025-09-12 14:42:00 -05:00
} else {
2026-01-24 19:49:42 -06:00
tunnelCmd = `exec -a " ${ tunnelMarker } " sshpass -p ' ${ resolvedEndpointCredentials . password || "" } ' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${ tunnelFlag } ${ portMapping } ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } ` ;
2025-09-12 14:42:00 -05:00
}
conn . exec ( tunnelCmd , ( err , stream ) => {
if ( err ) {
2026-01-24 19:49:42 -06:00
const errorType = classifyError ( err . message );
2025-09-12 14:42:00 -05:00
tunnelLogger . error (
2026-01-24 19:49:42 -06:00
`Failed to execute tunnel command for ' ${ tunnelName } '` ,
err ,
{
operation : "tunnel_exec_error" ,
tunnelName ,
errorType ,
errorMessage : err.message ,
sourceHost : ` ${ tunnelConfig . sourceUsername } @ ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
endpointHost : ` ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } : ${ tunnelConfig . endpointPort } ` ,
tunnelType : tunnelConfig.tunnelType || "remote" ,
sourcePort : tunnelConfig.sourcePort ,
endpointPort : tunnelConfig.endpointPort ,
retryAttempt ,
},
2025-09-12 14:42:00 -05:00
);
conn . end ();
activeTunnels . delete ( tunnelName );
const shouldNotRetry =
errorType === "AUTHENTICATION_FAILED" ||
errorType === "CONNECTION_FAILED" ;
handleDisconnect ( tunnelName , tunnelConfig , ! shouldNotRetry );
return ;
}
activeTunnels . set ( tunnelName , conn );
2026-02-12 22:28:13 -06:00
tunnelLogger . success ( "Tunnel port binding successful" , {
operation : "tunnel_port_bound" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
sourcePort : tunnelConfig.sourcePort ,
endpointPort : tunnelConfig.endpointPort ,
});
2025-09-12 14:42:00 -05:00
setTimeout (() => {
if (
! manualDisconnects . has ( tunnelName ) &&
activeTunnels . has ( tunnelName )
) {
2025-10-01 15:40:10 -05:00
tunnelConnecting . delete ( tunnelName );
2026-02-12 22:28:13 -06:00
tunnelLogger . success ( "Tunnel creation complete" , {
operation : "tunnel_create_complete" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
});
2025-10-01 15:40:10 -05:00
2025-09-12 14:42:00 -05:00
broadcastTunnelStatus ( tunnelName , {
connected : true ,
status : CONNECTION_STATES.CONNECTED ,
});
setupPingInterval ( tunnelName );
}
}, 2000 );
stream . on ( "close" , ( code : number ) => {
if ( activeRetryTimers . has ( tunnelName )) {
return ;
}
activeTunnels . delete ( tunnelName );
if ( tunnelVerifications . has ( tunnelName )) {
try {
2025-08-07 02:20:27 -05:00
const verification = tunnelVerifications . get ( tunnelName );
if ( verification ? . timeout ) clearTimeout ( verification . timeout );
verification ? . conn . end ();
2026-03-08 18:02:14 -05:00
} catch {
// expected
}
2025-09-12 14:42:00 -05:00
tunnelVerifications . delete ( tunnelName );
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
const isLikelyRemoteClosure = code === 255 ;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( isLikelyRemoteClosure && retryExhaustedTunnels . has ( tunnelName )) {
retryExhaustedTunnels . delete ( tunnelName );
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
if (
! manualDisconnects . has ( tunnelName ) &&
code !== 0 &&
code !== undefined
) {
if ( retryExhaustedTunnels . has ( tunnelName )) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : "Max retries exhausted" ,
});
} else {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : isLikelyRemoteClosure
? "Connection closed by remote host"
: "Connection closed unexpectedly" ,
});
}
}
if (
! activeRetryTimers . has ( tunnelName ) &&
! retryExhaustedTunnels . has ( tunnelName )
) {
handleDisconnect (
tunnelName ,
tunnelConfig ,
! manualDisconnects . has ( tunnelName ),
);
} else if (
retryExhaustedTunnels . has ( tunnelName ) &&
isLikelyRemoteClosure
) {
retryExhaustedTunnels . delete ( tunnelName );
retryCounters . delete ( tunnelName );
handleDisconnect ( tunnelName , tunnelConfig , true );
}
});
2025-11-05 10:36:16 -06:00
stream . stdout ? . on ( "data" , () => {});
2025-09-12 14:42:00 -05:00
2025-11-05 10:36:16 -06:00
stream . on ( "error" , () => {});
2025-09-12 14:42:00 -05:00
stream . stderr . on ( "data" , ( data ) => {
const errorMsg = data . toString (). trim ();
2025-10-01 15:40:10 -05:00
if ( errorMsg ) {
const isDebugMessage =
errorMsg . startsWith ( "debug1:" ) ||
errorMsg . startsWith ( "debug2:" ) ||
errorMsg . startsWith ( "debug3:" ) ||
errorMsg . includes ( "Reading configuration data" ) ||
errorMsg . includes ( "include /etc/ssh/ssh_config.d" ) ||
errorMsg . includes ( "matched no files" ) ||
errorMsg . includes ( "Applying options for" );
if ( ! isDebugMessage ) {
tunnelLogger . error ( `SSH stderr for ' ${ tunnelName } ': ${ errorMsg } ` );
}
if (
errorMsg . includes ( "sshpass: command not found" ) ||
errorMsg . includes ( "sshpass not found" )
) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason :
"sshpass tool not found on source host. Please install sshpass or use SSH key authentication." ,
});
}
if (
errorMsg . includes ( "remote port forwarding failed" ) ||
errorMsg . includes ( "Error: remote port forwarding failed" )
) {
const portMatch = errorMsg . match ( /listen port (\d+)/ );
const port = portMatch ? portMatch [ 1 ] : tunnelConfig . endpointPort ;
tunnelLogger . error (
`Port forwarding failed for tunnel ' ${ tunnelName } ' on port ${ port } . This prevents tunnel establishment.` ,
);
if ( activeTunnels . has ( tunnelName )) {
const conn = activeTunnels . get ( tunnelName );
if ( conn ) {
conn . end ();
}
activeTunnels . delete ( tunnelName );
}
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : `Remote port forwarding failed for port ${ port } . Port may be in use, requires root privileges, or SSH server doesn't allow port forwarding. Try a different port.` ,
});
}
}
2025-09-12 14:42:00 -05:00
});
});
});
2025-11-05 10:36:16 -06:00
const connOptions : Record < string , unknown > = {
2026-03-08 18:02:14 -05:00
host :
tunnelConfig.sourceIP?.replace ( /^\[|\]$/g , "" ) || tunnelConfig . sourceIP ,
2025-09-12 14:42:00 -05:00
port : tunnelConfig.sourceSSHPort ,
username : tunnelConfig.sourceUsername ,
2025-11-05 10:36:16 -06:00
tryKeyboard : true ,
2025-09-12 14:42:00 -05:00
keepaliveInterval : 30000 ,
keepaliveCountMax : 3 ,
readyTimeout : 60000 ,
tcpKeepAlive : true ,
2025-11-05 10:36:16 -06:00
tcpKeepAliveInitialDelay : 30000 ,
env : {
TERM : "xterm-256color" ,
LANG : "en_US.UTF-8" ,
LC_ALL : "en_US.UTF-8" ,
LC_CTYPE : "en_US.UTF-8" ,
LC_MESSAGES : "en_US.UTF-8" ,
LC_MONETARY : "en_US.UTF-8" ,
LC_NUMERIC : "en_US.UTF-8" ,
LC_TIME : "en_US.UTF-8" ,
LC_COLLATE : "en_US.UTF-8" ,
COLORTERM : "truecolor" ,
},
2025-09-12 14:42:00 -05:00
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" ,
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
],
cipher : [
2025-11-05 10:36:16 -06:00
"chacha20-poly1305@openssh.com" ,
2025-09-12 14:42:00 -05:00
"aes256-gcm@openssh.com" ,
2025-11-05 10:36:16 -06:00
"aes128-gcm@openssh.com" ,
"aes256-ctr" ,
"aes192-ctr" ,
"aes128-ctr" ,
2025-09-12 14:42:00 -05:00
"aes256-cbc" ,
2025-11-05 10:36:16 -06:00
"aes192-cbc" ,
"aes128-cbc" ,
2025-09-12 14:42:00 -05:00
"3des-cbc" ,
],
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" ],
},
};
if (
resolvedSourceCredentials . authMethod === "key" &&
resolvedSourceCredentials . sshKey
) {
if (
! resolvedSourceCredentials . sshKey . includes ( "-----BEGIN" ) ||
! resolvedSourceCredentials . sshKey . includes ( "-----END" )
) {
tunnelLogger . error (
`Invalid SSH key format for tunnel ' ${ tunnelName } '. Key should contain both BEGIN and END markers` ,
2026-01-24 19:49:42 -06:00
undefined ,
{
operation : "tunnel_invalid_ssh_key_format" ,
tunnelName ,
sourceHost : ` ${ tunnelConfig . sourceUsername } @ ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
keyType : resolvedSourceCredentials.keyType ,
hasBeginMarker :
resolvedSourceCredentials.sshKey.includes ( "-----BEGIN" ),
hasEndMarker : resolvedSourceCredentials.sshKey.includes ( "-----END" ),
},
2025-09-12 14:42:00 -05:00
);
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : "Invalid SSH key format" ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
return ;
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
const cleanKey = resolvedSourceCredentials . sshKey
. trim ()
. replace ( /\r\n/g , "\n" )
. replace ( /\r/g , "\n" );
connOptions . privateKey = Buffer . from ( cleanKey , "utf8" );
if ( resolvedSourceCredentials . keyPassword ) {
connOptions . passphrase = resolvedSourceCredentials . keyPassword ;
}
if (
resolvedSourceCredentials . keyType &&
resolvedSourceCredentials . keyType !== "auto"
) {
connOptions . privateKeyType = resolvedSourceCredentials . keyType ;
}
} else if ( resolvedSourceCredentials . authMethod === "key" ) {
tunnelLogger . error (
`SSH key authentication requested but no key provided for tunnel ' ${ tunnelName } '` ,
2026-01-24 19:49:42 -06:00
undefined ,
{
operation : "tunnel_ssh_key_missing" ,
tunnelName ,
sourceHost : ` ${ tunnelConfig . sourceUsername } @ ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
authMethod : resolvedSourceCredentials.authMethod ,
},
2025-09-12 14:42:00 -05:00
);
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason : "SSH key authentication requested but no key provided" ,
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
return ;
} else {
connOptions . password = resolvedSourceCredentials . password ;
}
const finalStatus = connectionStatus . get ( tunnelName );
if ( ! finalStatus || finalStatus . status !== CONNECTION_STATES . WAITING ) {
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.CONNECTING ,
retryCount : retryAttempt > 0 ? retryAttempt : undefined ,
});
}
2025-12-31 22:20:12 -06:00
if (
tunnelConfig . useSocks5 &&
( tunnelConfig . socks5Host ||
( tunnelConfig . socks5ProxyChain &&
tunnelConfig . socks5ProxyChain . length > 0 ))
) {
try {
const socks5Socket = await createSocks5Connection (
tunnelConfig . sourceIP ,
tunnelConfig . sourceSSHPort ,
{
useSocks5 : tunnelConfig.useSocks5 ,
socks5Host : tunnelConfig.socks5Host ,
socks5Port : tunnelConfig.socks5Port ,
socks5Username : tunnelConfig.socks5Username ,
socks5Password : tunnelConfig.socks5Password ,
socks5ProxyChain : tunnelConfig.socks5ProxyChain ,
},
);
if ( socks5Socket ) {
connOptions . sock = socks5Socket ;
conn . connect ( connOptions );
return ;
}
} catch ( socks5Error ) {
tunnelLogger . error ( "SOCKS5 connection failed for tunnel" , socks5Error , {
2026-01-24 19:49:42 -06:00
operation : "tunnel_socks5_connection_failed" ,
2025-12-31 22:20:12 -06:00
tunnelName ,
2026-01-24 19:49:42 -06:00
sourceHost : ` ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } ` ,
2025-12-31 22:20:12 -06:00
proxyHost : tunnelConfig.socks5Host ,
proxyPort : tunnelConfig.socks5Port || 1080 ,
2026-01-24 19:49:42 -06:00
hasProxyAuth : !! (
tunnelConfig . socks5Username && tunnelConfig . socks5Password
),
errorMessage :
socks5Error instanceof Error ? socks5Error . message : "Unknown error" ,
2025-12-31 22:20:12 -06:00
});
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.FAILED ,
reason :
"SOCKS5 proxy connection failed: " +
( socks5Error instanceof Error
? socks5Error . message
: "Unknown error" ),
});
2026-01-24 19:49:42 -06:00
tunnelConnecting . delete ( tunnelName );
2025-12-31 22:20:12 -06:00
return ;
}
}
2025-09-12 14:42:00 -05:00
conn . connect ( connOptions );
2025-08-07 02:20:27 -05:00
}
2025-10-01 15:40:10 -05:00
async function killRemoteTunnelByMarker (
2025-09-12 14:42:00 -05:00
tunnelConfig : TunnelConfig ,
tunnelName : string ,
callback : ( err? : Error ) => void ,
) {
const tunnelMarker = getTunnelMarker ( tunnelName );
2026-02-12 22:28:13 -06:00
tunnelLogger . info ( "Killing remote tunnel process" , {
operation : "tunnel_remote_kill" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
marker : tunnelMarker ,
});
2025-10-01 15:40:10 -05:00
let resolvedSourceCredentials = {
password : tunnelConfig.sourcePassword ,
sshKey : tunnelConfig.sourceSSHKey ,
keyPassword : tunnelConfig.sourceKeyPassword ,
keyType : tunnelConfig.sourceKeyType ,
authMethod : tunnelConfig.sourceAuthMethod ,
};
if ( tunnelConfig . sourceCredentialId && tunnelConfig . sourceUserId ) {
try {
const userDataKey = DataCrypto . getUserDataKey ( tunnelConfig . sourceUserId );
if ( userDataKey ) {
const credentials = await SimpleDBOps . select (
getDb ()
. select ()
. from ( sshCredentials )
2025-12-31 22:20:12 -06:00
. where ( eq ( sshCredentials . id , tunnelConfig . sourceCredentialId )),
2025-10-01 15:40:10 -05:00
"ssh_credentials" ,
tunnelConfig . sourceUserId ,
);
if ( credentials . length > 0 ) {
const credential = credentials [ 0 ];
resolvedSourceCredentials = {
2025-11-05 10:36:16 -06:00
password : credential.password as string | undefined ,
2026-03-08 18:02:14 -05:00
sshKey : credential.privateKey as string | undefined ,
keyPassword : credential.keyPassword as string | undefined ,
keyType : credential.keyType as string | undefined ,
authMethod : credential.authType as string ,
2025-10-01 15:40:10 -05:00
};
}
}
} catch ( error ) {
tunnelLogger . warn ( "Failed to resolve source credentials for cleanup" , {
tunnelName ,
credentialId : tunnelConfig.sourceCredentialId ,
error : error instanceof Error ? error . message : "Unknown error" ,
});
}
}
if (
resolvedSourceCredentials . authMethod === "key" &&
resolvedSourceCredentials . sshKey
) {
2025-09-12 14:42:00 -05:00
if (
2025-10-01 15:40:10 -05:00
! resolvedSourceCredentials . sshKey . includes ( "-----BEGIN" ) ||
! resolvedSourceCredentials . sshKey . includes ( "-----END" )
2025-09-12 14:42:00 -05:00
) {
callback ( new Error ( "Invalid SSH key format" ));
return ;
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
2026-03-08 18:02:14 -05:00
const poolKey = `tunnel: ${ tunnelConfig . sourceUserId } : ${ tunnelConfig . sourceIP } : ${ tunnelConfig . sourceSSHPort } : ${ tunnelConfig . sourceUsername } ` ;
2025-10-01 15:40:10 -05:00
2026-03-08 18:02:14 -05:00
const factory = async () : Promise < Client > => {
const connOptions : Record < string , unknown > = {
host :
tunnelConfig.sourceIP?.replace ( /^\[|\]$/g , "" ) || tunnelConfig . sourceIP ,
port : tunnelConfig.sourceSSHPort ,
username : tunnelConfig.sourceUsername ,
keepaliveInterval : 30000 ,
keepaliveCountMax : 3 ,
readyTimeout : 60000 ,
tcpKeepAlive : true ,
tcpKeepAliveInitialDelay : 15000 ,
algorithms : {
kex : [
"diffie-hellman-group14-sha256" ,
"diffie-hellman-group14-sha1" ,
"diffie-hellman-group1-sha1" ,
"diffie-hellman-group-exchange-sha256" ,
"diffie-hellman-group-exchange-sha1" ,
"ecdh-sha2-nistp256" ,
"ecdh-sha2-nistp384" ,
"ecdh-sha2-nistp521" ,
],
cipher : [
"aes128-ctr" ,
"aes192-ctr" ,
"aes256-ctr" ,
"aes128-gcm@openssh.com" ,
"aes256-gcm@openssh.com" ,
"aes128-cbc" ,
"aes192-cbc" ,
"aes256-cbc" ,
"3des-cbc" ,
],
hmac : [
"hmac-sha2-256-etm@openssh.com" ,
"hmac-sha2-512-etm@openssh.com" ,
"hmac-sha2-256" ,
"hmac-sha2-512" ,
"hmac-sha1" ,
"hmac-md5" ,
],
compress : [ "none" , "zlib@openssh.com" , "zlib" ],
},
};
2025-10-01 15:40:10 -05:00
2026-03-08 18:02:14 -05:00
if (
resolvedSourceCredentials . authMethod === "key" &&
resolvedSourceCredentials . sshKey
) {
const cleanKey = resolvedSourceCredentials . sshKey
. trim ()
. replace ( /\r\n/g , "\n" )
. replace ( /\r/g , "\n" );
connOptions . privateKey = Buffer . from ( cleanKey , "utf8" );
if ( resolvedSourceCredentials . keyPassword ) {
connOptions . passphrase = resolvedSourceCredentials . keyPassword ;
}
if (
resolvedSourceCredentials . keyType &&
resolvedSourceCredentials . keyType !== "auto"
) {
connOptions . privateKeyType = resolvedSourceCredentials . keyType ;
}
} else {
connOptions . password = resolvedSourceCredentials . password ;
}
2025-10-01 15:40:10 -05:00
2026-03-08 18:02:14 -05:00
if (
tunnelConfig . useSocks5 &&
( tunnelConfig . socks5Host ||
( tunnelConfig . socks5ProxyChain &&
tunnelConfig . socks5ProxyChain . length > 0 ))
) {
2025-12-31 22:20:12 -06:00
try {
const socks5Socket = await createSocks5Connection (
tunnelConfig . sourceIP ,
tunnelConfig . sourceSSHPort ,
{
useSocks5 : tunnelConfig.useSocks5 ,
socks5Host : tunnelConfig.socks5Host ,
socks5Port : tunnelConfig.socks5Port ,
socks5Username : tunnelConfig.socks5Username ,
socks5Password : tunnelConfig.socks5Password ,
socks5ProxyChain : tunnelConfig.socks5ProxyChain ,
},
);
if ( socks5Socket ) {
connOptions . sock = socks5Socket ;
} else {
2026-03-08 18:02:14 -05:00
throw new Error ( "Failed to create SOCKS5 connection" );
2025-12-31 22:20:12 -06:00
}
} catch ( socks5Error ) {
tunnelLogger . error (
"SOCKS5 connection failed for killing tunnel" ,
socks5Error ,
{
operation : "socks5_connect_kill" ,
tunnelName ,
proxyHost : tunnelConfig.socks5Host ,
proxyPort : tunnelConfig.socks5Port || 1080 ,
},
);
2026-03-08 18:02:14 -05:00
throw new Error (
"SOCKS5 proxy connection failed: " +
( socks5Error instanceof Error
? socks5Error . message
: "Unknown error" ),
2025-12-31 22:20:12 -06:00
);
}
2026-03-08 18:02:14 -05:00
}
return new Promise < Client >(( resolve , reject ) => {
const conn = new Client ();
conn . on ( "ready" , () => resolve ( conn ));
conn . on ( "error" , ( err ) => reject ( err ));
conn . connect ( connOptions );
});
};
const execCommand = ( client : Client , cmd : string ) : Promise < string > =>
new Promise (( resolve , reject ) => {
client . exec ( cmd , ( err , stream ) => {
if ( err ) {
reject ( err );
return ;
}
let output = "" ;
stream . on ( "data" , ( data : Buffer ) => {
output += data . toString ();
});
stream . stderr . on ( "data" , ( data : Buffer ) => {
const stderr = data . toString (). trim ();
if ( stderr && ! stderr . includes ( "debug1" )) {
tunnelLogger . warn (
`Kill command stderr for ' ${ tunnelName } ': ${ stderr } ` ,
);
}
});
stream . on ( "close" , () => resolve ( output . trim ()));
});
});
try {
await withConnection ( poolKey , factory , async ( client ) => {
const tunnelType = tunnelConfig . tunnelType || "remote" ;
const tunnelFlag = tunnelType === "local" ? "-L" : "-R" ;
const checkCmd = `ps aux | grep -E '( ${ tunnelMarker } |ssh.* ${ tunnelFlag } .* ${ tunnelConfig . endpointPort } :.*: ${ tunnelConfig . sourcePort } .* ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } |sshpass.*ssh.* ${ tunnelFlag } )' | grep -v grep` ;
const checkOutput = await execCommand ( client , checkCmd );
if ( ! checkOutput ) {
tunnelLogger . warn ( "Remote tunnel process not found" , {
operation : "tunnel_remote_not_found" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
marker : tunnelMarker ,
});
return ;
}
tunnelLogger . info ( "Remote tunnel process found, proceeding to kill" , {
operation : "tunnel_remote_found" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
marker : tunnelMarker ,
});
const killCmds = [
`pkill -TERM -f ' ${ tunnelMarker } '` ,
`sleep 1 && pkill -f 'ssh.* ${ tunnelFlag } .* ${ tunnelConfig . endpointPort } :.*: ${ tunnelConfig . sourcePort } .* ${ tunnelConfig . endpointUsername } @ ${ tunnelConfig . endpointIP } '` ,
`sleep 1 && pkill -f 'sshpass.*ssh.* ${ tunnelFlag } .* ${ tunnelConfig . endpointPort } '` ,
`sleep 2 && pkill -9 -f ' ${ tunnelMarker } '` ,
];
for ( const killCmd of killCmds ) {
try {
await execCommand ( client , killCmd );
} catch ( err ) {
tunnelLogger . warn (
`Kill command failed for ' ${ tunnelName } ': ${ ( err as Error ). message } ` ,
);
}
}
const verifyOutput = await execCommand ( client , checkCmd );
if ( verifyOutput ) {
tunnelLogger . warn (
`Some tunnel processes may still be running for ' ${ tunnelName } '` ,
);
} else {
tunnelLogger . success ( "Remote tunnel process killed" , {
operation : "tunnel_remote_killed" ,
userId : tunnelConfig.sourceUserId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
});
}
});
callback ();
} catch ( err ) {
tunnelLogger . error (
`Failed to connect to source host for killing tunnel ' ${ tunnelName } ': ${ ( err as Error ). message } ` ,
);
callback ( err as Error );
2025-12-31 22:20:12 -06:00
}
2025-08-07 02:20:27 -05:00
}
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /ssh/tunnel/status:
* get:
* summary: Get all tunnel statuses
* description: Retrieves the status of all SSH tunnels.
* tags:
* - SSH Tunnels
* responses:
* 200:
* description: A list of all tunnel statuses.
*/
2025-09-12 14:42:00 -05:00
app . get ( "/ssh/tunnel/status" , ( req , res ) => {
res . json ( getAllTunnelStatus ());
2025-08-07 02:20:27 -05:00
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /ssh/tunnel/status/{tunnelName}:
* get:
* summary: Get tunnel status by name
* description: Retrieves the status of a specific SSH tunnel by its name.
* tags:
* - SSH Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tunnel status.
* 404:
* description: Tunnel not found.
*/
2025-09-12 14:42:00 -05:00
app . get ( "/ssh/tunnel/status/:tunnelName" , ( req , res ) => {
const { tunnelName } = req . params ;
const status = connectionStatus . get ( tunnelName );
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( ! status ) {
return res . status ( 404 ). json ({ error : "Tunnel not found" });
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
res . json ({ name : tunnelName , status });
2025-08-07 02:20:27 -05:00
});
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /ssh/tunnel/connect:
* post:
* summary: Connect SSH tunnel
* description: Establishes an SSH tunnel connection with the specified configuration.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 401:
* description: Authentication required.
* 403:
* description: Access denied to this host.
* 500:
* description: Failed to connect tunnel.
*/
2025-12-31 22:20:12 -06:00
app . post (
"/ssh/tunnel/connect" ,
authenticateJWT ,
async ( req : AuthenticatedRequest , res : Response ) => {
const tunnelConfig : TunnelConfig = req . body ;
const userId = req . userId ;
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
if ( ! userId ) {
return res . status ( 401 ). json ({ error : "Authentication required" });
}
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
if ( ! tunnelConfig || ! tunnelConfig . name ) {
return res . status ( 400 ). json ({ error : "Invalid tunnel configuration" });
}
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
const tunnelName = tunnelConfig . name ;
2025-10-01 15:40:10 -05:00
2025-12-31 22:20:12 -06:00
try {
if ( ! validateTunnelConfig ( tunnelName , tunnelConfig )) {
tunnelLogger . error ( `Tunnel config validation failed` , {
operation : "tunnel_connect" ,
tunnelName ,
configHostId : tunnelConfig.sourceHostId ,
configTunnelIndex : tunnelConfig.tunnelIndex ,
});
return res . status ( 400 ). json ({
error : "Tunnel configuration does not match tunnel name" ,
});
}
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( tunnelConfig . sourceHostId ) {
const accessInfo = await permissionManager . canAccessHost (
userId ,
tunnelConfig . sourceHostId ,
"read" ,
);
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( ! accessInfo . hasAccess ) {
tunnelLogger . warn ( "User attempted tunnel connect without access" , {
operation : "tunnel_connect_unauthorized" ,
userId ,
hostId : tunnelConfig.sourceHostId ,
tunnelName ,
});
return res . status ( 403 ). json ({ error : "Access denied to this host" });
}
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( accessInfo . isShared && ! accessInfo . isOwner ) {
tunnelConfig . requestingUserId = userId ;
}
}
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( pendingTunnelOperations . has ( tunnelName )) {
try {
await pendingTunnelOperations . get ( tunnelName );
2026-03-08 18:02:14 -05:00
} catch {
2025-12-31 22:20:12 -06:00
tunnelLogger . warn ( `Previous tunnel operation failed` , { tunnelName });
}
}
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const operation = ( async () => {
manualDisconnects . delete ( tunnelName );
retryCounters . delete ( tunnelName );
retryExhaustedTunnels . delete ( tunnelName );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
await cleanupTunnelResources ( tunnelName );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( tunnelConfigs . has ( tunnelName )) {
const existingConfig = tunnelConfigs . get ( tunnelName );
if (
existingConfig &&
( existingConfig . sourceHostId !== tunnelConfig . sourceHostId ||
existingConfig . tunnelIndex !== tunnelConfig . tunnelIndex )
) {
throw new Error ( `Tunnel name collision detected: ${ tunnelName } ` );
}
}
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( ! tunnelConfig . endpointIP || ! tunnelConfig . endpointUsername ) {
try {
const systemCrypto = SystemCrypto . getInstance ();
const internalAuthToken = await systemCrypto . getInternalAuthToken ();
2025-10-01 15:40:10 -05:00
2025-12-31 22:20:12 -06:00
const allHostsResponse = await axios . get (
2026-03-14 20:05:05 -05:00
"http://localhost:30001/host/db/host/internal/all" ,
2025-12-31 22:20:12 -06:00
{
headers : {
"Content-Type" : "application/json" ,
"X-Internal-Auth-Token" : internalAuthToken ,
},
},
);
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const allHosts : SSHHost [] = allHostsResponse . data || [];
const endpointHost = allHosts . find (
( h ) =>
h . name === tunnelConfig . endpointHost ||
` ${ h . username } @ ${ h . ip } ` === tunnelConfig . endpointHost ,
);
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
if ( ! endpointHost ) {
throw new Error (
`Endpoint host ' ${ tunnelConfig . endpointHost } ' not found in database` ,
);
}
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
tunnelConfig . endpointIP = endpointHost . ip ;
tunnelConfig . endpointSSHPort = endpointHost . port ;
tunnelConfig . endpointUsername = endpointHost . username ;
tunnelConfig . endpointPassword = endpointHost . password ;
tunnelConfig . endpointAuthMethod = endpointHost . authType ;
tunnelConfig . endpointSSHKey = endpointHost . key ;
tunnelConfig . endpointKeyPassword = endpointHost . keyPassword ;
tunnelConfig . endpointKeyType = endpointHost . keyType ;
tunnelConfig . endpointCredentialId = endpointHost . credentialId ;
tunnelConfig . endpointUserId = endpointHost . userId ;
} catch ( resolveError ) {
tunnelLogger . error (
"Failed to resolve endpoint host" ,
resolveError ,
{
operation : "tunnel_connect_resolve_endpoint_failed" ,
tunnelName ,
endpointHost : tunnelConfig.endpointHost ,
},
);
throw new Error (
`Failed to resolve endpoint host: ${ resolveError instanceof Error ? resolveError . message : "Unknown error" } ` ,
);
}
}
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
tunnelConfigs . set ( tunnelName , tunnelConfig );
await connectSSHTunnel ( tunnelConfig , 0 );
})();
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
pendingTunnelOperations . set ( tunnelName , operation );
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
res . json ({ message : "Connection request received" , tunnelName });
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
operation . finally (() => {
pendingTunnelOperations . delete ( tunnelName );
});
} catch ( error ) {
tunnelLogger . error ( "Failed to process tunnel connect" , error , {
operation : "tunnel_connect" ,
tunnelName ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to connect tunnel" });
}
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /ssh/tunnel/disconnect:
* post:
* summary: Disconnect SSH tunnel
* description: Disconnects an active SSH tunnel.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Disconnect request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to disconnect tunnel.
*/
2025-12-31 22:20:12 -06:00
app . post (
"/ssh/tunnel/disconnect" ,
authenticateJWT ,
async ( req : AuthenticatedRequest , res : Response ) => {
const { tunnelName } = req . body ;
const userId = req . userId ;
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
if ( ! userId ) {
return res . status ( 401 ). json ({ error : "Authentication required" });
}
2025-10-01 15:40:10 -05:00
2025-12-31 22:20:12 -06:00
if ( ! tunnelName ) {
return res . status ( 400 ). json ({ error : "Tunnel name required" });
}
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
try {
const config = tunnelConfigs . get ( tunnelName );
if ( config && config . sourceHostId ) {
const accessInfo = await permissionManager . canAccessHost (
userId ,
config . sourceHostId ,
"read" ,
);
if ( ! accessInfo . hasAccess ) {
return res . status ( 403 ). json ({ error : "Access denied" });
}
}
2025-08-07 02:20:27 -05:00
2026-02-12 22:28:13 -06:00
tunnelLogger . info ( "Tunnel stop request received" , {
operation : "tunnel_stop_request" ,
userId ,
hostId : config?.sourceHostId ,
tunnelName ,
});
2025-12-31 22:20:12 -06:00
manualDisconnects . add ( tunnelName );
retryCounters . delete ( tunnelName );
retryExhaustedTunnels . delete ( tunnelName );
2025-08-07 02:20:27 -05:00
2025-12-31 22:20:12 -06:00
if ( activeRetryTimers . has ( tunnelName )) {
clearTimeout ( activeRetryTimers . get ( tunnelName ) ! );
activeRetryTimers . delete ( tunnelName );
}
await cleanupTunnelResources ( tunnelName , true );
2026-02-12 22:28:13 -06:00
tunnelLogger . info ( "Tunnel cleanup completed" , {
operation : "tunnel_cleanup_complete" ,
userId ,
hostId : config?.sourceHostId ,
tunnelName ,
});
2025-12-31 22:20:12 -06:00
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.DISCONNECTED ,
manualDisconnect : true ,
});
const tunnelConfig = tunnelConfigs . get ( tunnelName ) || null ;
handleDisconnect ( tunnelName , tunnelConfig , false );
setTimeout (() => {
manualDisconnects . delete ( tunnelName );
}, 5000 );
res . json ({ message : "Disconnect request received" , tunnelName });
} catch ( error ) {
tunnelLogger . error ( "Failed to disconnect tunnel" , error , {
operation : "tunnel_disconnect" ,
tunnelName ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to disconnect tunnel" });
}
},
);
2026-01-24 19:49:42 -06:00
/**
* @openapi
* /ssh/tunnel/cancel:
* post:
* summary: Cancel tunnel retry
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Cancel request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to cancel tunnel retry.
*/
2025-12-31 22:20:12 -06:00
app . post (
"/ssh/tunnel/cancel" ,
authenticateJWT ,
async ( req : AuthenticatedRequest , res : Response ) => {
const { tunnelName } = req . body ;
const userId = req . userId ;
if ( ! userId ) {
return res . status ( 401 ). json ({ error : "Authentication required" });
}
if ( ! tunnelName ) {
return res . status ( 400 ). json ({ error : "Tunnel name required" });
}
try {
const config = tunnelConfigs . get ( tunnelName );
if ( config && config . sourceHostId ) {
const accessInfo = await permissionManager . canAccessHost (
userId ,
config . sourceHostId ,
"read" ,
);
if ( ! accessInfo . hasAccess ) {
return res . status ( 403 ). json ({ error : "Access denied" });
}
}
retryCounters . delete ( tunnelName );
retryExhaustedTunnels . delete ( tunnelName );
if ( activeRetryTimers . has ( tunnelName )) {
clearTimeout ( activeRetryTimers . get ( tunnelName ) ! );
activeRetryTimers . delete ( tunnelName );
}
if ( countdownIntervals . has ( tunnelName )) {
clearInterval ( countdownIntervals . get ( tunnelName ) ! );
countdownIntervals . delete ( tunnelName );
}
await cleanupTunnelResources ( tunnelName , true );
broadcastTunnelStatus ( tunnelName , {
connected : false ,
status : CONNECTION_STATES.DISCONNECTED ,
manualDisconnect : true ,
});
const tunnelConfig = tunnelConfigs . get ( tunnelName ) || null ;
handleDisconnect ( tunnelName , tunnelConfig , false );
setTimeout (() => {
manualDisconnects . delete ( tunnelName );
}, 5000 );
res . json ({ message : "Cancel request received" , tunnelName });
} catch ( error ) {
tunnelLogger . error ( "Failed to cancel tunnel retry" , error , {
operation : "tunnel_cancel" ,
tunnelName ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to cancel tunnel retry" });
}
},
);
2025-08-07 02:20:27 -05:00
async function initializeAutoStartTunnels () : Promise < void > {
2025-09-12 14:42:00 -05:00
try {
2025-10-01 15:40:10 -05:00
const systemCrypto = SystemCrypto . getInstance ();
const internalAuthToken = await systemCrypto . getInternalAuthToken ();
const autostartResponse = await axios . get (
2026-03-14 20:05:05 -05:00
"http://localhost:30001/host/db/host/internal" ,
2025-09-12 14:42:00 -05:00
{
headers : {
"Content-Type" : "application/json" ,
2025-10-01 15:40:10 -05:00
"X-Internal-Auth-Token" : internalAuthToken ,
2025-09-12 14:42:00 -05:00
},
},
);
2025-08-07 02:20:27 -05:00
2025-10-01 15:40:10 -05:00
const allHostsResponse = await axios . get (
2026-03-14 20:05:05 -05:00
"http://localhost:30001/host/db/host/internal/all" ,
2025-10-01 15:40:10 -05:00
{
headers : {
"Content-Type" : "application/json" ,
"X-Internal-Auth-Token" : internalAuthToken ,
},
},
);
const autostartHosts : SSHHost [] = autostartResponse . data || [];
const allHosts : SSHHost [] = allHostsResponse . data || [];
2025-09-12 14:42:00 -05:00
const autoStartTunnels : TunnelConfig [] = [];
2025-10-01 15:40:10 -05:00
tunnelLogger . info (
`Found ${ autostartHosts . length } autostart hosts and ${ allHosts . length } total hosts for endpointHost resolution` ,
);
for ( const host of autostartHosts ) {
2025-09-12 14:42:00 -05:00
if ( host . enableTunnel && host . tunnelConnections ) {
for ( const tunnelConnection of host . tunnelConnections ) {
if ( tunnelConnection . autoStart ) {
2025-10-01 15:40:10 -05:00
const endpointHost = allHosts . find (
2025-09-12 14:42:00 -05:00
( h ) =>
h . name === tunnelConnection . endpointHost ||
` ${ h . username } @ ${ h . ip } ` === tunnelConnection . endpointHost ,
);
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( endpointHost ) {
2025-12-31 22:20:12 -06:00
const tunnelIndex =
host . tunnelConnections . indexOf ( tunnelConnection );
2025-09-12 14:42:00 -05:00
const tunnelConfig : TunnelConfig = {
2025-12-31 22:20:12 -06:00
name : normalizeTunnelName (
host . id ,
tunnelIndex ,
host . name || ` ${ host . username } @ ${ host . ip } ` ,
tunnelConnection . sourcePort ,
tunnelConnection . endpointHost ,
tunnelConnection . endpointPort ,
),
2026-01-24 19:49:42 -06:00
tunnelType : tunnelConnection.tunnelType || "remote" ,
2025-12-31 22:20:12 -06:00
sourceHostId : host.id ,
tunnelIndex : tunnelIndex ,
2025-09-12 14:42:00 -05:00
hostName : host.name || ` ${ host . username } @ ${ host . ip } ` ,
sourceIP : host.ip ,
sourceSSHPort : host.port ,
sourceUsername : host.username ,
2025-10-01 15:40:10 -05:00
sourcePassword : host.autostartPassword || host . password ,
2025-09-12 14:42:00 -05:00
sourceAuthMethod : host.authType ,
2025-10-01 15:40:10 -05:00
sourceSSHKey : host.autostartKey || host . key ,
sourceKeyPassword :
host.autostartKeyPassword || host . keyPassword ,
2025-09-12 14:42:00 -05:00
sourceKeyType : host.keyType ,
2025-10-01 15:40:10 -05:00
sourceCredentialId : host.credentialId ,
sourceUserId : host.userId ,
2025-09-12 14:42:00 -05:00
endpointIP : endpointHost.ip ,
endpointSSHPort : endpointHost.port ,
endpointUsername : endpointHost.username ,
2025-12-31 22:20:12 -06:00
endpointHost : tunnelConnection.endpointHost ,
2025-10-01 15:40:10 -05:00
endpointPassword :
tunnelConnection.endpointPassword ||
endpointHost . autostartPassword ||
endpointHost . password ,
endpointAuthMethod :
tunnelConnection.endpointAuthType || endpointHost . authType ,
endpointSSHKey :
tunnelConnection.endpointKey ||
endpointHost . autostartKey ||
endpointHost . key ,
endpointKeyPassword :
tunnelConnection.endpointKeyPassword ||
endpointHost . autostartKeyPassword ||
endpointHost . keyPassword ,
endpointKeyType :
tunnelConnection.endpointKeyType || endpointHost . keyType ,
endpointCredentialId : endpointHost.credentialId ,
endpointUserId : endpointHost.userId ,
2025-09-12 14:42:00 -05:00
sourcePort : tunnelConnection.sourcePort ,
endpointPort : tunnelConnection.endpointPort ,
maxRetries : tunnelConnection.maxRetries ,
retryInterval : tunnelConnection.retryInterval * 1000 ,
autoStart : tunnelConnection.autoStart ,
isPinned : host.pin ,
2025-12-31 22:20:12 -06:00
useSocks5 : host.useSocks5 ,
socks5Host : host.socks5Host ,
socks5Port : host.socks5Port ,
socks5Username : host.socks5Username ,
socks5Password : host.socks5Password ,
2025-09-12 14:42:00 -05:00
};
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
autoStartTunnels . push ( tunnelConfig );
2025-10-01 15:40:10 -05:00
} else {
tunnelLogger . error (
`Failed to find endpointHost ' ${ tunnelConnection . endpointHost } ' for tunnel from ${ host . name || ` ${ host . username } @ ${ host . ip } ` } . Available hosts: ${ allHosts . map (( h ) => h . name || ` ${ h . username } @ ${ h . ip } ` ). join ( ", " ) } ` ,
);
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
}
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
}
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
for ( const tunnelConfig of autoStartTunnels ) {
tunnelConfigs . set ( tunnelConfig . name , tunnelConfig );
setTimeout (() => {
connectSSHTunnel ( tunnelConfig , 0 ). catch (( error ) => {
tunnelLogger . error (
`Failed to connect tunnel ${ tunnelConfig . name } : ${ error instanceof Error ? error . message : "Unknown error" } ` ,
);
});
}, 1000 );
}
2025-11-05 10:36:16 -06:00
} catch ( error ) {
2025-09-12 14:42:00 -05:00
tunnelLogger . error (
"Failed to initialize auto-start tunnels:" ,
2025-11-05 10:36:16 -06:00
error instanceof Error ? error . message : "Unknown error" ,
2025-09-12 14:42:00 -05:00
);
}
2025-08-07 02:20:27 -05:00
}
2025-10-01 15:40:10 -05:00
const PORT = 30003 ;
2025-08-07 02:20:27 -05:00
app . listen ( PORT , () => {
2025-09-12 14:42:00 -05:00
setTimeout (() => {
initializeAutoStartTunnels ();
}, 2000 );
});