import { getErrorMessage } from "../../utils/error-message.js"; import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js"; import { findUsableCredential } from "../../hosts/usable-credential.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; import { pluginEvents, TOPICS } from "../../plugins/events.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; import { pickResolvedPassword, pickResolvedUsername, } from "../../hosts/credential-username.js"; import { emitInternalEvent } from "../../hosts/internal-events.js"; import { deleteOwnedHost } from "../../hosts/delete-host.js"; import { createCurrentCredentialRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, createCurrentUserRepository, createCurrentSharedHostAuthOverrideRepository, } from "../repositories/factory.js"; import { applyHostKeyTypeUpdate, containsOwnerPrivateAuthUpdate, isNonEmptyString, isOptionalBoolean, isValidPort, normalizeProtocolEnableFields, OWNER_PRIVATE_AUTH_FIELDS, OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS, sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, } from "./host-normalizers.js"; import { attachHostPluginSettings, loadHostPluginSettings, withHostPluginSettings, } from "./host-plugin-settings.js"; import { validateParentHostId } from "./host-parent-validation.js"; import { registerHostAuthCompatRoutes } from "./host-compat-routes.js"; import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostNetworkRoutes } from "./host-network-routes.js"; import { registerHostBulkRoutes } from "./host-bulk-routes.js"; import { registerHostStatusRoutes } from "./host-status-routes.js"; import { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath, } from "./host-enrollment-auth.js"; import { logAudit, getAuditUsername, getRequestMeta, } from "../../utils/audit-logger.js"; import type { HostResolutionCredentialRecord, HostResolutionHostRecord, } from "../repositories/host-resolution-repository.js"; import { requiresPersonalHostAuthentication, resolveRecipientSharedHostAuthentication, } from "../../utils/shared-host-auth-resolver.js"; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); /** * Tells whoever is polling this host that its details changed. * * An event rather than the HTTP POST to localhost:30005 this used to be: host * metrics is a plugin now and has no port of its own. Fire and forget, so a * subscriber that throws cannot fail the host update that caused it. */ function notifyStatsHostUpdated( hostId: number, userId: string, operation: string, ): void { try { // The user travels with the event: a subscriber re-reading the host needs // a data key, and the bus carries no session of its own. pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); } catch (err) { sshLogger.warn("Failed to publish host update event", { operation, hostId, error: err instanceof Error ? err.message : String(err), }); } } const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); registerHostStatusRoutes(router, { authenticateJWT, requireAdmin: authManager.createAdminMiddleware(), }); /** * @openapi * /host/db/host: * 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. */ router.post( ["/db/host", "/enroll"], authenticateJWT, permissionManager.requirePermission("hosts.create"), requireDataAccess, requireHostEnrollmentAccessForPath, upload.single("key"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; let hostData: Record; 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" }); } } else { sshLogger.warn("Missing data field in multipart request", { operation: "host_create", userId, }); return res.status(400).json({ error: "Missing data field" }); } if (req.file) { hostData.key = req.file.buffer.toString("utf8"); } } else { hostData = req.body; } if (req.path === "/enroll") { hostData = applyHostEnrollmentDefaults(hostData); } const { connectionType, name, folder, parentHostId, tags, ip, port, username, password, authMethod, authType, shareSshAuth, credentialId, key, keyPassword, keyType, sudoPassword, pin, jumpHosts, quickActions, statusCheckEnabled, statusCheckInterval, terminalConfig, forceKeyboardInteractive, domain, notes, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, connectionOrigin, portKnockSequence, overrideCredentialUsername, enableSsh, sshPort, rdpAuthType, rdpCredentialId, rdpUser, rdpPassword, rdpDomain, vncAuthType, vncCredentialId, vncPassword, vncUser, telnetAuthType, telnetCredentialId, telnetUser, telnetPassword, } = hostData; databaseLogger.info("Creating SSH host", { operation: "host_create", userId, name, ip, }); if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !isOptionalBoolean(shareSshAuth) || !isOptionalBoolean(enableSsh) ) { 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" }); } let validatedParentHostId: number | null = null; if (parentHostId !== undefined && parentHostId !== null) { const numericParentHostId = Number(parentHostId); if (!Number.isInteger(numericParentHostId)) { return res.status(400).json({ error: "Invalid parent host" }); } const parentError = await validateParentHostId( userId, null, numericParentHostId, ); if (parentError) { return res.status(400).json({ error: parentError }); } validatedParentHostId = numericParentHostId; } const effectiveConnectionType = connectionType || "ssh"; const effectiveAuthType = authType || authMethod || (effectiveConnectionType !== "ssh" ? "password" : undefined); const effectiveUsername = username || rdpUser || vncUser || telnetUser || ""; const effectiveName = name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { userId: userId, connectionType: effectiveConnectionType, name: effectiveName, // A host is either placed in a folder or nested under a parent host, // never both -- setting one clears the other. folder: validatedParentHostId ? null : folder || null, parentHostId: validatedParentHostId, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, username: effectiveUsername, authType: effectiveAuthType, shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, quickActions: Array.isArray(quickActions) ? JSON.stringify(quickActions) : null, statusCheckEnabled: statusCheckEnabled === false ? 0 : 1, statusCheckInterval: normalizeStatusInterval(statusCheckInterval), terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig : JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", domain: domain || null, 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, connectionOrigin: connectionOrigin === "local" || connectionOrigin === "remote" ? connectionOrigin : null, portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, ...normalizeProtocolEnableFields(hostData), sshPort: sshPort || port || 22, rdpAuthType: rdpAuthType || null, rdpCredentialId: rdpAuthType === "credential" && rdpCredentialId ? rdpCredentialId : null, rdpUser: rdpUser || null, rdpDomain: rdpDomain || null, vncAuthType: vncAuthType || null, vncCredentialId: vncAuthType === "credential" && vncCredentialId ? vncCredentialId : null, vncUser: vncUser || null, telnetAuthType: telnetAuthType || null, telnetCredentialId: telnetAuthType === "credential" && telnetCredentialId ? telnetCredentialId : null, telnetUser: telnetUser || null, }; // 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") { sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { 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"}`, }); } } sshDataObj.key = key || null; sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = password || null; } else if (effectiveAuthType === "credential") { sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } sshDataObj.rdpPassword = rdpPassword || null; sshDataObj.vncPassword = vncPassword || null; sshDataObj.telnetPassword = telnetPassword || null; try { const result = await createCurrentHostRepository().createEncryptedForUser( userId, sshDataObj, ); if (!result) { sshLogger.warn("No host returned after creation", { operation: "host_create", userId, name, ip, port, }); return res.status(500).json({ error: "Failed to create host" }); } const createdHost = result; // Standing folder shares apply to the newcomer. try { await applyFolderAccessRules( createdHost.id, userId!, createdHost.folder, ); } catch (folderAccessError) { sshLogger.warn("Failed to inherit folder access on host create", { operation: "host_create_folder_access", hostId: createdHost.id, error: getErrorMessage(folderAccessError), }); } const baseHost = transformHostResponse(createdHost); const resolvedHost = (await resolveHostCredentials(baseHost, userId)) || baseHost; databaseLogger.success("SSH host created", { operation: "host_create_success", userId, hostId: createdHost.id as number, name, }); const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req); await logAudit({ userId, username: await getAuditUsername(userId), action: "create_host", resourceType: "host", resourceId: String(createdHost.id), resourceName: String(name ?? ip), ipAddress: chIp, userAgent: chUa, success: true, }); emitInternalEvent("host_added", userId, createdHost.id as number, { name: String(name ?? ip), }); res.json(stripSensitiveFields(resolvedHost)); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); } catch (err) { 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" }); } }, ); /** * @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 * 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. */ /** * @openapi * /host/quick-connect: * 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) { const cred = await findUsableCredential(Number(credentialId), userId); if (!cred) { return res.status(404).json({ error: "Credential not found" }); } resolvedPassword = pickResolvedPassword(password, cred.password) as string | undefined; resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; resolvedAuthType = cred.authType as string | undefined; if (!overrideCredentialUsername) { resolvedUsername = cred.username as string; } } const tempHost: Record = { 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, jumpHosts: [], quickActions: [], statusCheckEnabled: true, statusCheckInterval: null, 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 * /host/db/host/{id}: * 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. */ router.put( "/db/host/:id", authenticateJWT, permissionManager.requirePermission("hosts.edit"), requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; let hostData: Record; 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" }); } } 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" }); } if (req.file) { hostData.key = req.file.buffer.toString("utf8"); } } else { hostData = req.body; } const { connectionType, name, folder, parentHostId, tags, ip, port, username, password, authMethod, authType, shareSshAuth, credentialId, key, keyPassword, keyType, sudoPassword, pin, jumpHosts, quickActions, statusCheckEnabled, statusCheckInterval, terminalConfig, forceKeyboardInteractive, domain, notes, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, connectionOrigin, portKnockSequence, overrideCredentialUsername, enableSsh, sshPort, rdpAuthType, rdpCredentialId, rdpUser, rdpPassword, rdpDomain, vncAuthType, vncCredentialId, vncPassword, vncUser, telnetAuthType, telnetCredentialId, telnetUser, telnetPassword, } = hostData; databaseLogger.info("Updating SSH host", { operation: "host_update", userId, hostId: parseInt(hostId), changes: Object.keys(hostData), }); if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !isOptionalBoolean(shareSshAuth) || !isOptionalBoolean(enableSsh) || !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" }); } let validatedParentHostId: number | null | undefined = undefined; if (parentHostId !== undefined) { if (parentHostId === null) { validatedParentHostId = null; } else { const numericParentHostId = Number(parentHostId); if (!Number.isInteger(numericParentHostId)) { return res.status(400).json({ error: "Invalid parent host" }); } const parentError = await validateParentHostId( userId, Number(hostId), numericParentHostId, ); if (parentError) { return res.status(400).json({ error: parentError }); } validatedParentHostId = numericParentHostId; } } const effectiveAuthType = authType || authMethod; const effectiveUsername = username || rdpUser || vncUser || telnetUser || ""; const effectiveName = name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { connectionType: connectionType || "ssh", name: effectiveName, // A host is either placed in a folder or nested under a parent host, // never both. When the caller is assigning a parent, clear folder; // when the caller is assigning a folder, clear parentHostId. folder: validatedParentHostId ? null : folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, username: effectiveUsername, authType: effectiveAuthType, shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, quickActions: Array.isArray(quickActions) ? JSON.stringify(quickActions) : null, statusCheckEnabled: statusCheckEnabled === false ? 0 : 1, statusCheckInterval: normalizeStatusInterval(statusCheckInterval), terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig : JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", domain: domain || null, 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, connectionOrigin: connectionOrigin === "local" || connectionOrigin === "remote" ? connectionOrigin : null, portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, ...normalizeProtocolEnableFields(hostData), sshPort: sshPort || port || 22, rdpAuthType: rdpAuthType || null, rdpCredentialId: rdpAuthType === "credential" && rdpCredentialId ? rdpCredentialId : null, rdpUser: rdpUser || null, rdpDomain: rdpDomain || null, vncAuthType: vncAuthType || null, vncCredentialId: vncAuthType === "credential" && vncCredentialId ? vncCredentialId : null, vncUser: vncUser || null, telnetAuthType: telnetAuthType || null, telnetCredentialId: telnetAuthType === "credential" && telnetCredentialId ? telnetCredentialId : null, telnetUser: telnetUser || null, }; // 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") { if (password) { sshDataObj.password = password; } sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { 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"}`, }); } sshDataObj.key = key; } if (keyPassword !== undefined) { sshDataObj.keyPassword = keyPassword || null; } applyHostKeyTypeUpdate(sshDataObj, keyType); sshDataObj.password = password || null; } else if (effectiveAuthType === "credential") { sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } if (rdpPassword) sshDataObj.rdpPassword = rdpPassword; if (vncPassword) sshDataObj.vncPassword = vncPassword; if (telnetPassword) sshDataObj.telnetPassword = telnetPassword; if (validatedParentHostId !== undefined) { sshDataObj.parentHostId = validatedParentHostId; } else if (folder !== undefined) { // Caller is assigning a folder (including clearing it back to root) // without touching parentHostId -- folder placement replaces // parent-host placement either way. sshDataObj.parentHostId = null; } try { const accessInfo = await permissionManager.canAccessHost( userId, Number(hostId), "edit", ); 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" }); } const hostRecord = await createCurrentHostResolutionRepository().findHostUpdateState( Number(hostId), ); if (!hostRecord) { sshLogger.warn("Host not found for update", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "Host not found" }); } const ownerId = hostRecord.userId; if (!accessInfo.isOwner) { // Shared editors work on the owner's real record, but the owner's SSH // authentication is private and can only be changed by that owner. if (containsOwnerPrivateAuthUpdate(hostData, "ssh")) { return res.status(403).json({ error: "Only the host owner can change the host's SSH authentication", }); } const parseTerminalConfig = ( value: unknown, ): Record | null => { if (!value) return null; if ( typeof value === "object" && value !== null && !Array.isArray(value) ) { return { ...(value as Record) }; } if (typeof value === "string") { const parsed = JSON.parse(value) as unknown; if ( typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ) { return { ...(parsed as Record) }; } } return null; }; if (hostData.terminalConfig === undefined) { delete sshDataObj.terminalConfig; } else { let incomingTerminalConfig: Record | null; try { incomingTerminalConfig = parseTerminalConfig( hostData.terminalConfig, ); } catch { return res.status(400).json({ error: "Invalid terminal config" }); } if (!incomingTerminalConfig) { return res.status(400).json({ error: "Invalid terminal config" }); } const protectedTerminalConfigField = OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) => Object.prototype.hasOwnProperty.call( incomingTerminalConfig, field, ), ); if (protectedTerminalConfigField) { return res.status(403).json({ error: "Only the host owner can change private SSH authentication settings", }); } const ownerHost = await createCurrentHostResolutionRepository().findHostById( Number(hostId), ownerId, ); const ownerTerminalConfig = parseTerminalConfig( ownerHost?.terminalConfig, ); if (ownerTerminalConfig) { for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) { if ( Object.prototype.hasOwnProperty.call(ownerTerminalConfig, field) ) { incomingTerminalConfig[field] = ownerTerminalConfig[field]; } } } sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig); } const referenceViolations: Array<[unknown, number | null, string]> = [ [ hostData.rdpCredentialId, hostRecord.rdpCredentialId, "RDP credential", ], [ hostData.vncCredentialId, hostRecord.vncCredentialId, "VNC credential", ], [ hostData.telnetCredentialId, hostRecord.telnetCredentialId, "Telnet credential", ], ]; 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}`, }); } } for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) { delete sshDataObj[field]; } } else if ( sshDataObj.terminalConfig && (hostData.terminalConfig as Record | undefined) ?.sudoPassword === undefined ) { // The editor omits sudoPassword entirely when the user hasn't // touched the field, so preserve whatever is already stored instead // of letting the wholesale terminalConfig replacement below wipe it. const existingHost = await createCurrentHostResolutionRepository().findHostById( Number(hostId), ownerId, ); const existingTerminalConfig = existingHost?.terminalConfig ? (JSON.parse(existingHost.terminalConfig as string) as Record< string, unknown >) : undefined; if (existingTerminalConfig?.sudoPassword !== undefined) { const incomingTerminalConfig = JSON.parse( sshDataObj.terminalConfig as string, ) as Record; incomingTerminalConfig.sudoPassword = existingTerminalConfig.sudoPassword; sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig); } } await createCurrentHostRepository().updateEncryptedForUser( ownerId, Number(hostId), sshDataObj, ); // A host that moved into a folder inherits that folder's standing shares. try { await applyFolderAccessRules( Number(hostId), ownerId, sshDataObj.folder as string | null | undefined, ); } catch (folderAccessError) { sshLogger.warn("Failed to inherit folder access on host update", { operation: "host_update_folder_access", hostId: parseInt(hostId), error: getErrorMessage(folderAccessError), }); } // 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: getErrorMessage(resyncError), }); } const updatedHost = await createCurrentHostResolutionRepository().findHostById( Number(hostId), ownerId, ); if (!updatedHost) { 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" }); } const baseHost = transformHostResponse(updatedHost); const resolvedHost = (await resolveHostCredentials(baseHost, userId)) || baseHost; databaseLogger.success("SSH host updated", { operation: "host_update_success", userId, hostId: parseInt(hostId), }); const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req); await logAudit({ userId, username: await getAuditUsername(userId), action: "update_host", resourceType: "host", resourceId: hostId, resourceName: String(name ?? ip), ipAddress: uhIp, userAgent: uhUa, success: true, }); res.json(stripSensitiveFields(resolvedHost)); notifyStatsHostUpdated(parseInt(hostId), userId, "host_update"); } catch (err) { 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" }); } }, ); /** * @openapi * /host/db/host: * 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. */ /** * @openapi * /host/db/host/{id}/terminal-config: * patch: * summary: Update a host's terminal behaviour flags * description: Merges the given flags into the host's terminalConfig. Owner or a recipient with edit access. Currently supports autoTmux; used by the "enable Auto-Tmux" action shown when a persisted session expires. * tags: * - Hosts * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * autoTmux: * type: boolean * responses: * 200: * description: Updated. * 403: * description: No edit access. * 404: * description: Host not found. */ router.patch( "/db/host/:id/terminal-config", authenticateJWT, permissionManager.requirePermission("hosts.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId!; const hostId = parseInt(String(req.params.id), 10); const { autoTmux } = req.body ?? {}; if (isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } if (typeof autoTmux !== "boolean") { return res.status(400).json({ error: "autoTmux must be a boolean" }); } try { const access = await permissionManager.canAccessHost( userId, hostId, "edit", ); if (!access.hasAccess) { return res.status(403).json({ error: "Access denied to host" }); } const ownerId = await createCurrentHostResolutionRepository().findHostOwnerId(hostId); const host = ownerId ? await createCurrentHostRepository().findByIdForUser(ownerId, hostId) : null; if (!host || !ownerId) { return res.status(404).json({ error: "Host not found" }); } let terminalConfig: Record = {}; if (host.terminalConfig) { try { terminalConfig = JSON.parse(host.terminalConfig); } catch { terminalConfig = {}; } } await createCurrentHostRepository().updateForUser(ownerId, hostId, { terminalConfig: JSON.stringify({ ...terminalConfig, autoTmux }), }); res.json({ success: true, autoTmux }); } catch (error) { sshLogger.error("Failed to update host terminal config", error, { operation: "host_terminal_config_update", hostId, userId, }); res.status(500).json({ error: "Failed to update terminal config" }); } }, ); router.get( "/db/host", authenticateJWT, permissionManager.requirePermission("hosts.view"), 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 { const now = new Date().toISOString(); const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); const accessEntries = await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( userId, roleIds, now, ); const rawData = await createCurrentHostResolutionRepository().listHostRowsForAccessList( userId, accessEntries, ); const ownHosts = rawData.filter((row) => row.userId === userId); const sharedHosts = rawData.filter((row) => row.userId !== userId); const decryptedOwnHosts: Record[] = []; 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: getErrorMessage(decryptError), }); } } } // One lookup for every owner rather than one per shared host. const ownerUsernames = new Map(); const ownerIds = Array.from( new Set(sharedHosts.map((host) => host.userId as string)), ); if (ownerIds.length > 0) { try { const owners = await createCurrentUserRepository().listByIds(ownerIds); for (const owner of owners) { ownerUsernames.set(owner.id, owner.username ?? ""); } } catch { // Falls through to an undefined ownerUsername below. } } const data = [...decryptedOwnHosts, ...sharedHosts]; // Own hosts all resolve against the caller's own credentials, so they can // be fetched and decrypted in one batch instead of once per host. const ownCredentialIds = decryptedOwnHosts .map((host) => host.credentialId) .filter((id): id is number => typeof id === "number"); const credentialsById = await createCurrentHostResolutionRepository() .listCredentialsByIdsForUser(ownCredentialIds, userId) .catch(() => new Map()); const result = await Promise.all( data.map(async (row: Record) => { const baseHost = { ...transformHostResponse(row), isShared: !!row.isShared, permissionLevel: row.permissionLevel || undefined, sharedExpiresAt: row.expiresAt || undefined, ownerUsername: row.isShared ? ownerUsernames.get(row.userId as string) || undefined : undefined, }; const resolved = (await resolveHostCredentials(baseHost, userId, credentialsById)) || baseHost; return resolved; }), ); const sanitized = result.map((host) => host.isShared ? sanitizeHostForRecipient( host, host.permissionLevel as string | undefined, ) : stripSensitiveFields(host), ); // After sanitizing: the connect-level projection reduces a shared host // to an allowlist, which would drop this again. One query for the list. attachHostPluginSettings( sanitized, await loadHostPluginSettings( sanitized .map((host) => Number(host.id)) .filter((id) => Number.isInteger(id)), ), ); res.json(sanitized); } 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" }); } }, ); /** * @openapi * /host/db/host/{id}: * 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. */ router.get( "/db/host/:id", authenticateJWT, permissionManager.requirePermission("hosts.view"), requireDataAccess, async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; 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" }); } try { const hostResolutionRepository = createCurrentHostResolutionRepository(); const host = await hostResolutionRepository.findHostByIdForUser( Number(hostId), userId, ); if (host) { const result = transformHostResponse(host); const resolved = (await resolveHostCredentials(result, userId)) || result; return res.json( await withHostPluginSettings(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) { sshLogger.warn("SSH host not found", { operation: "host_fetch_by_id", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "SSH host not found" }); } const ownerId = await hostResolutionRepository.findHostOwnerId( Number(hostId), ); const sharedHost = ownerId ? await hostResolutionRepository.findHostById(Number(hostId), ownerId) : null; 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, }; const resolvedSharedResult = (await resolveHostCredentials(sharedResult, userId)) || sharedResult; res.json( await withHostPluginSettings( sanitizeHostForRecipient( resolvedSharedResult, accessInfo.permissionLevel, ), ), ); } catch (err) { 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" }); } }, ); /** * Returns the minimum authentication material needed by the desktop app to * connect to a shared host from the recipient's own network. The response is * deliberately transient: callers must not persist it or include it in logs. */ router.get( "/db/host/:id/local-connection-auth", authenticateJWT, permissionManager.requirePermission("hosts.view"), requireDataAccess, async (req: Request, res: Response) => { const hostId = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) { return res.status(400).json({ error: "Invalid userId or hostId" }); } try { const access = await permissionManager.canAccessHost( userId, hostId, "connect", ); if (!access.hasAccess || !access.isShared) { return res.status(404).json({ error: "Shared host not found" }); } const repository = createCurrentHostResolutionRepository(); const ownerId = await repository.findHostOwnerId(hostId); const host = ownerId ? await repository.findHostById(hostId, ownerId) : null; if (!host) { return res.status(404).json({ error: "Shared host not found" }); } const resolved = await resolveHostCredentials( { ...transformHostResponse(host), isShared: true, permissionLevel: access.permissionLevel, }, userId, ); res.setHeader("Cache-Control", "no-store"); return res.json({ username: resolved.username, authType: resolved.authType, password: resolved.password || null, key: resolved.key || null, keyPassword: resolved.keyPassword || null, keyType: resolved.keyType || null, }); } catch (error) { sshLogger.error( "Failed to resolve shared host local authentication", error, { operation: "shared_host_local_auth_resolve", hostId, userId, }, ); return res .status(500) .json({ error: "Failed to resolve shared host authentication" }); } }, ); /** * @openapi * /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 * enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword] * 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"; if ( ![ "password", "sudoPassword", "rdpPassword", "vncPassword", "telnetPassword", "key", "keyPassword", ].includes(field) ) { return res.status(400).json({ error: "Invalid field" }); } try { const host = await createCurrentHostResolutionRepository().findHostByIdForUser( hostId, userId, ); if (!host) { return res.status(404).json({ error: "Host not found" }); } const resolved = (await resolveHostCredentials(host, userId)) || host; 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 } } 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: * 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. */ router.get( "/db/host/:id/export", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId) || !hostId) { return res.status(400).json({ error: "Invalid userId or hostId" }); } try { const host = await createCurrentHostResolutionRepository().findHostByIdForUser( Number(hostId), userId, ); if (!host) { return res.status(404).json({ error: "SSH host not found" }); } const resolvedHost = (await resolveHostCredentials(host, userId)) || host; const hostPluginSettings = ( await loadHostPluginSettings([Number(hostId)]) ).get(Number(hostId)); const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; const isRemoteDesktop = exportedConnectionType !== "ssh"; const baseExportData = { exportId: resolvedHost.id, connectionType: exportedConnectionType, 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, notes: resolvedHost.notes || null, // Every plugin's host settings, secrets redacted, for import to hand back. pluginSettings: hostPluginSettings ?? {}, }; const exportData = isRemoteDesktop ? { ...baseExportData, rdpAuthType: resolvedHost.rdpAuthType || null, rdpCredentialId: resolvedHost.rdpCredentialId || null, rdpUser: resolvedHost.rdpUser || null, rdpPassword: resolvedHost.rdpPassword || null, rdpDomain: resolvedHost.rdpDomain || null, vncAuthType: resolvedHost.vncAuthType || null, vncCredentialId: resolvedHost.vncCredentialId || null, vncUser: resolvedHost.vncUser || null, vncPassword: resolvedHost.vncPassword || null, telnetAuthType: resolvedHost.telnetAuthType || null, telnetCredentialId: resolvedHost.telnetCredentialId || null, telnetUser: resolvedHost.telnetUser || null, telnetPassword: resolvedHost.telnetPassword || null, } : { ...baseExportData, authType: resolvedHost.authType, key: resolvedHost.key || null, keyPassword: resolvedHost.keyPassword || null, keyType: resolvedHost.keyType || null, credentialId: resolvedHost.credentialId || null, overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, sudoPassword: resolvedHost.sudoPassword || null, jumpHosts: resolvedHost.jumpHosts ? JSON.parse(resolvedHost.jumpHosts as string) : null, quickActions: resolvedHost.quickActions ? JSON.parse(resolvedHost.quickActions as string) : null, 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, portKnockSequence: resolvedHost.portKnockSequence ? JSON.parse(resolvedHost.portKnockSequence as string) : null, }; 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" }); } }, ); /** * @openapi * /host/db/hosts/export: * get: * summary: Export all SSH hosts * 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. * tags: * - SSH * parameters: * - in: query * name: share * required: false * schema: * type: string * description: Set to "1" to export without embedded secrets. * 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; const shareMode = req.query.share === "1" || req.query.share === "true"; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid userId" }); } try { const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(userId); const pluginSettingsByHost = await loadHostPluginSettings( allHosts.map((h) => h.id as number), ); const exportedHosts = []; const usedCredentialIds = new Set(); for (const host of allHosts) { const resolvedHost = shareMode ? host : (await resolveHostCredentials(host, userId)) || host; const hostPluginSettings = pluginSettingsByHost.get(host.id as number); const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; const isRemoteDesktop = exportedConnectionType !== "ssh"; const baseExportData = { exportId: resolvedHost.id, connectionType: exportedConnectionType, name: resolvedHost.name, ip: resolvedHost.ip, port: resolvedHost.port, username: resolvedHost.username, password: shareMode ? null : resolvedHost.password || null, folder: resolvedHost.folder, tags: typeof resolvedHost.tags === "string" ? resolvedHost.tags.split(",").filter(Boolean) : resolvedHost.tags || [], pin: !!resolvedHost.pin, notes: resolvedHost.notes || null, // Every plugin's host settings, secrets redacted, for import to hand back. pluginSettings: hostPluginSettings ?? {}, }; const exportData = isRemoteDesktop ? { ...baseExportData, domain: resolvedHost.domain || null, } : { ...baseExportData, authType: resolvedHost.authType, key: shareMode ? null : resolvedHost.key || null, keyPassword: shareMode ? null : resolvedHost.keyPassword || null, keyType: resolvedHost.keyType || null, credentialId: resolvedHost.credentialId || null, overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null, jumpHosts: resolvedHost.jumpHosts ? JSON.parse(resolvedHost.jumpHosts as string) : null, quickActions: resolvedHost.quickActions ? JSON.parse(resolvedHost.quickActions as string) : null, 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: shareMode ? null : resolvedHost.socks5Password || null, socks5ProxyChain: resolvedHost.socks5ProxyChain ? JSON.parse(resolvedHost.socks5ProxyChain as string) : null, }; if ( shareMode && !isRemoteDesktop && resolvedHost.authType === "credential" && resolvedHost.credentialId ) { usedCredentialIds.add(resolvedHost.credentialId as number); } exportedHosts.push(exportData); } 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[] = []; 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[]) { 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[]) { delete host.credentialId; } sshLogger.success("All hosts exported for sharing without secrets", { operation: "hosts_export_all_share", count: exportedHosts.length, credentialCount: exportedCredentials.length, userId, }); res.json({ version: "1", exportedAt: new Date().toISOString(), credentials: exportedCredentials, hosts: exportedHosts, }); } 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" }); } }, ); /** * @openapi * /host/db/host/{id}: * 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. */ router.delete( "/db/host/:id", authenticateJWT, permissionManager.requirePermission("hosts.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; 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" }); } databaseLogger.info("Deleting SSH host", { operation: "host_delete", userId, hostId: parseInt(hostId), }); try { const deleted = await deleteOwnedHost(userId, Number(hostId)); if (!deleted) { 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" }); } databaseLogger.success("SSH host deleted", { operation: "host_delete_success", userId, hostId: parseInt(hostId), }); const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req); await logAudit({ userId, username: await getAuditUsername(userId), action: "delete_host", resourceType: "host", resourceId: hostId, resourceName: deleted.name, ipAddress: dhIp, userAgent: dhUa, success: true, }); res.json({ message: "SSH host deleted" }); } catch (err) { 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" }); } }, ); // File manager recent/pinned/shortcuts and transfer/recent routes moved to // the file-manager plugin, under /plugin-api/file-manager/, and command // history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/. async function resolveHostCredentials( host: Record, requestingUserId?: string, /** * Credentials already fetched for this request, keyed by id. The host list * preloads them in one query; single-host callers omit it and fall back to * fetching the one credential they need. */ preloadedCredentials?: Map, ): Promise> { try { const ownerId = (host.ownerId || host.userId) as string | undefined; if ( requestingUserId && ownerId && requestingUserId !== ownerId && typeof host.id === "number" ) { const authHost = host as unknown as HostResolutionHostRecord; const needsPersonalCredential = requiresPersonalHostAuthentication( authHost, "ssh", ); const baseSshOverrideState = { required: needsPersonalCredential, ownerAuthShared: !!host.shareSshAuth, }; // Owner auth for the remote desktop protocols is always snapshotted // for recipients; only their own override credential varies per user. const authOverrides: Record = { ssh: baseSshOverrideState, }; const overrideCredentialIds = await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( host.id, requestingUserId, ); // Whether a protocol is on is the remote-desktop plugin's host // setting; the client only offers an override for one that is. for (const protocol of ["rdp", "vnc", "telnet"] as const) { authOverrides[protocol] = { credentialId: overrideCredentialIds[protocol], required: false, ownerAuthShared: true, }; } const recipientHost: Record = { ...host, credentialId: null, password: null, key: null, keyPassword: null, keyType: null, authOverrides, }; try { const resolution = await resolveRecipientSharedHostAuthentication( authHost, host.id, requestingUserId, "ssh", ); if (resolution.source === "personal-override") { const credential = resolution.credential; return { ...recipientHost, authOverrides: { ...authOverrides, ssh: { credentialId: resolution.credentialId, required: false, ownerAuthShared: !!host.shareSshAuth, }, }, authType: credential.key || credential.privateKey ? "key" : credential.password ? "password" : "none", username: credential.username || recipientHost.username, password: credential.password, key: credential.privateKey || credential.key, keyPassword: credential.keyPassword, keyType: credential.keyType, }; } if (resolution.source === "owner-shared") { if (resolution.authType === "agent") { return { ...recipientHost, authOverrides: { ...authOverrides, ssh: { required: false, ownerAuthShared: true, }, }, authType: "agent", }; } const sharedAuth = resolution.secret; if (sharedAuth) { const resolvedUsername = pickResolvedUsername( recipientHost.username, sharedAuth.username, host.overrideCredentialUsername, ); return { ...recipientHost, authOverrides: { ...authOverrides, ssh: { required: false, ownerAuthShared: true, }, }, authType: sharedAuth.key ? "key" : sharedAuth.password ? "password" : "none", username: resolvedUsername, password: sharedAuth.password, key: sharedAuth.key, keyPassword: sharedAuth.keyPassword, keyType: sharedAuth.keyType, }; } } if (resolution.source === "secretless") { return { ...recipientHost, authOverrides: { ...authOverrides, ssh: { required: false, ownerAuthShared: !!host.shareSshAuth, }, }, }; } } catch { // A missing/deleted override or snapshot behaves like unavailable auth. } return recipientHost; } if (host.credentialId && (host.userId || host.ownerId)) { const credentialId = host.credentialId as number; const credentialOwnerId = (host.ownerId || host.userId) as string; const credential = preloadedCredentials?.get(credentialId) ?? (await findUsableCredential(credentialId, credentialOwnerId)); if (credential) { const resolvedHost: Record = { ...host, password: pickResolvedPassword(host.password, credential.password), key: credential.key, keyPassword: credential.keyPassword, keyType: credential.keyType, }; const resolvedUsername = pickResolvedUsername( host.username, credential.username, host.overrideCredentialUsername, ); if (resolvedUsername !== undefined) { resolvedHost.username = resolvedUsername; } return resolvedHost; } } return { ...host }; } catch (error) { sshLogger.warn( `Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`, ); return host; } } registerHostFolderRoutes(router, { authenticateJWT, requireViewPermission: permissionManager.requirePermission("hosts.view"), requireEditPermission: permissionManager.requirePermission("hosts.edit"), requireDeletePermission: permissionManager.requirePermission("hosts.delete"), requireCredentialEditPermission: permissionManager.requirePermission("credentials.edit"), requireDataAccess, }); registerHostBulkRoutes( router, authenticateJWT, permissionManager.requirePermission("hosts.create"), permissionManager.requirePermission("hosts.edit"), requireDataAccess, ); registerHostAuthCompatRoutes(router); registerHostNetworkRoutes(router, { authenticateJWT, requireViewPermission: permissionManager.requirePermission("hosts.view"), requireDataAccess, }); export default router; /** * A config field arriving as a JSON string (an import, or a client that * stringified it) must still reach storage as an object. Malformed input * becomes null rather than throwing inside a host save. */ function safeParseJson(raw: string): unknown { try { return JSON.parse(raw); } catch { return null; } } /** Seconds between status checks, or null to follow the global setting. */ function normalizeStatusInterval(value: unknown): number | null { const seconds = Number(value); if (value === null || value === undefined || value === "") return null; return Number.isInteger(seconds) && seconds >= 5 && seconds <= 86400 ? seconds : null; }