import { prepareHostImports, remapImportedJumpHosts, } from "./host-import-order.js"; import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; import { sshLogger } from "../../utils/logger.js"; import { createCurrentCredentialRepository, createCurrentHostRepository, createCurrentHostResolutionRepository, } from "../repositories/factory.js"; import { validateParentHostId } from "./host-parent-validation.js"; import { serializeWebUiConfig } from "./host-web-endpoints.js"; import { isNonEmptyString, isValidPort, normalizeImportedHost, } from "./host-normalizers.js"; type SSHConfigHost = { name: string; hostname?: string; user?: string; port?: number; identityFile?: string; proxyJump?: string; }; type ShareCredential = { alias?: unknown; name?: unknown; description?: unknown; folder?: unknown; tags?: unknown; authType?: unknown; username?: unknown; keyType?: unknown; }; function textValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function tagString(value: unknown): string { if (Array.isArray(value)) { return value .map((tag) => textValue(tag)) .filter((tag): tag is string => !!tag) .join(","); } return textValue(value) || ""; } function normalizeCredentialAuthType(value: unknown): "password" | "key" { return value === "key" ? "key" : "password"; } export function parseSSHConfig(content: string): SSHConfigHost[] { const results: SSHConfigHost[] = []; let current: SSHConfigHost | null = null; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const spaceIdx = line.indexOf(" "); if (spaceIdx === -1) continue; const key = line.slice(0, spaceIdx).toLowerCase(); const value = line.slice(spaceIdx + 1).trim(); if (key === "host") { if (current && current.hostname) results.push(current); // Skip wildcard patterns if (value === "*" || value.includes("*") || value.includes("?")) { current = null; } else { current = { name: value }; } continue; } if (!current) continue; switch (key) { case "hostname": current.hostname = value; break; case "user": current.user = value; break; case "port": { const p = Number.parseInt(value, 10); if (p > 0 && p <= 65535) current.port = p; break; } case "identityfile": if (!current.identityFile) current.identityFile = value; break; case "proxyjump": current.proxyJump = value; break; } } if (current && current.hostname) results.push(current); return results; } export function importedHostUsername( connectionType: string, authType: unknown, username: unknown, ): string | null { if (isNonEmptyString(username)) return username; if (connectionType !== "ssh" || authType === "credential") return ""; return null; } export function registerHostBulkRoutes( router: Router, authenticateJWT: RequestHandler, requireCreatePermission: RequestHandler, requireEditPermission: RequestHandler, requireDataAccess: RequestHandler, ): void { /** * @openapi * /host/bulk-import: * post: * summary: Bulk import SSH hosts * description: Bulk imports multiple SSH hosts. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hosts: * type: array * items: * type: object * responses: * 200: * description: Import completed. * 400: * description: Invalid request body. */ /** * @swagger * /host/bulk-update: * patch: * summary: Bulk update partial fields on multiple SSH hosts * tags: [SSH] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostIds: * type: array * items: * type: number * updates: * type: object * description: Partial fields to apply. Setting folder clears parentHostId and vice versa, since a host is either in a folder or nested under a parent host. * properties: * folder: * type: string * parentHostId: * type: integer * nullable: true * responses: * 200: * description: Bulk update completed. * 400: * description: Invalid request body. */ router.patch( "/bulk-update", authenticateJWT, requireEditPermission, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostIds, updates } = req.body; if (!Array.isArray(hostIds) || hostIds.length === 0) { return res .status(400) .json({ error: "hostIds array is required and must not be empty" }); } if (hostIds.length > 1000) { return res .status(400) .json({ error: "Maximum 1000 hosts allowed per bulk update" }); } if ( !updates || typeof updates !== "object" || Object.keys(updates).length === 0 ) { return res.status(400).json({ error: "updates object is required and must contain at least one field", }); } try { const hostRepository = createCurrentHostRepository(); const ownedHosts = await hostRepository.listBulkUpdateState( userId, hostIds, ); const ownedIds = ownedHosts.map((h) => h.id); const unauthorizedIds = hostIds.filter( (id: number) => !ownedIds.includes(id), ); if (ownedIds.length === 0) { return res.status(404).json({ error: "No matching hosts found" }); } const errors: string[] = []; if (unauthorizedIds.length > 0) { errors.push( `${unauthorizedIds.length} host(s) not found or not owned`, ); } const simpleUpdates: Record = {}; if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin; if (typeof updates.folder === "string") { simpleUpdates.folder = updates.folder || null; // Folder placement and parent-host placement are mutually // exclusive -- assigning a folder (including moving to root, an // empty folder) clears any parent host, matching the single-host // update route's behavior. simpleUpdates.parentHostId = null; } if (updates.parentHostId !== undefined) { if (updates.parentHostId === null) { simpleUpdates.parentHostId = null; } else { const numericParentHostId = Number(updates.parentHostId); if (!Number.isInteger(numericParentHostId)) { return res.status(400).json({ error: "Invalid parent host" }); } // A bulk move can only ever target one parent host at a time // (the caller drags a selection onto one drop target), so every // id in the batch is checked against the same candidate parent. for (const id of ownedIds) { const parentError = await validateParentHostId( userId, id, numericParentHostId, ); if (parentError) { return res.status(400).json({ error: parentError }); } } simpleUpdates.parentHostId = numericParentHostId; simpleUpdates.folder = null; } } if (typeof updates.enableTerminal === "boolean") simpleUpdates.enableTerminal = updates.enableTerminal; if (typeof updates.enableTunnel === "boolean") simpleUpdates.enableTunnel = updates.enableTunnel; if (typeof updates.enableFileManager === "boolean") simpleUpdates.enableFileManager = updates.enableFileManager; if (typeof updates.enableDocker === "boolean") simpleUpdates.enableDocker = updates.enableDocker; if (typeof updates.enableWebUi === "boolean") { simpleUpdates.enableWebUi = updates.enableWebUi; if (!updates.enableWebUi) simpleUpdates.webUiConfig = null; } if (typeof updates.enableTmuxMonitor === "boolean") simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor; if (typeof updates.enableTerminalToolbar === "boolean") simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar; if (typeof updates.enableAiAssistant === "boolean") simpleUpdates.enableAiAssistant = updates.enableAiAssistant; // Disabling Proxmox is a plain flag flip; enabling is handled per-host // below so each host can default to its own stored credential. if (updates.enableProxmox === false) simpleUpdates.enableProxmox = false; if (Object.keys(simpleUpdates).length > 0) { await hostRepository.updateManyForUser( userId, ownedIds, simpleUpdates, ); } if (updates.statsConfig && typeof updates.statsConfig === "object") { for (const host of ownedHosts) { try { const existing = host.statsConfig ? JSON.parse(host.statsConfig as string) : {}; const merged = { ...existing, ...updates.statsConfig }; await hostRepository.updateForUser(userId, host.id, { statsConfig: JSON.stringify(merged), }); } catch { errors.push(`Failed to update statsConfig for host ${host.id}`); } } } // Enabling Proxmox needs per-host handling: each host defaults its // Proxmox credential to the credential already stored on that host, so // discovery works right away without picking one by hand. Existing // proxmoxConfig values are preserved. if (updates.enableProxmox === true) { for (const host of ownedHosts) { try { const existing = host.proxmoxConfig ? JSON.parse(host.proxmoxConfig as string) : {}; const merged = { defaultCredentialId: existing.defaultCredentialId ?? host.credentialId ?? null, windowsPatterns: existing.windowsPatterns ?? "win, windows", dockerPatterns: existing.dockerPatterns ?? "docker", preferredPrefixes: existing.preferredPrefixes ?? "10., 192.168.", autoSyncEnabled: existing.autoSyncEnabled ?? false, syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, markMissingGuests: existing.markMissingGuests ?? true, }; await hostRepository.updateForUser(userId, host.id, { enableProxmox: true, proxmoxConfig: JSON.stringify(merged), }); } catch { errors.push(`Failed to enable Proxmox for host ${host.id}`); } } } return res.json({ updated: ownedIds.length, failed: unauthorizedIds.length, errors, }); } catch (error) { sshLogger.error("Failed to bulk update hosts:", error); return res.status(500).json({ error: "Failed to bulk update hosts" }); } }, ); /** * @openapi * /host/reorder: * put: * summary: Reorder hosts * description: Sets a manual sortOrder for multiple hosts within the same folder, used by drag-to-reorder in the sidebar's manual sort mode. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * positions: * type: array * items: * type: object * properties: * id: * type: integer * sortOrder: * type: integer * responses: * 200: * description: Hosts reordered successfully. * 400: * description: Invalid positions array. * 500: * description: Failed to reorder hosts. */ router.put( "/reorder", authenticateJWT, requireEditPermission, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { positions } = req.body as { positions?: { id?: unknown; sortOrder?: unknown }[]; }; if (!Array.isArray(positions)) { return res.status(400).json({ error: "positions array is required" }); } const normalized: { id: number; sortOrder: number }[] = []; for (const entry of positions) { if ( typeof entry?.id !== "number" || !Number.isInteger(entry.id) || typeof entry.sortOrder !== "number" || !Number.isFinite(entry.sortOrder) ) { return res.status(400).json({ error: "Each position requires an integer id and a numeric sortOrder", }); } normalized.push({ id: entry.id, sortOrder: entry.sortOrder }); } if (normalized.length === 0) { return res.status(400).json({ error: "positions array is required" }); } try { const updated = await createCurrentHostRepository().reorderForUser( userId, normalized, ); return res.json({ updated }); } catch (error) { sshLogger.error("Failed to reorder hosts:", error); return res.status(500).json({ error: "Failed to reorder hosts" }); } }, ); router.post( "/bulk-import", authenticateJWT, requireCreatePermission, requireEditPermission, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hosts: hostsToImport, overwrite, credentials: credentialsToImport, } = req.body; if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { return res .status(400) .json({ error: "Hosts array is required and must not be empty" }); } if (hostsToImport.length > 100) { return res .status(400) .json({ error: "Maximum 100 hosts allowed per import" }); } let orderedHosts: ReturnType; try { orderedHosts = prepareHostImports(hostsToImport); } catch (error) { return res.status(400).json({ error: getErrorMessage(error) }); } const importedIds = new Map(); const results = { success: 0, updated: 0, skipped: 0, failed: 0, errors: [] as string[], }; const credentialAliasMap = new Map(); const addCredentialAlias = (alias: unknown, id: number) => { const key = textValue(alias); if (key) credentialAliasMap.set(key.toLowerCase(), id); }; try { const credentialRepository = createCurrentCredentialRepository(); const existingCredentials = await credentialRepository.listDecryptedByUserId(userId); for (const credential of existingCredentials) { addCredentialAlias(credential.name, credential.id as number); } if (Array.isArray(credentialsToImport)) { for (const rawCredential of credentialsToImport as ShareCredential[]) { const alias = textValue(rawCredential.alias); const name = textValue(rawCredential.name) || alias; if (!alias || !name) continue; const existingId = credentialAliasMap.get(name.toLowerCase()); if (existingId) { addCredentialAlias(alias, existingId); continue; } const now = new Date().toISOString(); const created = await credentialRepository.createEncryptedForUser( userId, { userId, name, description: textValue(rawCredential.description) || "Imported placeholder. Add the secret before connecting.", folder: textValue(rawCredential.folder), tags: tagString(rawCredential.tags), authType: normalizeCredentialAuthType(rawCredential.authType), username: textValue(rawCredential.username), password: null, key: null, privateKey: null, publicKey: null, keyPassword: null, keyType: textValue(rawCredential.keyType), detectedKeyType: null, usageCount: 0, lastUsed: null, createdAt: now, updatedAt: now, }, ); const createdCredential = created as Record; addCredentialAlias(alias, createdCredential.id as number); addCredentialAlias(name, createdCredential.id as number); } } } catch (error) { results.errors.push( `Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`, ); } let existingHostMap: Map | undefined; const hostRepository = createCurrentHostRepository(); if (overwrite) { try { const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId( userId, ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number }); } } catch { existingHostMap = undefined; } } for (const { host: hostData, index: i, exportId } of orderedHosts) { try { const effectiveConnectionType = hostData.connectionType || "ssh"; if ( effectiveConnectionType === "ssh" && hostData.authType === "credential" && !hostData.credentialId && hostData.credentialAlias ) { hostData.credentialId = credentialAliasMap.get( hostData.credentialAlias.toLowerCase(), ); } if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { results.failed++; results.errors.push( `Host ${i + 1}: Missing required fields (ip, port)`, ); continue; } const username = importedHostUsername( effectiveConnectionType, hostData.authType, hostData.username, ); if (username === null) { results.failed++; results.errors.push( `Host ${i + 1}: Username required for SSH connections`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType && ![ "password", "key", "credential", "none", "opkssh", "stepca", "tailscale", "vault", ].includes(hostData.authType) ) { results.failed++; results.errors.push( `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "password" && !isNonEmptyString(hostData.password) ) { results.failed++; results.errors.push( `Host ${i + 1}: Password required for password authentication`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "key" && !isNonEmptyString(hostData.key) ) { results.failed++; results.errors.push( `Host ${i + 1}: Key required for key authentication`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "credential" && !hostData.credentialId ) { results.failed++; results.errors.push( `Host ${i + 1}: credentialId required for credential authentication`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "credential" && hostData.credentialId ) { const credentialRepository = createCurrentCredentialRepository(); const cred = await credentialRepository.findByIdForUser( userId, hostData.credentialId, ); if (!cred) { const fallback = await credentialRepository.listByUserId(userId); if (fallback.length > 0) { hostData.credentialId = fallback[0].id; } else if (isNonEmptyString(hostData.key)) { hostData.authType = "key"; hostData.credentialId = undefined; } else if (isNonEmptyString(hostData.password)) { hostData.authType = "password"; hostData.credentialId = undefined; } else { results.failed++; results.errors.push( `Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`, ); continue; } } } const jumpHosts = remapImportedJumpHosts( hostData.jumpHosts, importedIds, ); const sshDataObj: Record = { userId: userId, connectionType: effectiveConnectionType, name: hostData.name || `${username}@${hostData.ip}`, folder: hostData.folder || "Default", tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", ip: hostData.ip, port: hostData.port, username, pin: hostData.pin || false, enableTerminal: hostData.enableTerminal !== false, enableTunnel: hostData.enableTunnel !== false, enableFileManager: hostData.enableFileManager !== false, enableDocker: hostData.enableDocker || false, enableWebUi: hostData.enableWebUi || false, enableProxmox: hostData.enableProxmox || false, enableTmuxMonitor: hostData.enableTmuxMonitor || false, enableTerminalToolbar: hostData.enableTerminalToolbar !== false, enableAiAssistant: hostData.enableAiAssistant || false, enableCommandHistory: hostData.enableCommandHistory !== false, showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0, showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0, defaultPath: hostData.defaultPath || "/", sudoPassword: hostData.sudoPassword || null, tunnelConnections: hostData.tunnelConnections ? JSON.stringify(hostData.tunnelConnections) : "[]", jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, quickActions: hostData.quickActions ? JSON.stringify(hostData.quickActions) : null, statsConfig: hostData.statsConfig ? JSON.stringify(hostData.statsConfig) : null, dockerConfig: hostData.dockerConfig ? JSON.stringify(hostData.dockerConfig) : null, webUiConfig: hostData.enableWebUi ? serializeWebUiConfig(hostData.webUiConfig) : null, proxmoxConfig: hostData.proxmoxConfig ? JSON.stringify(hostData.proxmoxConfig) : null, terminalConfig: hostData.terminalConfig ? JSON.stringify(hostData.terminalConfig) : null, forceKeyboardInteractive: hostData.forceKeyboardInteractive ? "true" : "false", notes: hostData.notes || null, useSocks5: hostData.useSocks5 ? 1 : 0, socks5Host: hostData.socks5Host || null, socks5Port: hostData.socks5Port || null, socks5Username: hostData.socks5Username || null, socks5Password: hostData.socks5Password || null, socks5ProxyChain: hostData.socks5ProxyChain ? JSON.stringify(hostData.socks5ProxyChain) : null, portKnockSequence: hostData.portKnockSequence ? JSON.stringify(hostData.portKnockSequence) : null, overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh", enableRdp: hostData.enableRdp ?? false, enableVnc: hostData.enableVnc ?? false, enableTelnet: hostData.enableTelnet ?? false, updatedAt: new Date().toISOString(), }; if (effectiveConnectionType !== "ssh") { sshDataObj.password = hostData.password || null; sshDataObj.authType = "password"; sshDataObj.credentialId = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; sshDataObj.rdpUser = hostData.rdpUser || null; sshDataObj.rdpPassword = hostData.rdpPassword || null; sshDataObj.rdpDomain = hostData.rdpDomain || null; sshDataObj.rdpSecurity = hostData.rdpSecurity || null; sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0; sshDataObj.rdpPort = hostData.rdpPort || 3389; sshDataObj.vncUser = hostData.vncUser || null; sshDataObj.vncPassword = hostData.vncPassword || null; sshDataObj.vncPort = hostData.vncPort || 5900; sshDataObj.telnetUser = hostData.telnetUser || null; sshDataObj.telnetPassword = hostData.telnetPassword || null; sshDataObj.telnetPort = hostData.telnetPort || 23; sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0; sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0; sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0; sshDataObj.guacamoleConfig = hostData.guacamoleConfig ? JSON.stringify(hostData.guacamoleConfig) : null; } else { sshDataObj.password = hostData.authType === "password" ? hostData.password : null; sshDataObj.authType = hostData.authType || "password"; sshDataObj.credentialId = hostData.authType === "credential" ? hostData.credentialId : null; sshDataObj.key = hostData.authType === "key" ? hostData.key : null; sshDataObj.keyPassword = hostData.authType === "key" ? hostData.keyPassword || null : null; sshDataObj.keyType = hostData.authType === "key" ? hostData.keyType || "auto" : null; sshDataObj.domain = null; sshDataObj.security = null; sshDataObj.ignoreCert = 0; sshDataObj.guacamoleConfig = null; } const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; const existing = existingHostMap?.get(lookupKey); if (existing) { const saved = await hostRepository.updateEncryptedForUser( userId, existing.id, sshDataObj, ); if (!saved) throw new Error("Host no longer exists"); if (exportId !== undefined) importedIds.set(exportId, existing.id); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); const saved = await hostRepository.createEncryptedForUser( userId, sshDataObj, ); if (exportId !== undefined) importedIds.set(exportId, saved.id); results.success++; } } catch (error) { results.failed++; results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); } } res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, updated: results.updated, skipped: results.skipped, failed: results.failed, errors: results.errors, }); }, ); /** * @openapi * /host/ssh-config-import: * post: * summary: Import hosts from an OpenSSH config file * description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - content * properties: * content: * type: string * description: Raw text content of the SSH config file. * overwrite: * type: boolean * responses: * 200: * description: Import completed. * 400: * description: Invalid request body. */ router.post( "/ssh-config-import", authenticateJWT, requireCreatePermission, requireEditPermission, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { content, overwrite } = req.body; if (!isNonEmptyString(content)) { return res.status(400).json({ error: "content is required and must be a non-empty string", }); } let parsed: SSHConfigHost[]; try { parsed = parseSSHConfig(content); } catch { return res .status(400) .json({ error: "Failed to parse SSH config file" }); } if (parsed.length === 0) { return res.status(400).json({ error: "No valid Host entries found in the SSH config file", }); } if (parsed.length > 100) { return res .status(400) .json({ error: "Maximum 100 hosts allowed per import" }); } const hostsToImport = parsed.map((h) => ({ name: h.name, ip: h.hostname, port: h.port ?? 22, username: h.user, authType: h.identityFile ? "key" : undefined, connectionType: "ssh", enableSsh: true, ...(h.proxyJump ? { jumpHosts: [{ host: h.proxyJump, port: 22 }], } : {}), })); const results = { success: 0, updated: 0, skipped: 0, failed: 0, errors: [] as string[], }; let existingHostMap: Map | undefined; const hostRepository = createCurrentHostRepository(); if (overwrite) { try { const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId( userId, ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number }); } } catch { existingHostMap = undefined; } } for (let i = 0; i < hostsToImport.length; i++) { const hostData = normalizeImportedHost( hostsToImport[i] as Record, ); try { if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { results.failed++; results.errors.push( `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, ); continue; } const sshDataObj: Record = { userId, connectionType: "ssh", name: hostData.name || hostData.ip, folder: "Default", tags: "", ip: hostData.ip, port: hostData.port, username: hostData.username || null, authType: hostData.authType || "none", password: null, key: null, keyPassword: null, keyType: null, credentialId: null, pin: false, enableTerminal: true, enableTunnel: true, enableFileManager: true, enableDocker: false, enableWebUi: false, enableProxmox: false, enableTmuxMonitor: false, enableTerminalToolbar: true, enableAiAssistant: false, showTerminalInSidebar: 0, showFileManagerInSidebar: 0, showTunnelInSidebar: 0, showDockerInSidebar: 0, showServerStatsInSidebar: 0, defaultPath: "/", sudoPassword: null, tunnelConnections: "[]", jumpHosts: hostData.jumpHosts ? JSON.stringify(hostData.jumpHosts) : null, quickActions: null, statsConfig: null, dockerConfig: null, webUiConfig: null, proxmoxConfig: null, terminalConfig: null, forceKeyboardInteractive: "false", notes: null, useSocks5: 0, socks5Host: null, socks5Port: null, socks5Username: null, socks5Password: null, socks5ProxyChain: null, portKnockSequence: null, overrideCredentialUsername: 0, enableSsh: true, enableRdp: false, enableVnc: false, enableTelnet: false, updatedAt: new Date().toISOString(), }; const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; const existing = existingHostMap?.get(lookupKey); if (existing) { await hostRepository.updateEncryptedForUser( userId, existing.id, sshDataObj, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); await hostRepository.createEncryptedForUser(userId, sshDataObj); results.success++; } } catch (error) { results.failed++; results.errors.push( `Host "${parsed[i].name}": ${getErrorMessage(error)}`, ); } } res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, updated: results.updated, skipped: results.skipped, failed: results.failed, errors: results.errors, }); }, ); }