import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } export function isValidPort(port: unknown): port is number { return typeof port === "number" && port > 0 && port <= 65535; } export function isOptionalBoolean( value: unknown, ): value is boolean | undefined { return value === undefined || typeof value === "boolean"; } export const OWNER_PRIVATE_AUTH_FIELDS = { ssh: [ "authType", "authMethod", "credentialId", "vaultProfileId", "overrideCredentialUsername", "shareSshAuth", "password", "key", "keyPassword", "keyType", "sudoPassword", ], rdp: [ "rdpAuthType", "rdpCredentialId", "rdpUser", "rdpPassword", "rdpDomain", ], vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"], telnet: [ "telnetAuthType", "telnetCredentialId", "telnetUser", "telnetPassword", ], } as const satisfies Record; export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [ "sudoPassword", "agentSocketPath", ] as const; export function containsOwnerPrivateAuthUpdate( hostData: Record, protocol: AuthOverrideProtocol, ): boolean { return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) => Object.prototype.hasOwnProperty.call(hostData, field), ); } export const FOLDER_PATH_SEPARATOR = " / "; /** * Re-paths a folder string when its ancestor folder is renamed. Returns the new * path for an exact match or any nested child, or null when the path is unrelated. * Mirrors the SQL CASE expression used in the folder rename route. */ export function renameFolderPath( folderPath: string, oldName: string, newName: string, ): string | null { if (folderPath === oldName) return newName; const prefix = `${oldName}${FOLDER_PATH_SEPARATOR}`; if (folderPath.startsWith(prefix)) { return `${newName}${FOLDER_PATH_SEPARATOR}${folderPath.slice(prefix.length)}`; } return null; } function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function asPort(value: unknown): number | undefined { const port = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN; return isValidPort(port) ? port : undefined; } function asInteger(value: unknown): number | undefined { const number = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN; return Number.isInteger(number) ? number : undefined; } function asBoolean(value: unknown, fallback = false): boolean { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (["true", "1", "yes", "on"].includes(normalized)) return true; if (["false", "0", "no", "off"].includes(normalized)) return false; } return fallback; } function normalizeImportTags(value: unknown): string[] { if (Array.isArray(value)) { return value .map((tag) => asString(tag)) .filter((tag): tag is string => !!tag); } if (typeof value === "string") { return value .split(",") .map((tag) => tag.trim()) .filter(Boolean); } return []; } export type NormalizedImportedHost = Record & { connectionType: string; name?: string; ip?: string; port: number; username?: string; folder?: string; tags: string[]; authType?: string; password?: string; key?: string; keyPassword?: string; keyType?: string; credentialId?: number; credentialAlias?: string; pin?: unknown; enableTerminal?: unknown; enableTunnel?: unknown; enableFileManager?: unknown; enableDocker?: unknown; enableProxmox?: unknown; enableTmuxMonitor?: unknown; enableTerminalToolbar?: unknown; showTerminalInSidebar?: unknown; showFileManagerInSidebar?: unknown; showTunnelInSidebar?: unknown; showDockerInSidebar?: unknown; showServerStatsInSidebar?: unknown; defaultPath?: unknown; sudoPassword?: unknown; tunnelConnections?: unknown; jumpHosts?: unknown; quickActions?: unknown; statsConfig?: unknown; dockerConfig?: unknown; proxmoxConfig?: unknown; enableProxmoxStats?: unknown; proxmoxStatsConfig?: unknown; terminalConfig?: unknown; forceKeyboardInteractive?: unknown; notes?: unknown; useSocks5?: unknown; socks5Host?: unknown; socks5Port?: unknown; socks5Username?: unknown; socks5Password?: unknown; socks5ProxyChain?: unknown; portKnockSequence?: unknown; overrideCredentialUsername?: unknown; domain?: unknown; security?: unknown; ignoreCert?: unknown; guacamoleConfig?: unknown; enableSsh: boolean; enableRdp: boolean; enableVnc: boolean; enableTelnet: boolean; }; export function normalizeImportedHost( hostData: Record, ): NormalizedImportedHost { const credentialAlias = asString(hostData.credentialAlias) || asString(hostData.credentialName); const connectionType = asString(hostData.connectionType) || (asBoolean(hostData.enableRdp) ? "rdp" : asBoolean(hostData.enableVnc) ? "vnc" : asBoolean(hostData.enableTelnet) ? "telnet" : "ssh"); const port = asPort(hostData.port) || (connectionType === "rdp" ? asPort(hostData.rdpPort) || 3389 : connectionType === "vnc" ? asPort(hostData.vncPort) || 5900 : connectionType === "telnet" ? asPort(hostData.telnetPort) || 23 : asPort(hostData.sshPort) || 22); return { ...hostData, connectionType, name: asString(hostData.name) || asString(hostData.label), ip: asString(hostData.ip) || asString(hostData.address) || asString(hostData.host) || asString(hostData.hostname), port, username: asString(hostData.username) || asString(hostData.user), folder: asString(hostData.folder) || asString(hostData.group), tags: normalizeImportTags(hostData.tags), credentialId: asInteger(hostData.credentialId), credentialAlias, authType: asString(hostData.authType) || asString(hostData.authMethod) || (hostData.credentialId || credentialAlias ? "credential" : hostData.key ? "key" : undefined), enableSsh: hostData.enableSsh === undefined ? connectionType === "ssh" : asBoolean(hostData.enableSsh), enableRdp: hostData.enableRdp === undefined ? connectionType === "rdp" : asBoolean(hostData.enableRdp), enableVnc: hostData.enableVnc === undefined ? connectionType === "vnc" : asBoolean(hostData.enableVnc), enableTelnet: hostData.enableTelnet === undefined ? connectionType === "telnet" : asBoolean(hostData.enableTelnet), }; } const SENSITIVE_FIELDS = [ "key", "keyPassword", "autostartKey", "autostartKeyPassword", "password", "sudoPassword", "socks5Password", "rdpPassword", "vncPassword", "telnetPassword", "autostartPassword", ]; export function stripSensitiveFields( host: Record, ): Record { const result = { ...host }; const terminalConfigForSudo = host.terminalConfig && typeof host.terminalConfig === "object" && !Array.isArray(host.terminalConfig) ? (host.terminalConfig as Record) : undefined; result.hasKey = !!host.key; result.hasKeyPassword = !!host.keyPassword; result.hasPassword = !!host.password; result.hasSudoPassword = !!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword; result.hasRdpPassword = !!host.rdpPassword; result.hasVncPassword = !!host.vncPassword; result.hasTelnetPassword = !!host.telnetPassword; for (const field of SENSITIVE_FIELDS) { delete result[field]; } if ( result.terminalConfig && typeof result.terminalConfig === "object" && !Array.isArray(result.terminalConfig) ) { const terminalConfig = { ...(result.terminalConfig as Record), }; delete terminalConfig.sudoPassword; result.terminalConfig = terminalConfig; } return result; } // Connection essentials a connect-level recipient is allowed to see. const CONNECT_LEVEL_FIELDS = new Set([ "id", "userId", "ownerId", "ownerUsername", "isShared", "permissionLevel", "sharedExpiresAt", "name", "ip", "port", "username", "folder", "tags", "pin", "authType", "shareSshAuth", "authOverrides", "connectionType", "enableTerminal", "enableTunnel", "enableFileManager", "enableDocker", "enableProxmox", "enableProxmoxStats", "enableTmuxMonitor", "enableTerminalToolbar", "showTerminalInSidebar", "showFileManagerInSidebar", "showTunnelInSidebar", "showDockerInSidebar", "showServerStatsInSidebar", "enableSsh", "enableRdp", "enableVnc", "enableTelnet", "sshPort", "rdpPort", "vncPort", "telnetPort", "defaultPath", "scpLegacy", "tunnelConnections", "jumpHosts", "createdAt", "updatedAt", ]); /** * Shapes a shared host row for its recipient. Secrets are always stripped * (all levels); connect-level recipients are additionally reduced to * connection essentials since they may not view the host's configuration. */ export function sanitizeHostForRecipient( host: Record, permissionLevel: string | undefined, ): Record { const stripped = stripSensitiveFields(host); delete stripped.credentialId; delete stripped.overrideCredentialUsername; // Sub-host nesting is per-owner tree structure; a recipient generally // can't see (or share permission on) the parent host row, so a shared // host always renders at root rather than leaking another host's id. delete stripped.parentHostId; if ( stripped.terminalConfig && typeof stripped.terminalConfig === "object" && !Array.isArray(stripped.terminalConfig) ) { const terminalConfig = { ...(stripped.terminalConfig as Record), }; delete terminalConfig.agentSocketPath; stripped.terminalConfig = terminalConfig; } const authOverrides = stripped.authOverrides && typeof stripped.authOverrides === "object" && !Array.isArray(stripped.authOverrides) ? (stripped.authOverrides as Record) : undefined; const sshOverride = authOverrides?.ssh && typeof authOverrides.ssh === "object" && !Array.isArray(authOverrides.ssh) ? (authOverrides.ssh as Record) : undefined; if (!sshOverride?.credentialId) { stripped.hasPassword = false; stripped.hasKey = false; stripped.hasKeyPassword = false; stripped.hasSudoPassword = false; } if (permissionLevel !== "connect") { return stripped; } const reduced: Record = {}; for (const [key, value] of Object.entries(stripped)) { if (CONNECT_LEVEL_FIELDS.has(key)) { reduced[key] = value; } } return reduced; } export function transformHostResponse( host: Record, ): Record { return { ...host, tags: typeof host.tags === "string" ? host.tags ? host.tags.split(",").filter(Boolean) : [] : [], pin: !!host.pin, shareSshAuth: !!host.shareSshAuth, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: host.enableFileManager !== false, enableDocker: !!host.enableDocker, enableProxmox: !!host.enableProxmox, enableProxmoxStats: !!host.enableProxmoxStats, enableTmuxMonitor: !!host.enableTmuxMonitor, enableTerminalToolbar: host.enableTerminalToolbar !== false, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, // Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet. // The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH. // Detect this migration case: if no non-SSH protocol is explicitly enabled AND // connectionType is set to a non-SSH value, fall back to inferring from connectionType. ...(() => { const ct = host.connectionType; const rdp = !!host.enableRdp; const vnc = !!host.enableVnc; const tel = !!host.enableTelnet; const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh"; return { enableSsh: isMigratedNonSsh ? false : !!host.enableSsh, enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp, enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc, enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel, }; })(), sshPort: host.sshPort ?? host.port ?? 22, rdpPort: host.rdpPort ?? 3389, vncPort: host.vncPort ?? 5900, telnetPort: host.telnetPort ?? 23, rdpUser: host.rdpUser || undefined, rdpDomain: host.rdpDomain || undefined, rdpSecurity: host.rdpSecurity || undefined, rdpIgnoreCert: !!host.rdpIgnoreCert, vncUser: host.vncUser || undefined, telnetUser: host.telnetUser || undefined, tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], quickActions: host.quickActions ? JSON.parse(host.quickActions as string) : [], statsConfig: host.statsConfig ? JSON.parse(host.statsConfig as string) : undefined, terminalConfig: host.terminalConfig ? JSON.parse(host.terminalConfig as string) : undefined, dockerConfig: host.dockerConfig ? JSON.parse(host.dockerConfig as string) : undefined, proxmoxConfig: host.proxmoxConfig ? JSON.parse(host.proxmoxConfig as string) : undefined, proxmoxStatsConfig: host.proxmoxStatsConfig ? JSON.parse(host.proxmoxStatsConfig as string) : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", useWarpgate: !!host.useWarpgate, socks5ProxyChain: host.socks5ProxyChain ? JSON.parse(host.socks5ProxyChain as string) : [], portKnockSequence: host.portKnockSequence ? JSON.parse(host.portKnockSequence as string) : [], domain: host.domain || undefined, security: host.security || undefined, ignoreCert: !!host.ignoreCert, guacamoleConfig: host.guacamoleConfig ? JSON.parse(host.guacamoleConfig as string) : undefined, }; }