import { prepareHostImports, remapImportedJumpHosts, } from "./host-import-order.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; import { keepUsableProtocolCredentials, readProtocolAuthPayload, writeProtocolAuth, } from "../../hosts/protocol-auth/protocol-auth.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 { listSshAuthProviders, listSshAuthTypeOwners, } from "../../hosts/connect/auth-provider-registry.js"; import { applyPluginHostImportSettings, setHostPluginEnabled, } from "./host-plugin-settings.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 * statusCheckEnabled: * type: boolean * pin: * type: boolean * pluginEnable: * type: object * description: Plugin id to on or off, for each plugin that declares a host enable switch. * additionalProperties: * type: boolean * 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.statusCheckEnabled === "boolean") simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled; if (Object.keys(simpleUpdates).length > 0) { await hostRepository.updateManyForUser( userId, ownedIds, simpleUpdates, ); } // Each plugin's own host switch, by plugin id: the field its manifest // names in contributes.settings.host.enableKey. if (updates.pluginEnable && typeof updates.pluginEnable === "object") { for (const [pluginId, enabled] of Object.entries( updates.pluginEnable as Record, )) { if (typeof enabled !== "boolean") continue; if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) { errors.push(`Plugin ${pluginId} has no host switch`); } } } 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; } } const knownAuthTypes = listKnownAuthTypes(); 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 && !knownAuthTypes.has(hostData.authType) ) { results.failed++; results.errors.push( `Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`, ); 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, sudoPassword: hostData.sudoPassword || null, jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, ...importedStatusCheck(hostData as Record), terminalConfig: hostData.terminalConfig ? JSON.stringify(hostData.terminalConfig) : null, // A 2.8 export carries these inside terminalConfig. sshOptions: sshOptionsForWrite({ sshOptions: hostData.sshOptions, terminalConfig: 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", 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; } 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; } const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; const existing = existingHostMap?.get(lookupKey); let savedHostId: number; 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); savedHostId = 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); savedHostId = saved.id; results.success++; } const protocolAuth = readProtocolAuthPayload( hostData as Record, ); if (protocolAuth) { await writeProtocolAuth( userId, savedHostId, await keepUsableProtocolCredentials(protocolAuth, userId), { isOwner: true }, ); } // Every enabled plugin that declares host-scope settings and // registered a hostImportNormalizer validates and writes its own // fields here, so this loop does not need to know which plugins // exist. See host-plugin-settings.ts. await applyPluginHostImportSettings( savedHostId, hostData as Record, ); } 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, sudoPassword: null, jumpHosts: hostData.jumpHosts ? JSON.stringify(hostData.jumpHosts) : null, statusCheckEnabled: true, statusCheckInterval: null, terminalConfig: null, sshOptions: null, forceKeyboardInteractive: "false", notes: null, useSocks5: 0, socks5Host: null, socks5Port: null, socks5Username: null, socks5Password: null, socks5ProxyChain: null, portKnockSequence: null, overrideCredentialUsername: 0, enableSsh: true, 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, }); }, ); } /** * Status check fields from an import row. Exports from before 2.9.0 carried * them inside statsConfig. */ function importedStatusCheck(raw: Record): { statusCheckEnabled: boolean; statusCheckInterval: number | null; } { const legacy = raw.statsConfig && typeof raw.statsConfig === "object" ? (raw.statsConfig as Record) : {}; const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled; const useGlobal = legacy.useGlobalStatusInterval !== false; const interval = raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval); const seconds = Number(interval); return { statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true, statusCheckInterval: interval != null && Number.isInteger(seconds) && seconds >= 5 ? seconds : null, }; } const BUILTIN_SSH_AUTH_TYPES = [ "password", "key", "credential", "agent", "none", ]; /** Core's own types plus every type a plugin registers or declares. */ function listKnownAuthTypes(): Set { return new Set([ ...BUILTIN_SSH_AUTH_TYPES, ...listSshAuthProviders().map((provider) => provider.type), ...listSshAuthTypeOwners().map((owner) => owner.type), ]); }