import { TERMINAL_THEMES } from "@/lib/terminal-themes"; import type { Host } from "@/types/ui-types"; import type { SSHHostData } from "@/types"; import type { HostDefaults } from "@/api/settings-api"; import type { RemoteDesktopDefaults, TerminalDefaults, } from "@/lib/connection-defaults"; type HostSocks5ProxyNode = NonNullable[number]; export type HostProtocols = { enableSsh: boolean; enableRdp: boolean; enableVnc: boolean; enableTelnet: boolean; }; export type HostAuthType = Host["authType"]; export type HostCursorStyle = NonNullable< Host["terminalConfig"] >["cursorStyle"]; export type HostBellStyle = NonNullable["bellStyle"]; export type HostBackspaceMode = NonNullable< Host["terminalConfig"] >["backspaceMode"]; export type HostFastScrollModifier = NonNullable< Host["terminalConfig"] >["fastScrollModifier"]; export const terminalAppearanceKeys = [ "theme", "cursorBlink", "cursorStyle", "fontSize", "fontFamily", "scrollback", "letterSpacing", "lineHeight", "bellStyle", "minimumContrastRatio", "backgroundImage", "backgroundImageOpacity", "customThemeColors", ] as const satisfies readonly (keyof TerminalDefaults)[]; export const remoteDesktopDefaultKeys = [ "colorDepth", "resizeMethod", "forceLossless", "disableAudio", "enableWallpaper", "enableFontSmoothing", "enableDesktopComposition", "enablePrinting", "enableDrive", "disableCopy", "disablePaste", ] as const satisfies readonly (keyof RemoteDesktopDefaults)[]; export interface UserConnectionDefaults { terminal: TerminalDefaults; rdp: RemoteDesktopDefaults; } function hasOwn(value: object | undefined, key: PropertyKey): boolean { return !!value && Object.prototype.hasOwnProperty.call(value, key); } function stripKeys>( value: T, keys: readonly string[], ): Partial { const result = { ...value }; for (const key of keys) delete result[key]; return result; } type SnippetListItem = { id: number; name?: string; title?: string; }; type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] }; export function mapSnippetResponse( res: unknown, ): { id: number; name: string }[] { const snippetRes = res as SnippetResponse; return ( Array.isArray(snippetRes) ? snippetRes : (snippetRes.snippets ?? []) ).map((s) => ({ id: s.id, name: s.name ?? s.title ?? `Snippet ${s.id}`, })); } export function createHostEditorForm( host: Host | null, defaults?: HostDefaults, connectionDefaults?: UserConnectionDefaults, ) { const d = host ? undefined : defaults; const terminalConfig = { ...(connectionDefaults?.terminal ?? {}), ...(host?.terminalConfig ?? {}), }; const remoteDefaults = host?.enableRdp ? connectionDefaults?.rdp : undefined; const guacamoleConfig = { ...(remoteDefaults ?? {}), ...(host?.guacamoleConfig ?? {}), }; const rawTheme = terminalConfig.theme ?? d?.theme; const normalizedTheme = !rawTheme || ["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes( rawTheme, ) ? "termix" : rawTheme === "custom" || TERMINAL_THEMES[rawTheme] ? rawTheme : "termix"; return { name: host?.name ?? "", ip: host?.ip ?? "", username: host?.username ?? (host ? "" : "root"), sshPort: host?.sshPort ?? host?.port ?? 22, rdpPort: host?.rdpPort ?? 3389, vncPort: host?.vncPort ?? 5900, telnetPort: host?.telnetPort ?? 23, authType: host?.authType ?? "password", useWarpgate: host?.useWarpgate ?? false, shareSshAuth: host?.shareSshAuth ?? false, password: host?.hasPassword ? "existing_password" : (host?.password ?? ""), key: host?.key ?? (host?.hasKey ? "existing_key" : ""), keyPassword: host?.hasKeyPassword ? "existing_key_password" : (host?.keyPassword ?? ""), keyType: host?.keyType ?? "auto", keySubTab: "paste" as "paste" | "upload", credentialId: host?.credentialId ?? (d?.credentialId != null ? String(d.credentialId) : ""), vaultProfileId: host?.vaultProfileId ?? "", overrideCredentialUsername: host?.overrideCredentialUsername ?? false, folder: host?.folder ?? "", parentHostId: host?.parentHostId ?? "", tags: host?.tags ?? ([] as string[]), tagInput: "", notes: host?.notes ?? "", pin: host?.pin ?? false, macAddress: host?.macAddress ?? "", wolBroadcastAddress: host?.wolBroadcastAddress ?? "", useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false, socks5Host: host?.socks5Host ?? d?.socks5Host ?? "", socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080, socks5Username: host?.socks5Username ?? d?.socks5Username ?? "", socks5Password: host?.socks5Password ?? d?.socks5Password ?? "", socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0 ? "chain" : "single") as "single" | "chain", socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[], connectionOrigin: (host?.connectionOrigin ?? null) as "local" | "remote" | null, enableTerminal: host?.enableTerminal ?? true, enableSessionLogging: host?.enableSessionLogging ?? d?.enableSessionLogging ?? true, enableCommandHistory: host?.enableCommandHistory ?? d?.enableCommandHistory ?? true, enableFileManager: host?.enableFileManager ?? false, scpLegacy: host?.scpLegacy ?? false, enableDocker: host?.enableDocker ?? false, dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const }, enableTmuxMonitor: host?.enableTmuxMonitor ?? false, enableTerminalToolbar: host?.enableTerminalToolbar ?? true, allowSessionSharing: host?.allowSessionSharing ?? true, enableProxmox: host?.enableProxmox ?? false, proxmoxConfig: host?.proxmoxConfig ?? { defaultCredentialId: null as number | null, defaultAuthType: "password" as string, windowsPatterns: "win, windows", dockerPatterns: "docker", preferredPrefixes: "10., 192.168.", autoSyncEnabled: false, syncIntervalMinutes: 15, markMissingGuests: true, }, enableProxmoxStats: host?.enableProxmoxStats ?? false, proxmoxStatsConfig: host?.proxmoxStatsConfig ?? { pollInterval: 60, nodeName: null as string | null, }, enableTunnel: host?.enableTunnel ?? false, defaultPath: host?.defaultPath ?? "/", forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false, inheritTerminalAppearance: !host || terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)), inheritRemoteDesktopDefaults: !host || remoteDesktopDefaultKeys.every( (key) => !hasOwn(host.guacamoleConfig, key), ), localEcho: host?.terminalConfig?.localEcho ?? "default", fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14, fontFamily: terminalConfig.fontFamily ?? d?.fontFamily ?? "Caskaydia Cove Nerd Font Mono", theme: normalizedTheme, cursorStyle: (terminalConfig.cursorStyle ?? d?.cursorStyle ?? "bar") as "block" | "underline" | "bar", cursorBlink: terminalConfig.cursorBlink ?? d?.cursorBlink ?? true, scrollback: terminalConfig.scrollback ?? 10000, letterSpacing: terminalConfig.letterSpacing ?? 0, lineHeight: terminalConfig.lineHeight ?? 1.0, bellStyle: (terminalConfig.bellStyle ?? "none") as "none" | "sound" | "visual" | "both", rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false, macOptionIsMeta: host?.terminalConfig?.macOptionIsMeta ?? true, fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as "alt" | "ctrl" | "shift", fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5, minimumContrastRatio: terminalConfig.minimumContrastRatio ?? 1, backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as "normal" | "control-h", startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null, moshCommand: host?.terminalConfig?.moshCommand ?? "", agentForwarding: host?.terminalConfig?.agentForwarding ?? false, autoMosh: host?.terminalConfig?.autoMosh ?? false, autoTmux: host?.terminalConfig?.autoTmux ?? false, sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false, sudoPassword: host?.hasSudoPassword ? "existing_sudo_password" : (host?.terminalConfig?.sudoPassword ?? ""), keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60, keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5, backgroundImage: terminalConfig.backgroundImage ?? "", backgroundImageOpacity: terminalConfig.backgroundImageOpacity ?? 0.15, customThemeColors: terminalConfig.customThemeColors ?? null, allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true, linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ?? "default") as "default" | "confirm" | "direct", agentSocketPath: host?.terminalConfig?.agentSocketPath ?? "", useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false, syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true, syntaxHighlightingOptions: { logLevels: host?.terminalConfig?.syntaxHighlightingOptions?.logLevels ?? true, paths: host?.terminalConfig?.syntaxHighlightingOptions?.paths ?? true, timestamps: host?.terminalConfig?.syntaxHighlightingOptions?.timestamps ?? true, ipAddresses: host?.terminalConfig?.syntaxHighlightingOptions?.ipAddresses ?? true, urls: host?.terminalConfig?.syntaxHighlightingOptions?.urls ?? true, numbers: host?.terminalConfig?.syntaxHighlightingOptions?.numbers ?? true, }, environmentVariables: host?.terminalConfig?.environmentVariables ?? ([] as { key: string; value: string }[]), serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]), jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]), portKnockSequence: host?.portKnockSequence ?? ([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]), quickActions: host?.quickActions ?? ([] as { name: string; snippetId: string }[]), rdpCredentialId: host?.rdpCredentialId ?? "", rdpUser: host?.rdpUser ?? "", rdpPassword: host?.hasRdpPassword ? "existing_rdp_password" : (host?.rdpPassword ?? ""), domain: host?.domain ?? "", security: host?.security ?? "", ignoreCert: host?.ignoreCert ?? false, vncCredentialId: host?.vncCredentialId ?? "", vncPassword: host?.hasVncPassword ? "existing_vnc_password" : (host?.vncPassword ?? ""), vncUser: host?.vncUser ?? "", telnetUser: host?.telnetUser ?? "", telnetPassword: host?.hasTelnetPassword ? "existing_telnet_password" : (host?.telnetPassword ?? ""), telnetCredentialId: host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "", rdpAuthType: (host?.rdpAuthType ?? (host?.rdpCredentialId ? "credential" : "direct")) as "direct" | "credential" | "none", vncAuthType: (host?.vncAuthType ?? (host?.vncCredentialId ? "credential" : "direct")) as "direct" | "credential", telnetAuthType: (host?.telnetAuthType ?? (host?.telnetCredentialId ? "credential" : "direct")) as "direct" | "credential", guacamoleConfig, statsConfig: host?.statsConfig ?? { statusCheckEnabled: d?.statusCheckEnabled ?? true, statusCheckInterval: 60, useGlobalStatusInterval: true, metricsEnabled: d?.metricsEnabled ?? true, metricsInterval: 30, useGlobalMetricsInterval: true, enabledWidgets: [ "cpu", "memory", "disk", "network", "uptime", "system", "login_stats", "processes", "ports", "firewall", "temperature", ], excludedMounts: [] as string[], monitoredMounts: [] as Array<{ path: string; label?: string }>, }, }; } export type HostEditorForm = ReturnType; export function omitOwnerSshAuthFromSharedEdit( payload: SSHHostData, ): SSHHostData { const { authType: _authType, password: _password, key: _key, keyPassword: _keyPassword, keyType: _keyType, sudoPassword: _sudoPassword, credentialId: _credentialId, vaultProfileId: _vaultProfileId, overrideCredentialUsername: _overrideCredentialUsername, shareSshAuth: _shareSshAuth, ...editableFields } = payload; const terminalConfig = editableFields.terminalConfig ? { ...editableFields.terminalConfig } : undefined; if (terminalConfig) { delete terminalConfig.sudoPassword; delete terminalConfig.agentSocketPath; } return { ...editableFields, terminalConfig, } as SSHHostData; } export function buildHostEditorPayload( form: HostEditorForm, protocols: HostProtocols, ): SSHHostData { // Only carry the auth fields that belong to the selected method so switching // method (e.g. on a cloned host) doesn't leave a stale credentialId or key // behind that the backend would keep resolving. const usesCredential = form.authType === "credential"; const usesKey = form.authType === "key"; const usesPassword = form.authType === "password"; const usesAgent = form.authType === "agent"; const usesVault = form.authType === "vault"; const guacamoleConfig = form.inheritRemoteDesktopDefaults ? stripKeys(form.guacamoleConfig, remoteDesktopDefaultKeys) : form.guacamoleConfig; const terminalConfig = { theme: form.theme, cursorBlink: form.cursorBlink, cursorStyle: form.cursorStyle, fontSize: Number(form.fontSize), fontFamily: form.fontFamily, scrollback: Number(form.scrollback), letterSpacing: Number(form.letterSpacing), lineHeight: Number(form.lineHeight), bellStyle: form.bellStyle, rightClickSelectsWord: form.rightClickSelectsWord, macOptionIsMeta: form.macOptionIsMeta, fastScrollModifier: form.fastScrollModifier, fastScrollSensitivity: Number(form.fastScrollSensitivity), minimumContrastRatio: Number(form.minimumContrastRatio), backspaceMode: form.backspaceMode, startupSnippetId: form.startupSnippetId ?? null, moshCommand: form.moshCommand || null, agentForwarding: form.agentForwarding, autoMosh: form.autoMosh, autoTmux: form.autoTmux, sudoPasswordAutoFill: form.sudoPasswordAutoFill, sudoPassword: form.sudoPassword === "existing_sudo_password" ? undefined : form.sudoPassword || null, keepaliveInterval: Number(form.keepaliveInterval), keepaliveCountMax: Number(form.keepaliveCountMax), environmentVariables: form.environmentVariables, useSSHTitle: form.useSSHTitle, syntaxHighlighting: form.syntaxHighlighting, syntaxHighlightingOptions: form.syntaxHighlightingOptions, backgroundImage: form.backgroundImage || null, backgroundImageOpacity: Number(form.backgroundImageOpacity), customThemeColors: form.theme === "custom" ? form.customThemeColors : null, allowLegacyAlgorithms: form.allowLegacyAlgorithms, linkClickBehavior: form.linkClickBehavior !== "default" ? form.linkClickBehavior : undefined, localEcho: form.localEcho !== "default" ? form.localEcho : undefined, agentSocketPath: usesAgent ? form.agentSocketPath || null : null, }; const terminalOverrides = form.inheritTerminalAppearance ? stripKeys(terminalConfig, terminalAppearanceKeys) : terminalConfig; return { connectionType: protocols.enableSsh ? "ssh" : protocols.enableRdp ? "rdp" : protocols.enableVnc ? "vnc" : "telnet", name: form.name, ip: form.ip, port: protocols.enableSsh ? Number(form.sshPort) : protocols.enableRdp ? Number(form.rdpPort) : protocols.enableVnc ? Number(form.vncPort) : Number(form.telnetPort), username: form.username, folder: form.folder, parentHostId: form.parentHostId ? Number(form.parentHostId) : null, tags: form.tags, pin: form.pin, authType: form.authType, useWarpgate: form.useWarpgate, shareSshAuth: form.shareSshAuth, password: usesPassword || usesKey || usesCredential ? form.password === "existing_password" ? undefined : form.password || null : null, key: usesKey ? form.key === "existing_key" ? undefined : form.key || null : null, keyPassword: usesKey ? form.keyPassword === "existing_key_password" ? undefined : form.keyPassword || null : null, keyType: usesKey && form.keyType !== "auto" ? form.keyType : null, credentialId: usesCredential && form.credentialId ? Number(form.credentialId) : null, vaultProfileId: usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null, overrideCredentialUsername: form.overrideCredentialUsername, notes: form.notes, macAddress: form.macAddress || null, wolBroadcastAddress: form.wolBroadcastAddress || null, enableTerminal: form.enableTerminal, enableSessionLogging: form.enableSessionLogging, enableCommandHistory: form.enableCommandHistory, enableTunnel: form.enableTunnel, enableFileManager: form.enableFileManager, scpLegacy: form.scpLegacy, enableDocker: form.enableDocker, dockerConfig: form.enableDocker ? form.dockerConfig : null, enableTmuxMonitor: form.enableTmuxMonitor, enableTerminalToolbar: form.enableTerminalToolbar, allowSessionSharing: form.allowSessionSharing, enableProxmox: form.enableProxmox, proxmoxConfig: form.enableProxmox || form.proxmoxConfig?.source ? form.proxmoxConfig : null, enableProxmoxStats: form.enableProxmoxStats, proxmoxStatsConfig: form.enableProxmoxStats ? form.proxmoxStatsConfig : null, defaultPath: form.defaultPath || "/", useSocks5: form.useSocks5, socks5Host: form.socks5ProxyMode === "single" ? form.socks5Host || null : null, socks5Port: form.socks5ProxyMode === "single" ? form.socks5Port || null : null, socks5Username: form.socks5ProxyMode === "single" ? form.socks5Username || null : null, socks5Password: form.socks5ProxyMode === "single" ? form.socks5Password || null : null, socks5ProxyChain: form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null, connectionOrigin: form.connectionOrigin, enableSsh: protocols.enableSsh, enableRdp: protocols.enableRdp, enableVnc: protocols.enableVnc, enableTelnet: protocols.enableTelnet, sshPort: Number(form.sshPort), rdpPort: Number(form.rdpPort), vncPort: Number(form.vncPort), telnetPort: Number(form.telnetPort), forceKeyboardInteractive: form.forceKeyboardInteractive, rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null, rdpCredentialId: protocols.enableRdp && form.rdpAuthType === "credential" && form.rdpCredentialId ? Number(form.rdpCredentialId) : null, rdpUser: protocols.enableRdp && form.rdpAuthType === "direct" ? form.rdpUser || null : null, rdpPassword: protocols.enableRdp && form.rdpAuthType === "direct" && form.rdpPassword !== "existing_rdp_password" ? form.rdpPassword || null : null, rdpDomain: form.domain || null, rdpSecurity: form.security || null, rdpIgnoreCert: form.ignoreCert, vncAuthType: protocols.enableVnc ? form.vncAuthType : null, vncCredentialId: protocols.enableVnc && form.vncAuthType === "credential" && form.vncCredentialId ? Number(form.vncCredentialId) : null, vncPassword: protocols.enableVnc && form.vncAuthType === "direct" && form.vncPassword !== "existing_vnc_password" ? form.vncPassword || null : null, vncUser: protocols.enableVnc && form.vncAuthType === "direct" ? form.vncUser || null : null, telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null, telnetCredentialId: protocols.enableTelnet && form.telnetAuthType === "credential" && form.telnetCredentialId ? Number(form.telnetCredentialId) : null, telnetUser: protocols.enableTelnet && form.telnetAuthType === "direct" ? form.telnetUser || null : null, telnetPassword: protocols.enableTelnet && form.telnetAuthType === "direct" && form.telnetPassword !== "existing_telnet_password" ? form.telnetPassword || null : null, // The editor keeps ids as strings; the API and every backend lookup take // a number, and a string id does not compare equal on Postgres/MySQL. jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })), portKnockSequence: form.portKnockSequence, tunnelConnections: form.serverTunnels, quickActions: form.quickActions.map((a) => ({ name: a.name, snippetId: Number(a.snippetId), })), statsConfig: form.statsConfig, guacamoleConfig: (protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) && Object.keys(guacamoleConfig).length > 0 ? guacamoleConfig : null, terminalConfig: protocols.enableSsh ? terminalOverrides : null, }; }