Files
Termix/src/backend/database/routes/host.ts
T

2457 lines
74 KiB
TypeScript
Raw Normal View History

+7
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";
+7
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";
+4
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";
+1
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";
+8
2026-07-19 12:29:52 -05:00
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import {
createCurrentCommandHistoryRepository,
+2
2026-07-26 18:47:27 -05:00
createCurrentCredentialRepository,
+8
2026-07-19 12:29:52 -05:00
createCurrentFileManagerBookmarkRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
+2
2026-07-26 18:47:27 -05:00
createCurrentSyncTombstoneRepository,
+8
2026-07-19 12:29:52 -05:00
} from "../repositories/factory.js";
2026-06-04 15:16:53 -04:00
import {
isNonEmptyString,
isValidPort,
+8
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";
+8
2026-07-19 12:29:52 -05:00
import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
} from "./host-enrollment-auth.js";
+2
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";
+8
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();
+1
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
+9
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(
+8
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,
+8
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) => {
+7
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
}
+8
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,
+1
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,
+1
2025-12-31 22:20:12 -06:00
enableDocker,
+2
2026-06-16 15:59:53 -05:00
enableProxmox,
enableTmuxMonitor,
+2
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,
+7
2025-11-05 10:36:16 -06:00
statsConfig,
2026-03-14 20:05:05 -05:00
dockerConfig,
+2
2026-06-16 15:59:53 -05:00
proxmoxConfig,
+7
2025-11-05 10:36:16 -06:00
terminalConfig,
forceKeyboardInteractive,
2026-03-14 20:05:05 -05:00
domain,
security,
ignoreCert,
guacamoleConfig,
+1
2025-12-31 22:20:12 -06:00
notes,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
+2
2026-07-26 18:47:27 -05:00
connectionOrigin,
+9
2026-04-22 16:55:23 -05:00
portKnockSequence,
+1
2025-12-31 22:20:12 -06:00
overrideCredentialUsername,
+9
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,
+8
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,
+8
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;
+4
2026-02-12 22:28:13 -06:00
databaseLogger.info("Creating SSH host", {
operation: "host_create",
userId,
name,
ip,
});
+1
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";
+9
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));
+7
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,
+1
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,
+1
2025-12-31 22:20:12 -06:00
enableDocker: enableDocker ? 1 : 0,
+2
2026-06-16 15:59:53 -05:00
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
+2
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,
+5
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,
+2
2026-06-16 15:59:53 -05:00
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
+5
2026-03-08 18:02:14 -05:00
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
+7
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,
+1
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,
+2
2026-07-26 18:47:27 -05:00
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
+9
2026-04-22 16:55:23 -05:00
macAddress: macAddress || null,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress: wolBroadcastAddress || null,
+9
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,
+8
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,
+8
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;
+5
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;
+5
2026-03-08 18:02:14 -05:00
sshDataObj.keyPassword = keyPassword || null;
2025-09-12 14:42:00 -05:00
sshDataObj.keyType = keyType;
+8
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;
+5
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 {
+8
2026-07-19 12:29:52 -05:00
const result = await createCurrentHostRepository().createEncryptedForUser(
2025-10-01 15:40:10 -05:00
userId,
+8
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;
const baseHost = transformHostResponse(createdHost);
2025-09-12 14:42:00 -05:00
+1
2025-12-31 22:20:12 -06:00
const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
+4
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
+2
2026-06-16 15:59:53 -05:00
const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: await getAuditUsername(userId),
+2
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
+8
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
+9
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) {
+8
2026-07-19 12:29:52 -05:00
const cred =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
Number(credentialId),
userId,
);
2026-01-24 19:49:42 -06:00
+8
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" });
}
+8
2026-07-19 12:29:52 -05:00
resolvedPassword = pickResolvedPassword(password, cred.password) as
| string
| undefined;
+5
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,
+2
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
+9
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;
+7
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,
+1
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,
+1
2025-12-31 22:20:12 -06:00
enableDocker,
+2
2026-06-16 15:59:53 -05:00
enableProxmox,
enableTmuxMonitor,
+2
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,
+7
2025-11-05 10:36:16 -06:00
statsConfig,
2026-03-14 20:05:05 -05:00
dockerConfig,
+2
2026-06-16 15:59:53 -05:00
proxmoxConfig,
+7
2025-11-05 10:36:16 -06:00
terminalConfig,
forceKeyboardInteractive,
2026-03-14 20:05:05 -05:00
domain,
security,
ignoreCert,
guacamoleConfig,
+1
2025-12-31 22:20:12 -06:00
notes,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
+2
2026-07-26 18:47:27 -05:00
connectionOrigin,
+9
2026-04-22 16:55:23 -05:00
portKnockSequence,
+1
2025-12-31 22:20:12 -06:00
overrideCredentialUsername,
+9
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,
+8
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,
+8
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;
+4
2026-02-12 22:28:13 -06:00
databaseLogger.info("Updating SSH host", {
operation: "host_update",
userId,
hostId: parseInt(hostId),
changes: Object.keys(hostData),
});
+1
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));
+7
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,
+1
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,
+1
2025-12-31 22:20:12 -06:00
enableDocker: enableDocker ? 1 : 0,
+2
2026-06-16 15:59:53 -05:00
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
+2
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,
+5
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,
+2
2026-06-16 15:59:53 -05:00
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
+5
2026-03-08 18:02:14 -05:00
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
+7
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,
+1
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,
+2
2026-07-26 18:47:27 -05:00
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
+9
2026-04-22 16:55:23 -05:00
macAddress: macAddress || null,
2026-06-21 15:55:41 -05:00
wolBroadcastAddress: wolBroadcastAddress || null,
+9
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,
+8
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,
+8
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;
+5
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) {
+5
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
}
+8
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;
+5
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 {
+1
2025-12-31 22:20:12 -06:00
const accessInfo = await permissionManager.canAccessHost(
userId,
Number(hostId),
+8
2026-07-19 12:29:52 -05:00
"edit",
+1
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" });
}
+8
2026-07-19 12:29:52 -05:00
const hostRecord =
await createCurrentHostResolutionRepository().findHostUpdateState(
Number(hostId),
);
+1
2025-12-31 22:20:12 -06:00
+8
2026-07-19 12:29:52 -05:00
if (!hostRecord) {
+1
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" });
}
+8
2026-07-19 12:29:52 -05:00
const ownerId = hostRecord.userId;
+1
2025-12-31 22:20:12 -06:00
+8
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",
],
];
+1
2025-12-31 22:20:12 -06:00
+8
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}`,
});
}
}
+1
2025-12-31 22:20:12 -06:00
+8
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",
});
+1
2025-12-31 22:20:12 -06:00
}
}
+8
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
+8
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
+8
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" });
}
const baseHost = transformHostResponse(updatedHost);
2025-09-12 14:42:00 -05:00
+1
2025-12-31 22:20:12 -06:00
const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
+4
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
+2
2026-06-16 15:59:53 -05:00
const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: await getAuditUsername(userId),
+2
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
+9
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 {
+1
2025-12-31 22:20:12 -06:00
const now = new Date().toISOString();
+8
2026-07-19 12:29:52 -05:00
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const accessEntries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
now,
);
+1
2025-12-31 22:20:12 -06:00
+8
2026-07-19 12:29:52 -05:00
const rawData =
await createCurrentHostResolutionRepository().listHostRowsForAccessList(
userId,
accessEntries,
+1
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",
});
}
}
+1
2025-12-31 22:20:12 -06:00
}
+8
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, "");
}
}
}
+1
2025-12-31 22:20:12 -06:00
+8
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 = {
...transformHostResponse(row),
+1
2025-12-31 22:20:12 -06:00
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
+8
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
+1
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
+8
2026-07-19 12:29:52 -05:00
const sanitized = result.map((host) =>
host.isShared
? sanitizeHostForRecipient(
host,
host.permissionLevel as string | undefined,
)
: stripSensitiveFields(host),
);
+9
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
+9
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;
+7
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 {
+8
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
+8
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
+8
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
+8
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
+9
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
+2
2026-07-26 18:47:27 -05:00
* enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
+9
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";
+2
2026-07-26 18:47:27 -05:00
if (
![
"password",
"sudoPassword",
"rdpPassword",
"vncPassword",
"telnetPassword",
"key",
"keyPassword",
].includes(field)
) {
+9
2026-04-22 16:55:23 -05:00
return res.status(400).json({ error: "Invalid field" });
}
try {
+8
2026-07-19 12:29:52 -05:00
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
+9
2026-04-22 16:55:23 -05:00
userId,
);
+8
2026-07-19 12:29:52 -05:00
if (!host) {
+9
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
}
}
+9
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;
+7
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 {
+8
2026-07-19 12:29:52 -05:00
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
Number(hostId),
userId,
);
2025-10-03 00:02:10 -05:00
+8
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" });
}
+1
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,
+5
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,
+8
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,
+8
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,
+2
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,
+2
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,
+9
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" });
}
},
);
+9
2026-04-22 16:55:23 -05:00
/**
* @openapi
+2
2026-06-16 15:59:53 -05:00
* /host/db/hosts/export:
+9
2026-04-22 16:55:23 -05:00
* get:
* summary: Export all SSH hosts
+2
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.
+9
2026-04-22 16:55:23 -05:00
* tags:
* - SSH
+2
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.
+9
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;
+2
2026-07-26 18:47:27 -05:00
const shareMode = req.query.share === "1" || req.query.share === "true";
+9
2026-04-22 16:55:23 -05:00
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
+8
2026-07-19 12:29:52 -05:00
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
2026-06-29 13:28:26 -05:00
+9
2026-04-22 16:55:23 -05:00
const exportedHosts = [];
+2
2026-07-26 18:47:27 -05:00
const usedCredentialIds = new Set<number>();
+9
2026-04-22 16:55:23 -05:00
for (const host of allHosts) {
+2
2026-07-26 18:47:27 -05:00
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
+9
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,
+2
2026-07-26 18:47:27 -05:00
password: shareMode ? null : resolvedHost.password || null,
+9
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,
+2
2026-07-26 18:47:27 -05:00
key: shareMode ? null : resolvedHost.key || null,
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
+9
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,
+9
2026-04-22 16:55:23 -05:00
enableDocker: !!resolvedHost.enableDocker,
+2
2026-06-16 15:59:53 -05:00
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
+9
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,
+2
2026-07-26 18:47:27 -05:00
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
+9
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,
+2
2026-06-16 15:59:53 -05:00
proxmoxConfig: resolvedHost.proxmoxConfig
? JSON.parse(resolvedHost.proxmoxConfig as string)
: null,
+9
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,
+2
2026-07-26 18:47:27 -05:00
socks5Password: shareMode
? null
: resolvedHost.socks5Password || null,
+9
2026-04-22 16:55:23 -05:00
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
+2
2026-07-26 18:47:27 -05:00
if (
shareMode &&
!isRemoteDesktop &&
resolvedHost.authType === "credential" &&
resolvedHost.credentialId
) {
usedCredentialIds.add(resolvedHost.credentialId as number);
}
+9
2026-04-22 16:55:23 -05:00
exportedHosts.push(exportData);
}
+2
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",
+9
2026-04-22 16:55:23 -05:00
count: exportedHosts.length,
+2
2026-07-26 18:47:27 -05:00
credentialCount: exportedCredentials.length,
+9
2026-04-22 16:55:23 -05:00
userId,
});
+2
2026-07-26 18:47:27 -05:00
res.json({
version: "1",
exportedAt: new Date().toISOString(),
credentials: exportedCredentials,
hosts: exportedHosts,
});
+9
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
+2
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) => {
+7
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
}
+4
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 {
+8
2026-07-19 12:29:52 -05:00
const hostToDelete =
await createCurrentHostResolutionRepository().findHostByIdForUser(
Number(hostId),
userId,
);
2025-09-12 14:42:00 -05:00
+8
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);
+8
2026-07-19 12:29:52 -05:00
await createCurrentFileManagerBookmarkRepository().deleteByHostId(
numericHostId,
);
2025-09-12 14:42:00 -05:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentTransferRecentRepository().deleteByHostId(
numericHostId,
);
2025-09-12 14:42:00 -05:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentCommandHistoryRepository().deleteByHostId(
numericHostId,
);
2025-11-17 09:46:05 -06:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
);
2026-06-04 15:16:53 -04:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentRecentActivityRepository().deleteByHostId(
numericHostId,
);
2025-09-12 14:42:00 -05:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
numericHostId,
);
2025-11-17 09:46:05 -06:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentSessionRecordingRepository().deleteByHostId(
numericHostId,
);
+1
2025-12-31 22:20:12 -06:00
+8
2026-07-19 12:29:52 -05:00
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
+2
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
+4
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
+2
2026-06-16 15:59:53 -05:00
const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: await getAuditUsername(userId),
+2
2026-06-16 15:59:53 -05:00
action: "delete_host",
resourceType: "host",
resourceId: hostId,
+8
2026-07-19 12:29:52 -05:00
resourceName: hostToDelete.name ?? hostToDelete.ip,
+2
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) => {
+7
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 {
+8
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) => {
+7
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 {
+8
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
+8
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
+7
2025-11-05 10:36:16 -06:00
async function resolveHostCredentials(
host: Record<string, unknown>,
+1
2025-12-31 22:20:12 -06:00
requestingUserId?: string,
+7
2025-11-05 10:36:16 -06:00
): Promise<Record<string, unknown>> {
2025-09-12 14:42:00 -05:00
try {
+1
2025-12-31 22:20:12 -06:00
if (host.credentialId && (host.userId || host.ownerId)) {
+7
2025-11-05 10:36:16 -06:00
const credentialId = host.credentialId as number;
+1
2025-12-31 22:20:12 -06:00
const ownerId = (host.ownerId || host.userId) as string;
if (requestingUserId && requestingUserId !== ownerId) {
try {
+8
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",
);
+1
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,
};
+2
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
+1
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",
},
);
}
}
+7
2025-11-05 10:36:16 -06:00
+8
2026-07-19 12:29:52 -05:00
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
ownerId,
);
2025-09-12 14:42:00 -05:00
+8
2026-07-19 12:29:52 -05:00
if (credential) {
+1
2025-12-31 22:20:12 -06:00
const resolvedHost: Record<string, unknown> = {
2025-09-12 14:42:00 -05:00
...host,
+8
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,
+5
2026-03-08 18:02:14 -05:00
keyPassword: credential.keyPassword,
keyType: credential.keyType,
2025-09-12 14:42:00 -05:00
};
+1
2025-12-31 22:20:12 -06:00
+2
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
+1
2025-12-31 22:20:12 -06:00
}
return resolvedHost;
2025-09-12 14:42:00 -05:00
}
}
+7
2025-11-05 10:36:16 -06:00
+5
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
+4
2026-02-12 22:28:13 -06:00
/**
* @openapi
+9
2026-04-22 16:55:23 -05:00
* /host/opkssh/token/{hostId}:
+4
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 {
+8
2026-07-19 12:29:52 -05:00
const opksshTokenRepository = createCurrentOpksshTokenRepository();
const tokenData = await opksshTokenRepository.findByUserAndHost(
userId,
hostId,
);
+4
2026-02-12 22:28:13 -06:00
+8
2026-07-19 12:29:52 -05:00
if (!tokenData) {
+4
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()) {
+8
2026-07-19 12:29:52 -05:00
await opksshTokenRepository.deleteByUserAndHost(userId, hostId);
+4
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
+9
2026-04-22 16:55:23 -05:00
* /host/opkssh/token/{hostId}:
+4
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 {
+8
2026-07-19 12:29:52 -05:00
const { deleteOPKSSHToken } = await import("../../hosts/opkssh-auth.js");
+4
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);
+9
2026-04-22 16:55:23 -05:00
2026-06-04 15:16:53 -04:00
registerHostNetworkRoutes(router, {
authenticateJWT,
requireDataAccess,
+4
2026-02-12 22:28:13 -06:00
});
2025-09-12 14:42:00 -05:00
export default router;