Files
Termix/src/ui/sidebar/HostEditorData.ts
T

616 lines
22 KiB
TypeScript

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<Host["socks5ProxyChain"]>[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<Host["terminalConfig"]>["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<T extends Record<string, unknown>>(
value: T,
keys: readonly string[],
): Partial<T> {
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[] };
/**
* Whether the Connection Origin control is meaningful for a host.
*
* Every protocol Termix can dial from either backend belongs here. RDP, VNC
* and Telnet were added once they could originate from the desktop
* (Termix-SSH/Support#1240); before that the control was gated on SSH alone,
* so a host enabling only those protocols could never reach the setting.
*/
export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
return (
protocols.enableSsh ||
protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet
);
}
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 != null
? String(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,
enableWebUi: host?.enableWebUi ?? false,
webUiConfig: host?.webUiConfig ?? { endpoints: [] },
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
enableAiAssistant: host?.enableAiAssistant ?? false,
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 ?? false,
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 ?? d?.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 ?? "",
agentIdentity: host?.terminalConfig?.agentIdentity ?? "",
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 }>,
},
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
// saved through the plugin's own scoped route, not in the host payload.
pluginSettings: (host?.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>,
};
}
export type HostEditorForm = ReturnType<typeof createHostEditorForm>;
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,
agentIdentity: usesAgent ? form.agentIdentity || 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,
enableWebUi: form.enableWebUi,
webUiConfig: form.enableWebUi ? form.webUiConfig : null,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
enableAiAssistant: form.enableAiAssistant,
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,
};
}