2025-11-05 10:36:16 -06:00
import type { AuthenticatedRequest } from "../../../types/index.js" ;
2025-09-12 14:42:00 -05:00
import express from "express" ;
2025-11-05 10:36:16 -06:00
import type { Request , Response } from "express" ;
2026-05-06 15:12:07 -05:00
import axios from "axios" ;
2025-09-12 14:42:00 -05:00
import multer from "multer" ;
2026-02-12 22:28:13 -06:00
import { sshLogger , databaseLogger } from "../../utils/logger.js" ;
2025-10-01 15:40:10 -05:00
import { AuthManager } from "../../utils/auth-manager.js" ;
2025-12-31 22:20:12 -06:00
import { PermissionManager } from "../../utils/permission-manager.js" ;
2025-10-01 15:40:10 -05:00
import { DataCrypto } from "../../utils/data-crypto.js" ;
2026-01-24 19:49:42 -06:00
import { parseSSHKey } from "../../utils/ssh-key-utils.js" ;
2026-07-19 12:29:52 -05:00
import {
pickResolvedPassword ,
pickResolvedUsername ,
} from "../../hosts/credential-username.js" ;
import {
createCurrentCommandHistoryRepository ,
2026-07-26 18:47:27 -05:00
createCurrentCredentialRepository ,
2026-07-19 12:29:52 -05:00
createCurrentFileManagerBookmarkRepository ,
createCurrentOpksshTokenRepository ,
createCurrentRecentActivityRepository ,
createCurrentSshCredentialUsageRepository ,
createCurrentSessionRecordingRepository ,
createCurrentTransferRecentRepository ,
createCurrentRbacAccessRepository ,
createCurrentRoleRepository ,
createCurrentHostResolutionRepository ,
createCurrentHostRepository ,
createCurrentUserRepository ,
2026-07-26 18:47:27 -05:00
createCurrentSyncTombstoneRepository ,
2026-07-19 12:29:52 -05:00
} from "../repositories/factory.js" ;
2026-06-04 15:16:53 -04:00
import {
isNonEmptyString ,
isValidPort ,
2026-07-19 12:29:52 -05:00
sanitizeHostForRecipient ,
2026-06-04 15:16:53 -04:00
stripSensitiveFields ,
transformHostResponse ,
} from "./host-normalizers.js" ;
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js" ;
import { registerHostFolderRoutes } from "./host-folder-routes.js" ;
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js" ;
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js" ;
import { registerHostAutostartRoutes } from "./host-autostart-routes.js" ;
import { registerHostInternalRoutes } from "./host-internal-routes.js" ;
import { registerHostNetworkRoutes } from "./host-network-routes.js" ;
import { registerHostBulkRoutes } from "./host-bulk-routes.js" ;
2026-07-19 12:29:52 -05:00
import {
applyHostEnrollmentDefaults ,
requireHostEnrollmentAccessForPath ,
} from "./host-enrollment-auth.js" ;
2026-06-16 15:59:53 -05:00
import { logAudit , getRequestMeta } from "../../utils/audit-logger.js" ;
2025-08-07 02:20:27 -05:00
const router = express . Router ();
2025-09-12 14:42:00 -05:00
const upload = multer ({ storage : multer.memoryStorage () });
2025-08-07 02:20:27 -05:00
2026-05-28 22:29:20 -05:00
const STATS_SERVER_URL = "http://localhost:30005" ;
2026-07-19 12:29:52 -05:00
async function getAuditUsername ( userId : string ) : Promise < string > {
const actor = await createCurrentUserRepository (). findById ( userId );
return actor ? . username ?? userId ;
}
2026-05-06 15:12:07 -05:00
function notifyStatsHostUpdated (
hostId : number ,
headers : Pick < Request [ "headers" ] , "authorization" | "cookie" >,
operation : string ,
) : void {
axios
. post (
2026-05-28 22:29:20 -05:00
` ${ STATS_SERVER_URL } /host-updated` ,
2026-05-06 15:12:07 -05:00
{ hostId },
{
headers : {
Authorization : headers.authorization || "" ,
Cookie : headers.cookie || "" ,
},
timeout : 5000 ,
},
)
. catch (( err ) => {
sshLogger . warn ( "Failed to notify stats server of host update" , {
operation ,
hostId ,
error : err instanceof Error ? err.message : String ( err ),
});
});
}
2025-10-01 15:40:10 -05:00
const authManager = AuthManager . getInstance ();
2025-12-31 22:20:12 -06:00
const permissionManager = PermissionManager . getInstance ();
2025-10-01 15:40:10 -05:00
const authenticateJWT = authManager . createAuthMiddleware ();
const requireDataAccess = authManager . createDataAccessMiddleware ();
2025-08-07 02:20:27 -05:00
2026-06-04 15:16:53 -04:00
registerHostInternalRoutes ( router );
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host:
2026-01-24 19:49:42 -06:00
* post:
* summary: Create SSH host
* description: Creates a new SSH host configuration.
* tags:
* - SSH
* responses:
* 200:
* description: Host created successfully.
* 400:
* description: Invalid SSH data.
* 500:
* description: Failed to save SSH data.
*/
2025-09-12 14:42:00 -05:00
router . post (
2026-07-19 12:29:52 -05:00
[ "/db/host" , "/enroll" ],
2025-09-12 14:42:00 -05:00
authenticateJWT ,
2025-10-01 15:40:10 -05:00
requireDataAccess ,
2026-07-19 12:29:52 -05:00
requireHostEnrollmentAccessForPath ,
2025-09-12 14:42:00 -05:00
upload . single ( "key" ),
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
let hostData : Record < string , unknown >;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . headers [ "content-type" ] ? . includes ( "multipart/form-data" )) {
if ( req . body . data ) {
try {
hostData = JSON . parse ( req . body . data );
} catch ( err ) {
sshLogger . warn ( "Invalid JSON data in multipart request" , {
operation : "host_create" ,
userId ,
error : err ,
});
return res . status ( 400 ). json ({ error : "Invalid JSON data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
} else {
sshLogger . warn ( "Missing data field in multipart request" , {
operation : "host_create" ,
userId ,
});
return res . status ( 400 ). json ({ error : "Missing data field" });
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . file ) {
hostData . key = req . file . buffer . toString ( "utf8" );
}
2025-08-07 02:20:27 -05:00
} else {
2025-09-12 14:42:00 -05:00
hostData = req . body ;
2025-08-07 02:20:27 -05:00
}
2026-07-19 12:29:52 -05:00
if ( req . path === "/enroll" ) {
hostData = applyHostEnrollmentDefaults ( hostData );
}
2025-08-07 02:20:27 -05:00
const {
2026-03-14 20:05:05 -05:00
connectionType ,
2025-09-12 14:42:00 -05:00
name ,
folder ,
tags ,
ip ,
port ,
username ,
password ,
authMethod ,
authType ,
2026-06-21 15:55:41 -05:00
useWarpgate ,
2025-09-12 14:42:00 -05:00
credentialId ,
2026-06-29 13:28:26 -05:00
vaultProfileId ,
2025-09-12 14:42:00 -05:00
key ,
2025-10-08 23:55:33 -05:00
keyPassword ,
2025-09-12 14:42:00 -05:00
keyType ,
2025-12-31 22:20:12 -06:00
sudoPassword ,
2025-09-12 14:42:00 -05:00
pin ,
enableTerminal ,
enableTunnel ,
enableFileManager ,
2026-06-21 15:55:41 -05:00
scpLegacy ,
2025-12-31 22:20:12 -06:00
enableDocker ,
2026-06-16 15:59:53 -05:00
enableProxmox ,
enableTmuxMonitor ,
2026-07-26 18:47:27 -05:00
allowSessionSharing ,
2026-01-24 19:49:42 -06:00
showTerminalInSidebar ,
showFileManagerInSidebar ,
showTunnelInSidebar ,
showDockerInSidebar ,
showServerStatsInSidebar ,
2025-09-12 14:42:00 -05:00
defaultPath ,
tunnelConnections ,
2025-11-17 09:46:05 -06:00
jumpHosts ,
quickActions ,
2025-11-05 10:36:16 -06:00
statsConfig ,
2026-03-14 20:05:05 -05:00
dockerConfig ,
2026-06-16 15:59:53 -05:00
proxmoxConfig ,
2025-11-05 10:36:16 -06:00
terminalConfig ,
forceKeyboardInteractive ,
2026-03-14 20:05:05 -05:00
domain ,
security ,
ignoreCert ,
guacamoleConfig ,
2025-12-31 22:20:12 -06:00
notes ,
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
socks5ProxyChain ,
2026-07-26 18:47:27 -05:00
connectionOrigin ,
2026-04-22 16:55:23 -05:00
portKnockSequence ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername ,
2026-04-22 16:55:23 -05:00
macAddress ,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress ,
2026-05-11 01:23:11 -05:00
enableSsh ,
enableRdp ,
enableVnc ,
enableTelnet ,
sshPort ,
rdpPort ,
vncPort ,
telnetPort ,
2026-07-19 12:29:52 -05:00
rdpAuthType ,
rdpCredentialId ,
2026-05-11 01:23:11 -05:00
rdpUser ,
rdpPassword ,
rdpDomain ,
rdpSecurity ,
rdpIgnoreCert ,
2026-06-29 13:28:26 -05:00
vncAuthType ,
vncCredentialId ,
2026-05-11 01:23:11 -05:00
vncPassword ,
vncUser ,
2026-07-19 12:29:52 -05:00
telnetAuthType ,
telnetCredentialId ,
2026-05-11 01:23:11 -05:00
telnetUser ,
telnetPassword ,
2025-08-07 02:20:27 -05:00
} = hostData ;
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Creating SSH host" , {
operation : "host_create" ,
userId ,
name ,
ip ,
});
2025-12-31 22:20:12 -06:00
2025-09-12 14:42:00 -05:00
if (
! isNonEmptyString ( userId ) ||
! isNonEmptyString ( ip ) ||
! isValidPort ( port )
) {
sshLogger . warn ( "Invalid SSH data input validation failed" , {
operation : "host_create" ,
userId ,
hasIp : !! ip ,
port ,
isValidPort : isValidPort ( port ),
});
return res . status ( 400 ). json ({ error : "Invalid SSH data" });
2025-08-07 02:20:27 -05:00
}
2026-03-14 20:05:05 -05:00
const effectiveConnectionType = connectionType || "ssh" ;
2026-04-22 16:55:23 -05:00
const effectiveAuthType =
authType ||
authMethod ||
( effectiveConnectionType !== "ssh" ? "password" : undefined );
2026-05-28 22:29:20 -05:00
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "" ;
const effectiveName =
name || ( effectiveUsername ? ` ${ effectiveUsername } @ ${ ip } ` : String ( ip ));
2025-11-05 10:36:16 -06:00
const sshDataObj : Record < string , unknown > = {
2025-09-12 14:42:00 -05:00
userId : userId ,
2026-03-14 20:05:05 -05:00
connectionType : effectiveConnectionType ,
2026-05-28 22:29:20 -05:00
name : effectiveName ,
2025-09-12 14:42:00 -05:00
folder : folder || null ,
tags : Array.isArray ( tags ) ? tags . join ( "," ) : tags || "" ,
ip ,
port ,
2026-05-28 22:29:20 -05:00
username : effectiveUsername ,
2025-09-12 14:42:00 -05:00
authType : effectiveAuthType ,
2026-06-21 15:55:41 -05:00
useWarpgate : useWarpgate ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
credentialId : credentialId || null ,
2026-06-29 13:28:26 -05:00
vaultProfileId : vaultProfileId || null ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername : overrideCredentialUsername ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
pin : pin ? 1 : 0 ,
enableTerminal : enableTerminal ? 1 : 0 ,
enableTunnel : enableTunnel ? 1 : 0 ,
tunnelConnections : Array.isArray ( tunnelConnections )
? JSON . stringify ( tunnelConnections )
: null ,
2025-11-17 09:46:05 -06:00
jumpHosts : Array.isArray ( jumpHosts ) ? JSON . stringify ( jumpHosts ) : null ,
quickActions : Array.isArray ( quickActions )
? JSON . stringify ( quickActions )
: null ,
2025-09-12 14:42:00 -05:00
enableFileManager : enableFileManager ? 1 : 0 ,
2026-06-21 15:55:41 -05:00
scpLegacy : scpLegacy ? 1 : 0 ,
2025-12-31 22:20:12 -06:00
enableDocker : enableDocker ? 1 : 0 ,
2026-06-16 15:59:53 -05:00
enableProxmox : enableProxmox ? 1 : 0 ,
enableTmuxMonitor : enableTmuxMonitor ? 1 : 0 ,
2026-07-26 18:47:27 -05:00
allowSessionSharing : allowSessionSharing === false ? 0 : 1 ,
2026-01-24 19:49:42 -06:00
showTerminalInSidebar : showTerminalInSidebar ? 1 : 0 ,
showFileManagerInSidebar : showFileManagerInSidebar ? 1 : 0 ,
showTunnelInSidebar : showTunnelInSidebar ? 1 : 0 ,
showDockerInSidebar : showDockerInSidebar ? 1 : 0 ,
showServerStatsInSidebar : showServerStatsInSidebar ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
defaultPath : defaultPath || null ,
2026-03-08 18:02:14 -05:00
statsConfig : statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify ( statsConfig )
: null ,
2026-03-14 20:05:05 -05:00
dockerConfig : dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
: JSON.stringify ( dockerConfig )
: null ,
2026-06-16 15:59:53 -05:00
proxmoxConfig : proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify ( proxmoxConfig )
: null ,
2026-03-08 18:02:14 -05:00
terminalConfig : terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify ( terminalConfig )
: null ,
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive : forceKeyboardInteractive ? "true" : "false" ,
2026-03-14 20:05:05 -05:00
domain : domain || null ,
security : security || null ,
ignoreCert : ignoreCert ? 1 : 0 ,
guacamoleConfig : guacamoleConfig ? JSON . stringify ( guacamoleConfig ) : null ,
2025-12-31 22:20:12 -06:00
notes : notes || null ,
sudoPassword : sudoPassword || null ,
useSocks5 : useSocks5 ? 1 : 0 ,
socks5Host : socks5Host || null ,
socks5Port : socks5Port || null ,
socks5Username : socks5Username || null ,
socks5Password : socks5Password || null ,
socks5ProxyChain : socks5ProxyChain
? JSON . stringify ( socks5ProxyChain )
: null ,
2026-07-26 18:47:27 -05:00
connectionOrigin :
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null ,
2026-04-22 16:55:23 -05:00
macAddress : macAddress || null ,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress : wolBroadcastAddress || null ,
2026-04-22 16:55:23 -05:00
portKnockSequence : portKnockSequence
? JSON . stringify ( portKnockSequence )
: null ,
2026-05-28 22:29:20 -05:00
enableSsh : enableSsh ? 1 : 0 ,
2026-05-11 01:23:11 -05:00
enableRdp : enableRdp ? 1 : 0 ,
enableVnc : enableVnc ? 1 : 0 ,
enableTelnet : enableTelnet ? 1 : 0 ,
sshPort : sshPort || port || 22 ,
rdpPort : rdpPort || 3389 ,
vncPort : vncPort || 5900 ,
telnetPort : telnetPort || 23 ,
2026-07-19 12:29:52 -05:00
rdpAuthType : enableRdp ? rdpAuthType || null : null ,
rdpCredentialId :
enableRdp && rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null ,
2026-05-11 01:23:11 -05:00
rdpUser : rdpUser || null ,
rdpDomain : rdpDomain || null ,
rdpSecurity : rdpSecurity || null ,
rdpIgnoreCert : rdpIgnoreCert ? 1 : 0 ,
2026-06-29 13:28:26 -05:00
vncAuthType : enableVnc ? vncAuthType || null : null ,
vncCredentialId :
enableVnc && vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null ,
2026-05-11 01:23:11 -05:00
vncUser : vncUser || null ,
2026-07-19 12:29:52 -05:00
telnetAuthType : enableTelnet ? telnetAuthType || null : null ,
telnetCredentialId :
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null ,
2026-05-11 01:23:11 -05:00
telnetUser : telnetUser || null ,
2025-08-07 02:20:27 -05:00
};
2026-03-14 20:05:05 -05:00
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
if ( effectiveConnectionType !== "ssh" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "password" ) {
2025-09-12 14:42:00 -05:00
sshDataObj . password = password || null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "key" ) {
2026-01-24 19:49:42 -06:00
if ( key && typeof key === "string" ) {
const keyValidation = parseSSHKey (
key ,
typeof keyPassword === "string" ? keyPassword : undefined ,
);
if ( ! keyValidation . success ) {
sshLogger . warn ( "SSH key validation failed" , {
operation : "host_create" ,
userId ,
name ,
ip ,
port ,
error : keyValidation.error ,
});
return res . status ( 400 ). json ({
error : `Invalid SSH key: ${ keyValidation . error || "Unable to parse key" } ` ,
});
}
}
2025-09-12 14:42:00 -05:00
sshDataObj . key = key || null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = keyPassword || null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = keyType ;
2026-07-19 12:29:52 -05:00
sshDataObj . password = password || null ;
} else if ( effectiveAuthType === "credential" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2026-06-29 13:28:26 -05:00
} else if ( effectiveAuthType === "agent" ) {
sshDataObj . password = null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2025-10-01 15:40:10 -05:00
} else {
sshDataObj . password = null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-10-01 15:40:10 -05:00
sshDataObj . keyType = null ;
2025-08-07 02:20:27 -05:00
}
2026-05-11 01:23:11 -05:00
sshDataObj . rdpPassword = rdpPassword || null ;
sshDataObj . vncPassword = vncPassword || null ;
sshDataObj . telnetPassword = telnetPassword || null ;
2025-08-07 02:20:27 -05:00
try {
2026-07-19 12:29:52 -05:00
const result = await createCurrentHostRepository (). createEncryptedForUser (
2025-10-01 15:40:10 -05:00
userId ,
2026-07-19 12:29:52 -05:00
sshDataObj ,
2025-10-01 15:40:10 -05:00
);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
if ( ! result ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "No host returned after creation" , {
operation : "host_create" ,
userId ,
name ,
ip ,
port ,
});
return res . status ( 500 ). json ({ error : "Failed to create host" });
}
2025-10-01 15:40:10 -05:00
const createdHost = result ;
2026-01-26 00:28:40 -06:00
const baseHost = transformHostResponse ( createdHost );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolvedHost =
( await resolveHostCredentials ( baseHost , userId )) || baseHost ;
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host created" , {
operation : "host_create_success" ,
userId ,
hostId : createdHost.id as number ,
name ,
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : chIp , userAgent : chUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "create_host" ,
resourceType : "host" ,
resourceId : String ( createdHost . id ),
resourceName : String ( name ?? ip ),
ipAddress : chIp ,
userAgent : chUa ,
success : true ,
});
2025-09-12 14:42:00 -05:00
res . json ( resolvedHost );
2026-05-06 15:12:07 -05:00
notifyStatsHostUpdated (
createdHost . id as number ,
req . headers ,
"host_create" ,
);
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
sshLogger . error ( "Failed to save SSH host to database" , err , {
operation : "host_create" ,
userId ,
name ,
ip ,
port ,
authType : effectiveAuthType ,
});
res . status ( 500 ). json ({ error : "Failed to save SSH data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
/**
* @openapi
* /host/enroll:
* post:
* summary: Enroll a host with an API key
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
* tags:
* - Host Enrollment
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [ip]
* properties:
* name:
* type: string
* ip:
* type: string
* port:
* type: integer
* minimum: 1
* maximum: 65535
* default: 22
* username:
* type: string
* authType:
* type: string
* enum: [none, password, key, credential, agent]
* default: none
* password:
* type: string
* folder:
* type: string
* tags:
* oneOf:
* - type: string
* - type: array
* items:
* type: string
* enableTerminal:
* type: boolean
* enableFileManager:
* type: boolean
* enableTunnel:
* type: boolean
* responses:
* 200:
* description: Host enrolled successfully.
* 400:
* description: Invalid host data.
* 401:
* description: Missing or invalid API key.
* 423:
* description: The API key user's encrypted data is locked.
* 500:
* description: Failed to enroll the host.
*/
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/quick-connect:
2026-01-24 19:49:42 -06:00
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 403:
* description: Forbidden
* 404:
* description: Credential not found
* 500:
* description: Server error
*/
router . post (
"/quick-connect" ,
authenticateJWT ,
requireDataAccess ,
async ( req : AuthenticatedRequest , res : Response ) => {
const userId = req . userId ;
const {
ip ,
port ,
username ,
authType ,
password ,
key ,
keyPassword ,
keyType ,
credentialId ,
overrideCredentialUsername ,
} = req . body ;
if (
! isNonEmptyString ( ip ) ||
! isValidPort ( port ) ||
! isNonEmptyString ( username ) ||
! authType
) {
return res . status ( 400 ). json ({ error : "Missing required fields" });
}
try {
let resolvedPassword = password ;
let resolvedKey = key ;
let resolvedKeyPassword = keyPassword ;
let resolvedKeyType = keyType ;
let resolvedAuthType = authType ;
let resolvedUsername = username ;
if ( authType === "credential" && credentialId ) {
2026-07-19 12:29:52 -05:00
const cred =
await createCurrentHostResolutionRepository (). findCredentialByIdForUser (
Number ( credentialId ),
userId ,
);
2026-01-24 19:49:42 -06:00
2026-07-19 12:29:52 -05:00
if ( ! cred ) {
2026-01-24 19:49:42 -06:00
return res . status ( 404 ). json ({ error : "Credential not found" });
}
2026-07-19 12:29:52 -05:00
resolvedPassword = pickResolvedPassword ( password , cred . password ) as
| string
| undefined ;
2026-03-08 18:02:14 -05:00
resolvedKey = cred . privateKey as string | undefined ;
resolvedKeyPassword = cred . keyPassword as string | undefined ;
resolvedKeyType = cred . keyType as string | undefined ;
resolvedAuthType = cred . authType as string | undefined ;
2026-01-24 19:49:42 -06:00
if ( ! overrideCredentialUsername ) {
resolvedUsername = cred . username as string ;
}
}
const tempHost : Record < string , unknown > = {
id : - Date . now (),
userId : userId ,
name : ` ${ resolvedUsername } @ ${ ip } : ${ port } ` ,
ip ,
port : Number ( port ),
username : resolvedUsername ,
folder : "" ,
tags : [],
pin : false ,
authType : resolvedAuthType || authType ,
password : resolvedPassword ,
key : resolvedKey ,
keyPassword : resolvedKeyPassword ,
keyType : resolvedKeyType ,
enableTerminal : true ,
enableTunnel : false ,
enableFileManager : true ,
enableDocker : false ,
2026-06-16 15:59:53 -05:00
enableProxmox : false ,
enableTmuxMonitor : false ,
2026-01-24 19:49:42 -06:00
showTerminalInSidebar : true ,
showFileManagerInSidebar : false ,
showTunnelInSidebar : false ,
showDockerInSidebar : false ,
showServerStatsInSidebar : false ,
defaultPath : "/" ,
tunnelConnections : [],
jumpHosts : [],
quickActions : [],
statsConfig : {},
notes : "" ,
createdAt : new Date (). toISOString (),
updatedAt : new Date (). toISOString (),
};
return res . status ( 200 ). json ( tempHost );
} catch ( error ) {
sshLogger . error ( "Quick connect failed" , error , {
operation : "quick_connect" ,
userId ,
ip ,
port ,
authType ,
});
return res
. status ( 500 )
. json ({ error : "Failed to create quick connection" });
}
},
);
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* put:
* summary: Update SSH host
* description: Updates an existing SSH host configuration.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Host updated successfully.
* 400:
* description: Invalid SSH data.
* 403:
* description: Access denied.
* 404:
* description: Host not found.
* 500:
* description: Failed to update SSH data.
*/
2025-09-12 14:42:00 -05:00
router . put (
"/db/host/:id" ,
authenticateJWT ,
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
upload . single ( "key" ),
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
let hostData : Record < string , unknown >;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . headers [ "content-type" ] ? . includes ( "multipart/form-data" )) {
if ( req . body . data ) {
try {
hostData = JSON . parse ( req . body . data );
} catch ( err ) {
sshLogger . warn ( "Invalid JSON data in multipart request" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
error : err ,
});
return res . status ( 400 ). json ({ error : "Invalid JSON data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
} else {
sshLogger . warn ( "Missing data field in multipart request" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 400 ). json ({ error : "Missing data field" });
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . file ) {
hostData . key = req . file . buffer . toString ( "utf8" );
}
2025-08-07 02:20:27 -05:00
} else {
2025-09-12 14:42:00 -05:00
hostData = req . body ;
2025-08-07 02:20:27 -05:00
}
const {
2026-03-14 20:05:05 -05:00
connectionType ,
2025-09-12 14:42:00 -05:00
name ,
folder ,
tags ,
ip ,
port ,
username ,
password ,
authMethod ,
authType ,
2026-06-21 15:55:41 -05:00
useWarpgate ,
2025-09-12 14:42:00 -05:00
credentialId ,
2026-06-29 13:28:26 -05:00
vaultProfileId ,
2025-09-12 14:42:00 -05:00
key ,
2025-10-08 23:55:33 -05:00
keyPassword ,
2025-09-12 14:42:00 -05:00
keyType ,
2025-12-31 22:20:12 -06:00
sudoPassword ,
2025-09-12 14:42:00 -05:00
pin ,
enableTerminal ,
enableTunnel ,
enableFileManager ,
2026-06-21 15:55:41 -05:00
scpLegacy ,
2025-12-31 22:20:12 -06:00
enableDocker ,
2026-06-16 15:59:53 -05:00
enableProxmox ,
enableTmuxMonitor ,
2026-07-26 18:47:27 -05:00
allowSessionSharing ,
2026-01-24 19:49:42 -06:00
showTerminalInSidebar ,
showFileManagerInSidebar ,
showTunnelInSidebar ,
showDockerInSidebar ,
showServerStatsInSidebar ,
2025-09-12 14:42:00 -05:00
defaultPath ,
tunnelConnections ,
2025-11-17 09:46:05 -06:00
jumpHosts ,
quickActions ,
2025-11-05 10:36:16 -06:00
statsConfig ,
2026-03-14 20:05:05 -05:00
dockerConfig ,
2026-06-16 15:59:53 -05:00
proxmoxConfig ,
2025-11-05 10:36:16 -06:00
terminalConfig ,
forceKeyboardInteractive ,
2026-03-14 20:05:05 -05:00
domain ,
security ,
ignoreCert ,
guacamoleConfig ,
2025-12-31 22:20:12 -06:00
notes ,
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
socks5ProxyChain ,
2026-07-26 18:47:27 -05:00
connectionOrigin ,
2026-04-22 16:55:23 -05:00
portKnockSequence ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername ,
2026-04-22 16:55:23 -05:00
macAddress ,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress ,
2026-05-11 01:23:11 -05:00
enableSsh ,
enableRdp ,
enableVnc ,
enableTelnet ,
sshPort ,
rdpPort ,
vncPort ,
telnetPort ,
2026-07-19 12:29:52 -05:00
rdpAuthType ,
rdpCredentialId ,
2026-05-11 01:23:11 -05:00
rdpUser ,
rdpPassword ,
rdpDomain ,
rdpSecurity ,
rdpIgnoreCert ,
2026-06-29 13:28:26 -05:00
vncAuthType ,
vncCredentialId ,
2026-05-11 01:23:11 -05:00
vncPassword ,
vncUser ,
2026-07-19 12:29:52 -05:00
telnetAuthType ,
telnetCredentialId ,
2026-05-11 01:23:11 -05:00
telnetUser ,
telnetPassword ,
2025-08-07 02:20:27 -05:00
} = hostData ;
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Updating SSH host" , {
operation : "host_update" ,
userId ,
hostId : parseInt ( hostId ),
changes : Object.keys ( hostData ),
});
2025-12-31 22:20:12 -06:00
2025-09-12 14:42:00 -05:00
if (
! isNonEmptyString ( userId ) ||
! isNonEmptyString ( ip ) ||
! isValidPort ( port ) ||
! hostId
) {
sshLogger . warn ( "Invalid SSH data input validation failed for update" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
hasIp : !! ip ,
port ,
isValidPort : isValidPort ( port ),
});
return res . status ( 400 ). json ({ error : "Invalid SSH data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
const effectiveAuthType = authType || authMethod ;
2026-05-28 22:29:20 -05:00
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "" ;
const effectiveName =
name || ( effectiveUsername ? ` ${ effectiveUsername } @ ${ ip } ` : String ( ip ));
2025-11-05 10:36:16 -06:00
const sshDataObj : Record < string , unknown > = {
2026-03-14 20:05:05 -05:00
connectionType : connectionType || "ssh" ,
2026-05-28 22:29:20 -05:00
name : effectiveName ,
2025-09-12 14:42:00 -05:00
folder ,
tags : Array.isArray ( tags ) ? tags . join ( "," ) : tags || "" ,
ip ,
port ,
2026-05-28 22:29:20 -05:00
username : effectiveUsername ,
2025-09-12 14:42:00 -05:00
authType : effectiveAuthType ,
2026-06-21 15:55:41 -05:00
useWarpgate : useWarpgate ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
credentialId : credentialId || null ,
2026-06-29 13:28:26 -05:00
vaultProfileId : vaultProfileId || null ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername : overrideCredentialUsername ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
pin : pin ? 1 : 0 ,
enableTerminal : enableTerminal ? 1 : 0 ,
enableTunnel : enableTunnel ? 1 : 0 ,
tunnelConnections : Array.isArray ( tunnelConnections )
? JSON . stringify ( tunnelConnections )
: null ,
2025-11-17 09:46:05 -06:00
jumpHosts : Array.isArray ( jumpHosts ) ? JSON . stringify ( jumpHosts ) : null ,
quickActions : Array.isArray ( quickActions )
? JSON . stringify ( quickActions )
: null ,
2025-09-12 14:42:00 -05:00
enableFileManager : enableFileManager ? 1 : 0 ,
2026-06-21 15:55:41 -05:00
scpLegacy : scpLegacy ? 1 : 0 ,
2025-12-31 22:20:12 -06:00
enableDocker : enableDocker ? 1 : 0 ,
2026-06-16 15:59:53 -05:00
enableProxmox : enableProxmox ? 1 : 0 ,
enableTmuxMonitor : enableTmuxMonitor ? 1 : 0 ,
2026-07-26 18:47:27 -05:00
allowSessionSharing : allowSessionSharing === false ? 0 : 1 ,
2026-01-24 19:49:42 -06:00
showTerminalInSidebar : showTerminalInSidebar ? 1 : 0 ,
showFileManagerInSidebar : showFileManagerInSidebar ? 1 : 0 ,
showTunnelInSidebar : showTunnelInSidebar ? 1 : 0 ,
showDockerInSidebar : showDockerInSidebar ? 1 : 0 ,
showServerStatsInSidebar : showServerStatsInSidebar ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
defaultPath : defaultPath || null ,
2026-03-08 18:02:14 -05:00
statsConfig : statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify ( statsConfig )
: null ,
2026-03-14 20:05:05 -05:00
dockerConfig : dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
: JSON.stringify ( dockerConfig )
: null ,
2026-06-16 15:59:53 -05:00
proxmoxConfig : proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify ( proxmoxConfig )
: null ,
2026-03-08 18:02:14 -05:00
terminalConfig : terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify ( terminalConfig )
: null ,
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive : forceKeyboardInteractive ? "true" : "false" ,
2026-03-14 20:05:05 -05:00
domain : domain || null ,
security : security || null ,
ignoreCert : ignoreCert ? 1 : 0 ,
guacamoleConfig : guacamoleConfig ? JSON . stringify ( guacamoleConfig ) : null ,
2025-12-31 22:20:12 -06:00
notes : notes || null ,
sudoPassword : sudoPassword || null ,
useSocks5 : useSocks5 ? 1 : 0 ,
socks5Host : socks5Host || null ,
socks5Port : socks5Port || null ,
socks5Username : socks5Username || null ,
socks5Password : socks5Password || null ,
socks5ProxyChain : socks5ProxyChain
? JSON . stringify ( socks5ProxyChain )
: null ,
2026-07-26 18:47:27 -05:00
connectionOrigin :
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null ,
2026-04-22 16:55:23 -05:00
macAddress : macAddress || null ,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress : wolBroadcastAddress || null ,
2026-04-22 16:55:23 -05:00
portKnockSequence : portKnockSequence
? JSON . stringify ( portKnockSequence )
: null ,
2026-05-28 22:29:20 -05:00
enableSsh : enableSsh ? 1 : 0 ,
2026-05-11 01:23:11 -05:00
enableRdp : enableRdp ? 1 : 0 ,
enableVnc : enableVnc ? 1 : 0 ,
enableTelnet : enableTelnet ? 1 : 0 ,
sshPort : sshPort || port || 22 ,
rdpPort : rdpPort || 3389 ,
vncPort : vncPort || 5900 ,
telnetPort : telnetPort || 23 ,
2026-07-19 12:29:52 -05:00
rdpAuthType : enableRdp ? rdpAuthType || null : null ,
rdpCredentialId :
enableRdp && rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null ,
2026-05-11 01:23:11 -05:00
rdpUser : rdpUser || null ,
rdpDomain : rdpDomain || null ,
rdpSecurity : rdpSecurity || null ,
rdpIgnoreCert : rdpIgnoreCert ? 1 : 0 ,
2026-06-29 13:28:26 -05:00
vncAuthType : enableVnc ? vncAuthType || null : null ,
vncCredentialId :
enableVnc && vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null ,
2026-05-11 01:23:11 -05:00
vncUser : vncUser || null ,
2026-07-19 12:29:52 -05:00
telnetAuthType : enableTelnet ? telnetAuthType || null : null ,
telnetCredentialId :
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null ,
2026-05-11 01:23:11 -05:00
telnetUser : telnetUser || null ,
2025-08-07 02:20:27 -05:00
};
2026-03-14 20:05:05 -05:00
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
if (( connectionType || "ssh" ) !== "ssh" ) {
if ( password ) {
sshDataObj . password = password ;
}
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "password" ) {
2025-09-12 14:42:00 -05:00
if ( password ) {
2025-08-07 02:20:27 -05:00
sshDataObj . password = password ;
2025-09-12 14:42:00 -05:00
}
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "key" ) {
2026-01-24 19:49:42 -06:00
if ( key && typeof key === "string" ) {
const keyValidation = parseSSHKey (
key ,
typeof keyPassword === "string" ? keyPassword : undefined ,
);
if ( ! keyValidation . success ) {
sshLogger . warn ( "SSH key validation failed" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
name ,
ip ,
port ,
error : keyValidation.error ,
});
return res . status ( 400 ). json ({
error : `Invalid SSH key: ${ keyValidation . error || "Unable to parse key" } ` ,
});
}
2025-08-07 02:20:27 -05:00
sshDataObj . key = key ;
2025-09-12 14:42:00 -05:00
}
2025-10-08 23:55:33 -05:00
if ( keyPassword !== undefined ) {
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = keyPassword || null ;
2025-09-12 14:42:00 -05:00
}
if ( keyType ) {
2025-08-07 02:20:27 -05:00
sshDataObj . keyType = keyType ;
2025-09-12 14:42:00 -05:00
}
2026-07-19 12:29:52 -05:00
sshDataObj . password = password || null ;
} else if ( effectiveAuthType === "credential" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2026-06-29 13:28:26 -05:00
} else if ( effectiveAuthType === "agent" ) {
sshDataObj . password = null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2025-10-01 15:40:10 -05:00
} else {
sshDataObj . password = null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-10-01 15:40:10 -05:00
sshDataObj . keyType = null ;
2025-08-07 02:20:27 -05:00
}
2026-06-21 15:55:41 -05:00
if ( rdpPassword ) sshDataObj . rdpPassword = rdpPassword ;
if ( vncPassword ) sshDataObj . vncPassword = vncPassword ;
if ( telnetPassword ) sshDataObj . telnetPassword = telnetPassword ;
2026-05-11 01:23:11 -05:00
2025-08-07 02:20:27 -05:00
try {
2025-12-31 22:20:12 -06:00
const accessInfo = await permissionManager . canAccessHost (
userId ,
Number ( hostId ),
2026-07-19 12:29:52 -05:00
"edit" ,
2025-12-31 22:20:12 -06:00
);
if ( ! accessInfo . hasAccess ) {
sshLogger . warn ( "User does not have permission to update host" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 403 ). json ({ error : "Access denied" });
}
2026-07-19 12:29:52 -05:00
const hostRecord =
await createCurrentHostResolutionRepository (). findHostUpdateState (
Number ( hostId ),
);
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
if ( ! hostRecord ) {
2025-12-31 22:20:12 -06:00
sshLogger . warn ( "Host not found for update" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 404 ). json ({ error : "Host not found" });
}
2026-07-19 12:29:52 -05:00
const ownerId = hostRecord . userId ;
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
if ( ! accessInfo . isOwner ) {
// Shared editors work on the owner's real record but may never
// repoint it at credential/vault references (those live in the
// owner's personal vault) or switch the authentication type.
const referenceViolations : Array < [ unknown , number | null , string ] > = [
[ sshDataObj . credentialId , hostRecord . credentialId , "credential" ],
[
sshDataObj . rdpCredentialId ,
hostRecord . rdpCredentialId ,
"RDP credential" ,
],
[
sshDataObj . vncCredentialId ,
hostRecord . vncCredentialId ,
"VNC credential" ,
],
[
sshDataObj . telnetCredentialId ,
hostRecord . telnetCredentialId ,
"Telnet credential" ,
],
[
sshDataObj . vaultProfileId ,
hostRecord . vaultProfileId ,
"Vault profile" ,
],
];
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
for ( const [ incoming , current , label ] of referenceViolations ) {
if ( incoming !== undefined && ( incoming ?? null ) !== current ) {
return res . status ( 403 ). json ({
error : `Only the host owner can change the ${ label } ` ,
});
}
}
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
if (
sshDataObj . authType !== undefined &&
sshDataObj . authType !== hostRecord . authType
) {
return res . status ( 403 ). json ({
error : "Only the host owner can change the authentication type" ,
});
2025-12-31 22:20:12 -06:00
}
}
2026-07-19 12:29:52 -05:00
await createCurrentHostRepository (). updateEncryptedForUser (
ownerId ,
Number ( hostId ),
2025-10-01 15:40:10 -05:00
sshDataObj ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
// Keep every recipient's re-encrypted secret snapshots in sync with
// the updated host record.
try {
const { SharedHostSecretsManager } =
await import ( "../../utils/shared-host-secrets-manager.js" );
await SharedHostSecretsManager . getInstance (). resyncHost ( Number ( hostId ));
} catch ( resyncError ) {
sshLogger . warn ( "Failed to resync shared host secrets after update" , {
operation : "host_update_resync" ,
hostId : parseInt ( hostId ),
error :
resyncError instanceof Error
? resyncError . message
: "Unknown error" ,
});
}
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
const updatedHost =
await createCurrentHostResolutionRepository (). findHostById (
Number ( hostId ),
ownerId ,
);
if ( ! updatedHost ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "Updated host not found after update" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 404 ). json ({ error : "Host not found after update" });
}
2026-01-26 00:28:40 -06:00
const baseHost = transformHostResponse ( updatedHost );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolvedHost =
( await resolveHostCredentials ( baseHost , userId )) || baseHost ;
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host updated" , {
operation : "host_update_success" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : uhIp , userAgent : uhUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "update_host" ,
resourceType : "host" ,
resourceId : hostId ,
resourceName : String ( name ?? ip ),
ipAddress : uhIp ,
userAgent : uhUa ,
success : true ,
});
2025-09-12 14:42:00 -05:00
res . json ( resolvedHost );
2026-05-06 15:12:07 -05:00
notifyStatsHostUpdated ( parseInt ( hostId ), req . headers , "host_update" );
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
sshLogger . error ( "Failed to update SSH host in database" , err , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
name ,
ip ,
port ,
authType : effectiveAuthType ,
});
res . status ( 500 ). json ({ error : "Failed to update SSH data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host:
2026-01-24 19:49:42 -06:00
* get:
* summary: Get all SSH hosts
* description: Retrieves all SSH hosts for the authenticated user.
* tags:
* - SSH
* responses:
* 200:
* description: A list of SSH hosts.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to fetch SSH data.
*/
2025-11-17 09:46:05 -06:00
router . get (
"/db/host" ,
authenticateJWT ,
requireDataAccess ,
async ( req : Request , res : Response ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
if ( ! isNonEmptyString ( userId )) {
sshLogger . warn ( "Invalid userId for SSH data fetch" , {
operation : "host_fetch" ,
userId ,
});
return res . status ( 400 ). json ({ error : "Invalid userId" });
}
try {
2025-12-31 22:20:12 -06:00
const now = new Date (). toISOString ();
2026-07-19 12:29:52 -05:00
const roleIds =
await createCurrentRoleRepository (). listUserRoleIds ( userId );
const accessEntries =
await createCurrentRbacAccessRepository (). listVisibleHostAccessEntries (
userId ,
roleIds ,
now ,
);
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
const rawData =
await createCurrentHostResolutionRepository (). listHostRowsForAccessList (
userId ,
accessEntries ,
2025-12-31 22:20:12 -06:00
);
const ownHosts = rawData . filter (( row ) => row . userId === userId );
const sharedHosts = rawData . filter (( row ) => row . userId !== userId );
2026-05-12 21:55:14 -05:00
const decryptedOwnHosts : Record < string , unknown >[] = [];
const userDataKey = DataCrypto . getUserDataKey ( userId );
if ( userDataKey ) {
for ( const host of ownHosts ) {
try {
decryptedOwnHosts . push (
DataCrypto . decryptRecord ( "ssh_data" , host , userId , userDataKey ),
);
} catch ( decryptError ) {
sshLogger . warn ( "Skipping host with invalid encrypted fields" , {
operation : "host_fetch_own_decrypt_failed" ,
userId ,
hostId : host.id ,
error :
decryptError instanceof Error
? decryptError . message
: "Unknown error" ,
});
}
}
2025-12-31 22:20:12 -06:00
}
2026-07-19 12:29:52 -05:00
const ownerUsernames = new Map < string , string >();
const userRepository = createCurrentUserRepository ();
for ( const sharedHost of sharedHosts ) {
const ownerId = sharedHost . userId as string ;
if ( ! ownerUsernames . has ( ownerId )) {
try {
const owner = await userRepository . findById ( ownerId );
ownerUsernames . set ( ownerId , owner ? . username ?? "" );
} catch {
ownerUsernames . set ( ownerId , "" );
}
}
}
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
const data = [... decryptedOwnHosts , ... sharedHosts ];
2025-09-12 14:42:00 -05:00
2025-11-17 09:46:05 -06:00
const result = await Promise . all (
data . map ( async ( row : Record < string , unknown >) => {
const baseHost = {
2026-01-26 00:28:40 -06:00
... transformHostResponse ( row ),
2025-12-31 22:20:12 -06:00
isShared : !! row . isShared ,
permissionLevel : row.permissionLevel || undefined ,
sharedExpiresAt : row.expiresAt || undefined ,
2026-07-19 12:29:52 -05:00
ownerUsername : row.isShared
? ownerUsernames . get ( row . userId as string ) || undefined
: undefined ,
2025-11-17 09:46:05 -06:00
};
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolved =
( await resolveHostCredentials ( baseHost , userId )) || baseHost ;
return resolved ;
2025-11-17 09:46:05 -06:00
}),
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
const sanitized = result . map (( host ) =>
host . isShared
? sanitizeHostForRecipient (
host ,
host . permissionLevel as string | undefined ,
)
: stripSensitiveFields ( host ),
);
2026-04-22 16:55:23 -05:00
res . json ( sanitized );
2025-11-17 09:46:05 -06:00
} catch ( err ) {
sshLogger . error ( "Failed to fetch SSH hosts from database" , err , {
operation : "host_fetch" ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to fetch SSH data" });
}
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* get:
* summary: Get SSH host by ID
* description: Retrieves a specific SSH host by its ID.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The requested SSH host.
* 400:
* description: Invalid userId or hostId.
* 404:
* description: SSH host not found.
* 500:
* description: Failed to fetch SSH host.
*/
2025-09-12 14:42:00 -05:00
router . get (
"/db/host/:id" ,
authenticateJWT ,
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( ! isNonEmptyString ( userId ) || ! hostId ) {
sshLogger . warn ( "Invalid userId or hostId for SSH host fetch by ID" , {
operation : "host_fetch_by_id" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 400 ). json ({ error : "Invalid userId or hostId" });
2025-08-07 02:20:27 -05:00
}
try {
2026-07-19 12:29:52 -05:00
const hostResolutionRepository = createCurrentHostResolutionRepository ();
const host = await hostResolutionRepository . findHostByIdForUser (
Number ( hostId ),
2026-03-14 20:05:05 -05:00
userId ,
);
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
if ( host ) {
const result = transformHostResponse ( host );
const resolved =
( await resolveHostCredentials ( result , userId )) || result ;
return res . json ( stripSensitiveFields ( resolved ));
}
// Not the owner: shared recipients get a sanitized view of the host.
const accessInfo = await permissionManager . canAccessHost (
userId ,
Number ( hostId ),
"connect" ,
);
if ( ! accessInfo . hasAccess ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "SSH host not found" , {
operation : "host_fetch_by_id" ,
hostId : parseInt ( hostId ),
userId ,
2025-08-07 02:20:27 -05:00
});
2025-09-12 14:42:00 -05:00
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
const ownerId = await hostResolutionRepository . findHostOwnerId (
Number ( hostId ),
);
const sharedHost = ownerId
? await hostResolutionRepository . findHostById ( Number ( hostId ), ownerId )
: null ;
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
if ( ! sharedHost ) {
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
let ownerUsername : string | undefined ;
try {
const owner = ownerId
? await createCurrentUserRepository (). findById ( ownerId )
: null ;
ownerUsername = owner ? . username ?? undefined ;
} catch {
ownerUsername = undefined ;
}
const sharedResult = {
... transformHostResponse ( sharedHost ),
isShared : true ,
permissionLevel : accessInfo.permissionLevel ,
sharedExpiresAt : accessInfo.expiresAt || undefined ,
ownerUsername ,
};
res . json (
sanitizeHostForRecipient ( sharedResult , accessInfo . permissionLevel ),
);
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
sshLogger . error ( "Failed to fetch SSH host by ID from database" , err , {
operation : "host_fetch_by_id" ,
hostId : parseInt ( hostId ),
userId ,
});
res . status ( 500 ). json ({ error : "Failed to fetch SSH host" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host/{id}/password:
* get:
* summary: Get host password for clipboard copy
* description: Returns the password for a specific host. Used by the copy-password feature.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: field
* schema:
* type: string
2026-07-26 18:47:27 -05:00
* enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
2026-04-22 16:55:23 -05:00
* responses:
* 200:
* description: The requested password value.
* 404:
* description: Host not found or no password set.
*/
router . get (
"/db/host/:id/password" ,
authenticateJWT ,
requireDataAccess ,
async ( req : Request , res : Response ) => {
const hostId = Number ( req . params . id );
const userId = ( req as AuthenticatedRequest ). userId ;
const field = ( req . query . field as string ) || "password" ;
2026-07-26 18:47:27 -05:00
if (
! [
"password" ,
"sudoPassword" ,
"rdpPassword" ,
"vncPassword" ,
"telnetPassword" ,
"key" ,
"keyPassword" ,
]. includes ( field )
) {
2026-04-22 16:55:23 -05:00
return res . status ( 400 ). json ({ error : "Invalid field" });
}
try {
2026-07-19 12:29:52 -05:00
const host = await createCurrentHostResolutionRepository (). findHostById (
hostId ,
2026-04-22 16:55:23 -05:00
userId ,
);
2026-07-19 12:29:52 -05:00
if ( ! host ) {
2026-04-22 16:55:23 -05:00
return res . status ( 404 ). json ({ error : "Host not found" });
}
const resolved = ( await resolveHostCredentials ( host , userId )) || host ;
2026-06-21 15:55:41 -05:00
let value = resolved [ field ];
if ( ! value && field === "sudoPassword" && resolved . terminalConfig ) {
try {
const tc =
typeof resolved . terminalConfig === "string"
? JSON . parse ( resolved . terminalConfig )
: resolved . terminalConfig ;
value = tc ? . sudoPassword || null ;
} catch {
// malformed JSON — leave value null
}
}
2026-04-22 16:55:23 -05:00
if ( ! value ) {
return res . status ( 404 ). json ({ error : "No password set" });
}
res . json ({ value });
} catch ( err ) {
sshLogger . error ( "Failed to fetch host password" , err , {
operation : "host_password_fetch" ,
hostId ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to fetch password" });
}
},
);
/**
* @openapi
* /host/db/host/{id}/export:
2026-01-24 19:49:42 -06:00
* get:
* summary: Export SSH host
* description: Exports a specific SSH host with decrypted credentials.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The exported SSH host.
* 400:
* description: Invalid userId or hostId.
* 404:
* description: SSH host not found.
* 500:
* description: Failed to export SSH host.
*/
2025-10-03 00:02:10 -05:00
router . get (
"/db/host/:id/export" ,
authenticateJWT ,
requireDataAccess ,
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2025-10-03 00:02:10 -05:00
if ( ! isNonEmptyString ( userId ) || ! hostId ) {
return res . status ( 400 ). json ({ error : "Invalid userId or hostId" });
}
try {
2026-07-19 12:29:52 -05:00
const host =
await createCurrentHostResolutionRepository (). findHostByIdForUser (
Number ( hostId ),
userId ,
);
2025-10-03 00:02:10 -05:00
2026-07-19 12:29:52 -05:00
if ( ! host ) {
2025-10-03 00:02:10 -05:00
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
2025-12-31 22:20:12 -06:00
const resolvedHost = ( await resolveHostCredentials ( host , userId )) || host ;
2025-10-03 00:02:10 -05:00
2026-03-14 20:05:05 -05:00
const exportedConnectionType =
( resolvedHost . connectionType as string ) || "ssh" ;
const isRemoteDesktop = [ "rdp" , "vnc" , "telnet" ]. includes (
exportedConnectionType ,
);
const baseExportData = {
connectionType : exportedConnectionType ,
2025-10-03 00:02:10 -05:00
name : resolvedHost.name ,
ip : resolvedHost.ip ,
port : resolvedHost.port ,
username : resolvedHost.username ,
password : resolvedHost.password || null ,
folder : resolvedHost.folder ,
tags :
typeof resolvedHost . tags === "string"
? resolvedHost . tags . split ( "," ). filter ( Boolean )
: resolvedHost . tags || [],
pin : !! resolvedHost . pin ,
2026-03-08 18:02:14 -05:00
notes : resolvedHost.notes || null ,
2025-10-03 00:02:10 -05:00
};
2026-03-14 20:05:05 -05:00
const exportData = isRemoteDesktop
? {
... baseExportData ,
2026-05-28 22:29:20 -05:00
enableRdp : !! resolvedHost . enableRdp ,
enableVnc : !! resolvedHost . enableVnc ,
enableTelnet : !! resolvedHost . enableTelnet ,
rdpPort : resolvedHost.rdpPort || 3389 ,
vncPort : resolvedHost.vncPort || 5900 ,
telnetPort : resolvedHost.telnetPort || 23 ,
2026-07-19 12:29:52 -05:00
rdpAuthType : resolvedHost.rdpAuthType || null ,
rdpCredentialId : resolvedHost.rdpCredentialId || null ,
2026-05-28 22:29:20 -05:00
rdpUser : resolvedHost.rdpUser || null ,
rdpPassword : resolvedHost.rdpPassword || null ,
rdpDomain : resolvedHost.rdpDomain || null ,
rdpSecurity : resolvedHost.rdpSecurity || null ,
rdpIgnoreCert : !! resolvedHost . rdpIgnoreCert ,
2026-06-29 13:28:26 -05:00
vncAuthType : resolvedHost.vncAuthType || null ,
vncCredentialId : resolvedHost.vncCredentialId || null ,
2026-05-28 22:29:20 -05:00
vncUser : resolvedHost.vncUser || null ,
vncPassword : resolvedHost.vncPassword || null ,
2026-07-19 12:29:52 -05:00
telnetAuthType : resolvedHost.telnetAuthType || null ,
telnetCredentialId : resolvedHost.telnetCredentialId || null ,
2026-05-28 22:29:20 -05:00
telnetUser : resolvedHost.telnetUser || null ,
telnetPassword : resolvedHost.telnetPassword || null ,
2026-03-14 20:05:05 -05:00
guacamoleConfig : resolvedHost.guacamoleConfig
? JSON . parse ( resolvedHost . guacamoleConfig as string )
: null ,
}
: {
... baseExportData ,
authType : resolvedHost.authType ,
key : resolvedHost.key || null ,
keyPassword : resolvedHost.keyPassword || null ,
keyType : resolvedHost.keyType || null ,
credentialId : resolvedHost.credentialId || null ,
overrideCredentialUsername :
!! resolvedHost . overrideCredentialUsername ,
enableTerminal : !! resolvedHost . enableTerminal ,
enableTunnel : !! resolvedHost . enableTunnel ,
2026-06-21 15:55:41 -05:00
enableFileManager : resolvedHost.enableFileManager !== false ,
scpLegacy : !! resolvedHost . scpLegacy ,
2026-03-14 20:05:05 -05:00
enableDocker : !! resolvedHost . enableDocker ,
2026-06-16 15:59:53 -05:00
enableProxmox : !! resolvedHost . enableProxmox ,
enableTmuxMonitor : !! resolvedHost . enableTmuxMonitor ,
2026-03-14 20:05:05 -05:00
showTerminalInSidebar : !! resolvedHost . showTerminalInSidebar ,
showFileManagerInSidebar : !! resolvedHost . showFileManagerInSidebar ,
showTunnelInSidebar : !! resolvedHost . showTunnelInSidebar ,
showDockerInSidebar : !! resolvedHost . showDockerInSidebar ,
showServerStatsInSidebar : !! resolvedHost . showServerStatsInSidebar ,
defaultPath : resolvedHost.defaultPath ,
sudoPassword : resolvedHost.sudoPassword || null ,
tunnelConnections : resolvedHost.tunnelConnections
? JSON . parse ( resolvedHost . tunnelConnections as string )
: [],
jumpHosts : resolvedHost.jumpHosts
? JSON . parse ( resolvedHost . jumpHosts as string )
: null ,
quickActions : resolvedHost.quickActions
? JSON . parse ( resolvedHost . quickActions as string )
: null ,
statsConfig : resolvedHost.statsConfig
? JSON . parse ( resolvedHost . statsConfig as string )
: null ,
dockerConfig : resolvedHost.dockerConfig
? JSON . parse ( resolvedHost . dockerConfig as string )
: null ,
2026-06-16 15:59:53 -05:00
proxmoxConfig : resolvedHost.proxmoxConfig
? JSON . parse ( resolvedHost . proxmoxConfig as string )
: null ,
2026-03-14 20:05:05 -05:00
terminalConfig : resolvedHost.terminalConfig
? JSON . parse ( resolvedHost . terminalConfig as string )
: null ,
forceKeyboardInteractive :
resolvedHost.forceKeyboardInteractive === "true" ,
useSocks5 : !! resolvedHost . useSocks5 ,
socks5Host : resolvedHost.socks5Host || null ,
socks5Port : resolvedHost.socks5Port || null ,
socks5Username : resolvedHost.socks5Username || null ,
socks5Password : resolvedHost.socks5Password || null ,
socks5ProxyChain : resolvedHost.socks5ProxyChain
? JSON . parse ( resolvedHost . socks5ProxyChain as string )
: null ,
2026-04-22 16:55:23 -05:00
portKnockSequence : resolvedHost.portKnockSequence
? JSON . parse ( resolvedHost . portKnockSequence as string )
: null ,
2026-03-14 20:05:05 -05:00
};
2025-10-03 00:02:10 -05:00
sshLogger . success ( "Host exported with decrypted credentials" , {
operation : "host_export" ,
hostId : parseInt ( hostId ),
userId ,
});
res . json ( exportData );
} catch ( err ) {
sshLogger . error ( "Failed to export SSH host" , err , {
operation : "host_export" ,
hostId : parseInt ( hostId ),
userId ,
});
res . status ( 500 ). json ({ error : "Failed to export SSH host" });
}
},
);
2026-04-22 16:55:23 -05:00
/**
* @openapi
2026-06-16 15:59:53 -05:00
* /host/db/hosts/export:
2026-04-22 16:55:23 -05:00
* get:
* summary: Export all SSH hosts
2026-07-26 18:47:27 -05:00
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
2026-04-22 16:55:23 -05:00
* tags:
* - SSH
2026-07-26 18:47:27 -05:00
* parameters:
* - in: query
* name: share
* required: false
* schema:
* type: string
* description: Set to "1" to export without embedded secrets.
2026-04-22 16:55:23 -05:00
* responses:
* 200:
* description: All exported SSH hosts.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to export SSH hosts.
*/
router . get (
"/db/hosts/export" ,
authenticateJWT ,
requireDataAccess ,
async ( req : Request , res : Response ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
2026-07-26 18:47:27 -05:00
const shareMode = req . query . share === "1" || req . query . share === "true" ;
2026-04-22 16:55:23 -05:00
if ( ! isNonEmptyString ( userId )) {
return res . status ( 400 ). json ({ error : "Invalid userId" });
}
try {
2026-07-19 12:29:52 -05:00
const allHosts =
await createCurrentHostResolutionRepository (). findHostsByUserId ( userId );
2026-06-29 13:28:26 -05:00
2026-04-22 16:55:23 -05:00
const exportedHosts = [];
2026-07-26 18:47:27 -05:00
const usedCredentialIds = new Set < number >();
2026-04-22 16:55:23 -05:00
for ( const host of allHosts ) {
2026-07-26 18:47:27 -05:00
const resolvedHost = shareMode
? host
: ( await resolveHostCredentials ( host , userId )) || host ;
2026-04-22 16:55:23 -05:00
const exportedConnectionType =
( resolvedHost . connectionType as string ) || "ssh" ;
const isRemoteDesktop = [ "rdp" , "vnc" , "telnet" ]. includes (
exportedConnectionType ,
);
const baseExportData = {
connectionType : exportedConnectionType ,
name : resolvedHost.name ,
ip : resolvedHost.ip ,
port : resolvedHost.port ,
username : resolvedHost.username ,
2026-07-26 18:47:27 -05:00
password : shareMode ? null : resolvedHost . password || null ,
2026-04-22 16:55:23 -05:00
folder : resolvedHost.folder ,
tags :
typeof resolvedHost . tags === "string"
? resolvedHost . tags . split ( "," ). filter ( Boolean )
: resolvedHost . tags || [],
pin : !! resolvedHost . pin ,
notes : resolvedHost.notes || null ,
};
const exportData = isRemoteDesktop
? {
... baseExportData ,
domain : resolvedHost.domain || null ,
security : resolvedHost.security || null ,
ignoreCert : !! resolvedHost . ignoreCert ,
guacamoleConfig : resolvedHost.guacamoleConfig
? JSON . parse ( resolvedHost . guacamoleConfig as string )
: null ,
}
: {
... baseExportData ,
authType : resolvedHost.authType ,
2026-07-26 18:47:27 -05:00
key : shareMode ? null : resolvedHost . key || null ,
keyPassword : shareMode ? null : resolvedHost . keyPassword || null ,
2026-04-22 16:55:23 -05:00
keyType : resolvedHost.keyType || null ,
credentialId : resolvedHost.credentialId || null ,
overrideCredentialUsername :
!! resolvedHost . overrideCredentialUsername ,
enableTerminal : !! resolvedHost . enableTerminal ,
enableTunnel : !! resolvedHost . enableTunnel ,
2026-06-21 15:55:41 -05:00
enableFileManager : resolvedHost.enableFileManager !== false ,
2026-04-22 16:55:23 -05:00
enableDocker : !! resolvedHost . enableDocker ,
2026-06-16 15:59:53 -05:00
enableProxmox : !! resolvedHost . enableProxmox ,
enableTmuxMonitor : !! resolvedHost . enableTmuxMonitor ,
2026-04-22 16:55:23 -05:00
showTerminalInSidebar : !! resolvedHost . showTerminalInSidebar ,
showFileManagerInSidebar : !! resolvedHost . showFileManagerInSidebar ,
showTunnelInSidebar : !! resolvedHost . showTunnelInSidebar ,
showDockerInSidebar : !! resolvedHost . showDockerInSidebar ,
showServerStatsInSidebar : !! resolvedHost . showServerStatsInSidebar ,
defaultPath : resolvedHost.defaultPath ,
2026-07-26 18:47:27 -05:00
sudoPassword : shareMode
? null
: resolvedHost . sudoPassword || null ,
2026-04-22 16:55:23 -05:00
tunnelConnections : resolvedHost.tunnelConnections
? JSON . parse ( resolvedHost . tunnelConnections as string )
: [],
jumpHosts : resolvedHost.jumpHosts
? JSON . parse ( resolvedHost . jumpHosts as string )
: null ,
quickActions : resolvedHost.quickActions
? JSON . parse ( resolvedHost . quickActions as string )
: null ,
statsConfig : resolvedHost.statsConfig
? JSON . parse ( resolvedHost . statsConfig as string )
: null ,
dockerConfig : resolvedHost.dockerConfig
? JSON . parse ( resolvedHost . dockerConfig as string )
: null ,
2026-06-16 15:59:53 -05:00
proxmoxConfig : resolvedHost.proxmoxConfig
? JSON . parse ( resolvedHost . proxmoxConfig as string )
: null ,
2026-04-22 16:55:23 -05:00
terminalConfig : resolvedHost.terminalConfig
? JSON . parse ( resolvedHost . terminalConfig as string )
: null ,
forceKeyboardInteractive :
resolvedHost.forceKeyboardInteractive === "true" ,
useSocks5 : !! resolvedHost . useSocks5 ,
socks5Host : resolvedHost.socks5Host || null ,
socks5Port : resolvedHost.socks5Port || null ,
socks5Username : resolvedHost.socks5Username || null ,
2026-07-26 18:47:27 -05:00
socks5Password : shareMode
? null
: resolvedHost . socks5Password || null ,
2026-04-22 16:55:23 -05:00
socks5ProxyChain : resolvedHost.socks5ProxyChain
? JSON . parse ( resolvedHost . socks5ProxyChain as string )
: null ,
};
2026-07-26 18:47:27 -05:00
if (
shareMode &&
! isRemoteDesktop &&
resolvedHost . authType === "credential" &&
resolvedHost . credentialId
) {
usedCredentialIds . add ( resolvedHost . credentialId as number );
}
2026-04-22 16:55:23 -05:00
exportedHosts . push ( exportData );
}
2026-07-26 18:47:27 -05:00
if ( ! shareMode ) {
sshLogger . success ( "All hosts exported with decrypted credentials" , {
operation : "hosts_export_all" ,
count : exportedHosts.length ,
userId ,
});
return res . json ({ hosts : exportedHosts });
}
const exportedCredentials : Record < string , unknown >[] = [];
if ( usedCredentialIds . size > 0 ) {
const credentialRepository = createCurrentCredentialRepository ();
const ownedCredentials =
await credentialRepository . listDecryptedByUserId ( userId );
const credentialById = new Map (
ownedCredentials . map (( credential ) => [ credential . id , credential ]),
);
for ( const host of exportedHosts as Record < string , unknown >[]) {
const credentialId = host . credentialId as number | null ;
if ( ! credentialId ) continue ;
const credential = credentialById . get ( credentialId );
if ( ! credential ) continue ;
host . credentialAlias = credential . name ;
if (
! exportedCredentials . some (
( entry ) => entry . alias === credential . name ,
)
) {
exportedCredentials . push ({
alias : credential.name ,
name : credential.name ,
description : credential.description || null ,
folder : credential.folder || null ,
tags :
typeof credential . tags === "string"
? credential . tags . split ( "," ). filter ( Boolean )
: [],
authType : credential.authType ,
username : credential.username || null ,
keyType : credential.keyType || null ,
});
}
}
}
for ( const host of exportedHosts as Record < string , unknown >[]) {
delete host . credentialId ;
}
sshLogger . success ( "All hosts exported for sharing without secrets" , {
operation : "hosts_export_all_share" ,
2026-04-22 16:55:23 -05:00
count : exportedHosts.length ,
2026-07-26 18:47:27 -05:00
credentialCount : exportedCredentials.length ,
2026-04-22 16:55:23 -05:00
userId ,
});
2026-07-26 18:47:27 -05:00
res . json ({
version : "1" ,
exportedAt : new Date (). toISOString (),
credentials : exportedCredentials ,
hosts : exportedHosts ,
});
2026-04-22 16:55:23 -05:00
} catch ( err ) {
sshLogger . error ( "Failed to export all SSH hosts" , err , {
operation : "hosts_export_all" ,
userId ,
});
res . status ( 500 ). json ({ error : "Failed to export SSH hosts" });
}
},
);
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-06-16 15:59:53 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* delete:
* summary: Delete SSH host
* description: Deletes an SSH host by its ID.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: SSH host deleted successfully.
* 400:
* description: Invalid userId or id.
* 404:
* description: SSH host not found.
* 500:
* description: Failed to delete SSH host.
*/
2025-09-12 14:42:00 -05:00
router . delete (
"/db/host/:id" ,
authenticateJWT ,
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-09-12 14:42:00 -05:00
if ( ! isNonEmptyString ( userId ) || ! hostId ) {
sshLogger . warn ( "Invalid userId or hostId for SSH host delete" , {
operation : "host_delete" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 400 ). json ({ error : "Invalid userId or id" });
2025-08-07 02:20:27 -05:00
}
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Deleting SSH host" , {
operation : "host_delete" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-08-07 02:20:27 -05:00
try {
2026-07-19 12:29:52 -05:00
const hostToDelete =
await createCurrentHostResolutionRepository (). findHostByIdForUser (
Number ( hostId ),
userId ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
if ( ! hostToDelete ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "SSH host not found for deletion" , {
operation : "host_delete" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
const numericHostId = Number ( hostId );
2026-07-19 12:29:52 -05:00
await createCurrentFileManagerBookmarkRepository (). deleteByHostId (
numericHostId ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
await createCurrentTransferRecentRepository (). deleteByHostId (
numericHostId ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
await createCurrentCommandHistoryRepository (). deleteByHostId (
numericHostId ,
);
2025-11-17 09:46:05 -06:00
2026-07-19 12:29:52 -05:00
await createCurrentSshCredentialUsageRepository (). deleteByHostId (
numericHostId ,
);
2026-06-04 15:16:53 -04:00
2026-07-19 12:29:52 -05:00
await createCurrentRecentActivityRepository (). deleteByHostId (
numericHostId ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
await createCurrentRbacAccessRepository (). deleteHostAccessForHost (
numericHostId ,
);
2025-11-17 09:46:05 -06:00
2026-07-19 12:29:52 -05:00
await createCurrentSessionRecordingRepository (). deleteByHostId (
numericHostId ,
);
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
await createCurrentHostRepository (). deleteForUser ( userId , numericHostId );
2026-07-26 18:47:27 -05:00
if ( hostToDelete . syncId ) {
await createCurrentSyncTombstoneRepository (). record (
userId ,
"hosts" ,
hostToDelete . syncId ,
);
}
2025-09-12 14:42:00 -05:00
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host deleted" , {
operation : "host_delete_success" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : dhIp , userAgent : dhUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "delete_host" ,
resourceType : "host" ,
resourceId : hostId ,
2026-07-19 12:29:52 -05:00
resourceName : hostToDelete.name ?? hostToDelete . ip ,
2026-06-16 15:59:53 -05:00
ipAddress : dhIp ,
userAgent : dhUa ,
success : true ,
});
2025-11-17 09:46:05 -06:00
try {
const axios = ( await import ( "axios" )). default ;
await axios . post (
2026-05-28 22:29:20 -05:00
` ${ STATS_SERVER_URL } /host-deleted` ,
2025-11-17 09:46:05 -06:00
{ hostId : numericHostId },
{
headers : {
Authorization : req.headers.authorization || "" ,
Cookie : req.headers.cookie || "" ,
},
timeout : 5000 ,
},
);
} catch ( err ) {
sshLogger . warn ( "Failed to notify stats server of host deletion" , {
operation : "host_delete" ,
hostId : numericHostId ,
error : err instanceof Error ? err.message : String ( err ),
});
}
2025-09-12 14:42:00 -05:00
res . json ({ message : "SSH host deleted" });
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
sshLogger . error ( "Failed to delete SSH host from database" , err , {
operation : "host_delete" ,
hostId : parseInt ( hostId ),
userId ,
});
res . status ( 500 ). json ({ error : "Failed to delete SSH host" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-06-04 15:16:53 -04:00
registerHostFileManagerBookmarkRoutes ( router , authenticateJWT );
2025-09-12 14:42:00 -05:00
router . get (
2026-06-04 15:16:53 -04:00
"/transfer/recent" ,
2025-09-12 14:42:00 -05:00
authenticateJWT ,
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2026-06-04 15:16:53 -04:00
const sourceHostIdQuery = Array . isArray ( req . query . sourceHostId )
? req . query . sourceHostId [ 0 ]
: req . query . sourceHostId ;
const sourceHostId = sourceHostIdQuery
? parseInt ( sourceHostIdQuery as string )
: null ;
2025-08-07 02:20:27 -05:00
if ( ! isNonEmptyString ( userId )) {
2025-09-12 14:42:00 -05:00
return res . status ( 400 ). json ({ error : "Invalid userId" });
2025-08-07 02:20:27 -05:00
}
2026-06-04 15:16:53 -04:00
if ( ! sourceHostId ) {
return res . status ( 400 ). json ({ error : "Source host ID is required" });
2025-08-07 02:20:27 -05:00
}
try {
2026-07-19 12:29:52 -05:00
const recent =
await createCurrentTransferRecentRepository (). listBySourceHost (
userId ,
sourceHostId ,
10 ,
);
2025-08-07 02:20:27 -05:00
2026-06-04 15:16:53 -04:00
res . json ( recent );
2025-09-12 14:42:00 -05:00
} catch ( err ) {
2026-06-04 15:16:53 -04:00
sshLogger . error ( "Failed to fetch transfer recent destinations" , err );
res . status ( 500 ). json ({ error : "Failed to fetch recent destinations" });
2025-09-12 14:42:00 -05:00
}
},
);
router . post (
2026-06-04 15:16:53 -04:00
"/transfer/recent" ,
2025-09-12 14:42:00 -05:00
authenticateJWT ,
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2026-06-04 15:16:53 -04:00
const { sourceHostId , destHostId , destPath , destPathLabel } = req . body ;
2025-09-12 14:42:00 -05:00
2026-06-04 15:16:53 -04:00
if (
! isNonEmptyString ( userId ) ||
! sourceHostId ||
! destHostId ||
! destPath
) {
2025-09-12 14:42:00 -05:00
return res . status ( 400 ). json ({ error : "Invalid data" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
2025-08-07 02:20:27 -05:00
try {
2026-07-19 12:29:52 -05:00
const transferRecentRepository = createCurrentTransferRecentRepository ();
await transferRecentRepository . upsertForDestination ( userId , {
sourceHostId ,
destHostId ,
destPath ,
destPathLabel ,
});
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
await transferRecentRepository . pruneSourceHost ( userId , sourceHostId , 10 );
2025-08-07 02:20:27 -05:00
2026-06-04 15:16:53 -04:00
res . json ({ message : "Recent destination saved" });
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2026-06-04 15:16:53 -04:00
sshLogger . error ( "Failed to save transfer recent destination" , err );
res . status ( 500 ). json ({ error : "Failed to save recent destination" });
2025-11-17 09:46:05 -06:00
}
},
);
2026-06-04 15:16:53 -04:00
registerHostCommandHistoryRoutes ( router , authenticateJWT );
2025-11-17 09:46:05 -06:00
2025-11-05 10:36:16 -06:00
async function resolveHostCredentials (
host : Record < string , unknown >,
2025-12-31 22:20:12 -06:00
requestingUserId? : string ,
2025-11-05 10:36:16 -06:00
) : Promise < Record < string , unknown >> {
2025-09-12 14:42:00 -05:00
try {
2025-12-31 22:20:12 -06:00
if ( host . credentialId && ( host . userId || host . ownerId )) {
2025-11-05 10:36:16 -06:00
const credentialId = host . credentialId as number ;
2025-12-31 22:20:12 -06:00
const ownerId = ( host . ownerId || host . userId ) as string ;
if ( requestingUserId && requestingUserId !== ownerId ) {
try {
2026-07-19 12:29:52 -05:00
const { SharedHostSecretsManager } =
await import ( "../../utils/shared-host-secrets-manager.js" );
const sharedCred =
await SharedHostSecretsManager . getInstance (). getSecretForUser (
host . id as number ,
requestingUserId ,
"ssh" ,
);
2025-12-31 22:20:12 -06:00
if ( sharedCred ) {
const resolvedHost : Record < string , unknown > = {
... host ,
password : sharedCred.password ,
key : sharedCred.key ,
keyPassword : sharedCred.keyPassword ,
keyType : sharedCred.keyType ,
};
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername (
host . username ,
sharedCred . username ,
host . overrideCredentialUsername ,
);
if ( resolvedUsername !== undefined ) {
resolvedHost . username = resolvedUsername ;
2025-12-31 22:20:12 -06:00
}
return resolvedHost ;
}
} catch ( sharedCredError ) {
sshLogger . warn (
"Failed to get shared credential, falling back to owner credential" ,
{
operation : "resolve_shared_credential_fallback" ,
hostId : host.id as number ,
requestingUserId ,
error :
sharedCredError instanceof Error
? sharedCredError . message
: "Unknown error" ,
},
);
}
}
2025-11-05 10:36:16 -06:00
2026-07-19 12:29:52 -05:00
const credential =
await createCurrentHostResolutionRepository (). findCredentialByIdForUser (
credentialId ,
ownerId ,
);
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
if ( credential ) {
2025-12-31 22:20:12 -06:00
const resolvedHost : Record < string , unknown > = {
2025-09-12 14:42:00 -05:00
... host ,
2026-07-19 12:29:52 -05:00
password : pickResolvedPassword ( host . password , credential . password ),
2025-09-12 14:42:00 -05:00
key : credential.key ,
2026-03-08 18:02:14 -05:00
keyPassword : credential.keyPassword ,
keyType : credential.keyType ,
2025-09-12 14:42:00 -05:00
};
2025-12-31 22:20:12 -06:00
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername (
host . username ,
credential . username ,
host . overrideCredentialUsername ,
);
if ( resolvedUsername !== undefined ) {
resolvedHost . username = resolvedUsername ;
2025-12-31 22:20:12 -06:00
}
return resolvedHost ;
2025-09-12 14:42:00 -05:00
}
}
2025-11-05 10:36:16 -06:00
2026-03-08 18:02:14 -05:00
return { ... host };
2025-09-12 14:42:00 -05:00
} catch ( error ) {
sshLogger . warn (
`Failed to resolve credentials for host ${ host . id } : ${ error instanceof Error ? error . message : "Unknown error" } ` ,
);
return host ;
}
}
2026-06-04 15:16:53 -04:00
registerHostFolderRoutes ( router , {
2025-09-12 14:42:00 -05:00
authenticateJWT ,
2026-06-04 15:16:53 -04:00
statsServerUrl : STATS_SERVER_URL ,
2025-11-17 09:46:05 -06:00
});
2026-06-04 15:16:53 -04:00
registerHostBulkRoutes ( router , authenticateJWT );
2025-08-13 00:26:32 -05:00
2026-06-04 15:16:53 -04:00
registerHostAutostartRoutes ( router , {
2025-10-01 15:40:10 -05:00
authenticateJWT ,
requireDataAccess ,
2026-06-04 15:16:53 -04:00
});
2025-10-01 15:40:10 -05:00
2026-02-12 22:28:13 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/opkssh/token/{hostId}:
2026-02-12 22:28:13 -06:00
* get:
* summary: Get OPKSSH token status for a host
* tags: [SSH]
* security:
* - bearerAuth: []
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* description: Host ID
* responses:
* 200:
* description: Token status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* exists:
* type: boolean
* description: Whether a valid token exists
* expiresAt:
* type: string
* format: date-time
* description: Token expiration timestamp
* email:
* type: string
* description: User email from OIDC identity
* 404:
* description: No valid token found
* 500:
* description: Internal server error
*/
router . get (
"/ssh/opkssh/token/:hostId" ,
authenticateJWT ,
requireDataAccess ,
async ( req : AuthenticatedRequest , res : Response ) => {
const userId = req . userId ;
const hostId = parseInt (
Array . isArray ( req . params . hostId )
? req . params . hostId [ 0 ]
: req . params . hostId ,
);
if ( ! userId || isNaN ( hostId )) {
return res . status ( 400 ). json ({ error : "Invalid request" });
}
try {
2026-07-19 12:29:52 -05:00
const opksshTokenRepository = createCurrentOpksshTokenRepository ();
const tokenData = await opksshTokenRepository . findByUserAndHost (
userId ,
hostId ,
);
2026-02-12 22:28:13 -06:00
2026-07-19 12:29:52 -05:00
if ( ! tokenData ) {
2026-02-12 22:28:13 -06:00
return res . status ( 404 ). json ({ exists : false });
}
const expiresAt = new Date ( tokenData . expiresAt );
if ( expiresAt < new Date ()) {
2026-07-19 12:29:52 -05:00
await opksshTokenRepository . deleteByUserAndHost ( userId , hostId );
2026-02-12 22:28:13 -06:00
return res . status ( 404 ). json ({ exists : false });
}
res . json ({
exists : true ,
expiresAt : tokenData.expiresAt ,
email : tokenData.email ,
});
} catch ( error ) {
sshLogger . error ( "Error retrieving OPKSSH token status" , error , {
operation : "opkssh_token_status_error" ,
userId ,
hostId ,
});
res . status ( 500 ). json ({ error : "Internal server error" });
}
},
);
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/opkssh/token/{hostId}:
2026-02-12 22:28:13 -06:00
* delete:
* summary: Delete OPKSSH token for a host
* tags: [SSH]
* security:
* - bearerAuth: []
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* description: Host ID
* responses:
* 200:
* description: Token deleted successfully
* 500:
* description: Internal server error
*/
router . delete (
"/ssh/opkssh/token/:hostId" ,
authenticateJWT ,
requireDataAccess ,
async ( req : AuthenticatedRequest , res : Response ) => {
const userId = req . userId ;
const hostId = parseInt (
Array . isArray ( req . params . hostId )
? req . params . hostId [ 0 ]
: req . params . hostId ,
);
if ( ! userId || isNaN ( hostId )) {
return res . status ( 400 ). json ({ error : "Invalid request" });
}
try {
2026-07-19 12:29:52 -05:00
const { deleteOPKSSHToken } = await import ( "../../hosts/opkssh-auth.js" );
2026-02-12 22:28:13 -06:00
await deleteOPKSSHToken ( userId , hostId );
res . json ({ success : true });
} catch ( error ) {
sshLogger . error ( "Error deleting OPKSSH token" , error , {
operation : "opkssh_token_delete_error" ,
userId ,
hostId ,
});
res . status ( 500 ). json ({ error : "Internal server error" });
}
},
);
2026-06-04 15:16:53 -04:00
registerHostOpksshRoutes ( router );
2026-04-22 16:55:23 -05:00
2026-06-04 15:16:53 -04:00
registerHostNetworkRoutes ( router , {
authenticateJWT ,
requireDataAccess ,
2026-02-12 22:28:13 -06:00
});
2025-09-12 14:42:00 -05:00
export default router ;