feat: move terminal settings, look and ssh tools into ssh-terminal plugin

This commit is contained in:
LukeGus committed 2026-09-29 02:38:37 -05:00
1 parent ffa872ebd3
commit d9bba10612
242 files changed
+25071 -7207

No files matched your search

@@ -2,6 +2,7 @@ import {
prepareHostImports,
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
@@ -674,6 +675,12 @@ export function registerHostBulkRoutes(
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",
@@ -918,6 +925,7 @@ export function registerHostBulkRoutes(
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
+61 -13
View File
@@ -1,4 +1,8 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import {
parseSshOptions,
type HostSshOptions,
} from "../../hosts/ssh-options.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -71,6 +75,9 @@ export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"agentSocketPath",
] as const;
/** SSH options only the host's owner may change. */
export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const;
export function containsOwnerPrivateAuthUpdate(
hostData: Record<string, unknown>,
protocol: AuthOverrideProtocol,
@@ -176,6 +183,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
terminalConfig?: unknown;
sshOptions?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
useSocks5?: unknown;
@@ -272,6 +280,7 @@ export function stripSensitiveFields(
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasRdpPassword = !!host.rdpPassword;
@@ -340,16 +349,13 @@ export function sanitizeHostForRecipient(
// 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<string, unknown>),
};
delete terminalConfig.agentSocketPath;
stripped.terminalConfig = terminalConfig;
for (const field of ["terminalConfig", "sshOptions"] as const) {
const value = stripped[field];
if (value && typeof value === "object" && !Array.isArray(value)) {
const copy = { ...(value as Record<string, unknown>) };
for (const key of OWNER_PRIVATE_SSH_OPTION_FIELDS) delete copy[key];
stripped[field] = copy;
}
}
const authOverrides =
stripped.authOverrides &&
@@ -396,6 +402,50 @@ export function parseSharedSource(
}
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read: core's own
* keys only (the SSH options and the startup snippet). The terminal's look
* and behavior are the ssh-terminal plugin's host settings, which it puts
* back through its hostPayloadLegacy. A sudo password 2.8 kept in
* terminal_config comes out as sudoPassword, for the sanitizers to handle.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
terminalConfig?: Record<string, unknown>;
sudoPassword?: unknown;
} {
const raw = parseJsonObject(host.terminalConfig);
const sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : raw,
);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
if (raw && raw.startupSnippetId !== undefined) {
terminalConfig.startupSnippetId = raw.startupSnippetId;
}
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
terminalConfig:
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
...(legacySudo ? { sudoPassword: legacySudo } : {}),
};
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
@@ -436,9 +486,7 @@ export function transformHostResponse(
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig as string)
: undefined,
...hostTerminalExport(host),
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
@@ -163,13 +163,19 @@ function visibleHostPluginSettings(
* clients outside Termix (Termix-Mobile) that have not moved to
* pluginSettings yet. Registered as ctx.registry.provide(
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
* values and the host, and never overwrites a field core already set.
* values and the host, and never overwrites a field core already set. When
* both are objects the plugin's keys are added to core's, under the same
* rule.
*/
export type PluginHostPayloadLegacy = (
values: Record<string, unknown>,
host: Record<string, unknown>,
) => Record<string, unknown> | null;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function applyLegacyFields(
host: Record<string, unknown>,
values: HostPluginSettings,
@@ -183,7 +189,14 @@ function applyLegacyFields(
if (!legacy) continue;
try {
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
if (!(key in host)) host[key] = value;
const current = host[key];
if (current === undefined || current === null) {
host[key] = value;
} else if (isPlainObject(current) && isPlainObject(value)) {
// An object core already sends (terminalConfig) gains the plugin's
// keys, still never over one core set.
host[key] = { ...value, ...current };
}
}
} catch {
// A plugin's compat shape must never break a host read.
@@ -0,0 +1,89 @@
/**
* How a host update writes terminal_config and ssh_options, kept apart from
* the route so it can be tested on its own.
*/
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { parseSshOptions } from "../../hosts/ssh-options.js";
import {
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
} from "./host-normalizers.js";
export function parseTerminalConfig(
value: unknown,
): Record<string, unknown> | null {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return null;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? { ...(parsed as Record<string, unknown>) }
: null;
}
/**
* terminal_config is merged into what is stored rather than replaced. The
* 2.8 keys the editor no longer sends (the terminal's look, which the
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
* editors kept there) stay until 3.0.0 drops the column. A shared editor
* never changes the owner's private keys in either JSON. Returns an error
* for a terminalConfig that cannot be read.
*/
export async function mergeStoredTerminalFields(
sshDataObj: Record<string, unknown>,
hostData: Record<string, unknown>,
hostId: number,
ownerId: string,
isOwner: boolean,
): Promise<string | null> {
const sentTerminalConfig =
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
const incoming = sentTerminalConfig
? parseTerminalConfig(hostData.terminalConfig)
: null;
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
delete sshDataObj.terminalConfig;
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
return null;
}
const stored = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
const merged = { ...storedConfig, ...(incoming ?? {}) };
if (!isOwner) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (field in storedConfig) merged[field] = storedConfig[field];
else delete merged[field];
}
}
// A sudo password saved on its own lives in the encrypted column.
if (sentSudoPassword) delete merged.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(merged);
}
if (!isOwner && sshDataObj.sshOptions !== undefined) {
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
}
sshDataObj.sshOptions = JSON.stringify(options);
}
return null;
}
+65 -200
View File
@@ -35,7 +35,9 @@ import {
isValidPort,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
hostTerminalExport,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
@@ -68,6 +70,11 @@ import {
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
mergeStoredTerminalFields,
parseTerminalConfig,
} from "./host-terminal-fields.js";
const router = express.Router();
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
@@ -191,6 +198,7 @@ router.post(
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
@@ -298,6 +306,7 @@ router.post(
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
@@ -789,6 +798,7 @@ router.put(
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
@@ -900,7 +910,6 @@ router.put(
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null,
socks5Port: socks5Port || null,
@@ -940,6 +949,13 @@ router.put(
telnetUser: telnetUser || null,
};
const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig });
if (nextSshOptions !== undefined) sshDataObj.sshOptions = nextSshOptions;
// The editor leaves sudoPassword out when the user did not touch it.
if (sudoPassword !== undefined) {
sshDataObj.sudoPassword = sudoPassword || null;
}
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
if ((connectionType || "ssh") !== "ssh") {
if (password) {
@@ -1055,77 +1071,29 @@ router.put(
});
}
const parseTerminalConfig = (
value: unknown,
): Record<string, unknown> | null => {
if (!value) return null;
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
return { ...(value as Record<string, unknown>) };
}
if (typeof value === "string") {
const parsed = JSON.parse(value) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
return { ...(parsed as Record<string, unknown>) };
}
}
return null;
};
if (hostData.terminalConfig === undefined) {
delete sshDataObj.terminalConfig;
} else {
let incomingTerminalConfig: Record<string, unknown> | null;
try {
incomingTerminalConfig = parseTerminalConfig(
hostData.terminalConfig,
);
} catch {
return res.status(400).json({ error: "Invalid terminal config" });
}
if (!incomingTerminalConfig) {
return res.status(400).json({ error: "Invalid terminal config" });
}
const protectedTerminalConfigField =
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) =>
Object.prototype.hasOwnProperty.call(
incomingTerminalConfig,
field,
),
);
if (protectedTerminalConfigField) {
return res.status(403).json({
error:
"Only the host owner can change private SSH authentication settings",
});
}
const ownerHost =
await createCurrentHostResolutionRepository().findHostById(
Number(hostId),
ownerId,
);
const ownerTerminalConfig = parseTerminalConfig(
ownerHost?.terminalConfig,
const incomingTerminalConfig = parseTerminalConfig(
hostData.terminalConfig,
);
const protectedTerminalConfigField =
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) =>
Object.prototype.hasOwnProperty.call(
incomingTerminalConfig ?? {},
field,
),
);
if (ownerTerminalConfig) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (
Object.prototype.hasOwnProperty.call(ownerTerminalConfig, field)
) {
incomingTerminalConfig[field] = ownerTerminalConfig[field];
}
}
}
sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig);
const incomingSshOptions = parseTerminalConfig(hostData.sshOptions);
const protectedSshOptionField = OWNER_PRIVATE_SSH_OPTION_FIELDS.find(
(field) =>
Object.prototype.hasOwnProperty.call(
incomingSshOptions ?? {},
field,
),
);
if (protectedTerminalConfigField || protectedSshOptionField) {
return res.status(403).json({
error:
"Only the host owner can change private SSH authentication settings",
});
}
const referenceViolations: Array<[unknown, number | null, string]> = [
@@ -1157,33 +1125,17 @@ router.put(
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
delete sshDataObj[field];
}
} else if (
sshDataObj.terminalConfig &&
(hostData.terminalConfig as Record<string, unknown> | undefined)
?.sudoPassword === undefined
) {
// The editor omits sudoPassword entirely when the user hasn't
// touched the field, so preserve whatever is already stored instead
// of letting the wholesale terminalConfig replacement below wipe it.
const existingHost =
await createCurrentHostResolutionRepository().findHostById(
Number(hostId),
ownerId,
);
const existingTerminalConfig = existingHost?.terminalConfig
? (JSON.parse(existingHost.terminalConfig as string) as Record<
string,
unknown
>)
: undefined;
if (existingTerminalConfig?.sudoPassword !== undefined) {
const incomingTerminalConfig = JSON.parse(
sshDataObj.terminalConfig as string,
) as Record<string, unknown>;
incomingTerminalConfig.sudoPassword =
existingTerminalConfig.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig);
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
sshDataObj,
hostData,
Number(hostId),
ownerId,
accessInfo.isOwner,
);
if (terminalFieldsError) {
return res.status(400).json({ error: terminalFieldsError });
}
await createCurrentHostRepository().updateEncryptedForUser(
@@ -1292,90 +1244,6 @@ router.put(
* 500:
* description: Failed to fetch SSH data.
*/
/**
* @openapi
* /host/db/host/{id}/terminal-config:
* patch:
* summary: Update a host's terminal behaviour flags
* description: Merges the given flags into the host's terminalConfig. Owner or a recipient with edit access. Currently supports autoTmux; used by the "enable Auto-Tmux" action shown when a persisted session expires.
* tags:
* - Hosts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* autoTmux:
* type: boolean
* responses:
* 200:
* description: Updated.
* 403:
* description: No edit access.
* 404:
* description: Host not found.
*/
router.patch(
"/db/host/:id/terminal-config",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = parseInt(String(req.params.id), 10);
const { autoTmux } = req.body ?? {};
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
if (typeof autoTmux !== "boolean") {
return res.status(400).json({ error: "autoTmux must be a boolean" });
}
try {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied to host" });
}
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
const host = ownerId
? await createCurrentHostRepository().findByIdForUser(ownerId, hostId)
: null;
if (!host || !ownerId) {
return res.status(404).json({ error: "Host not found" });
}
let terminalConfig: Record<string, unknown> = {};
if (host.terminalConfig) {
try {
terminalConfig = JSON.parse(host.terminalConfig);
} catch {
terminalConfig = {};
}
}
await createCurrentHostRepository().updateForUser(ownerId, hostId, {
terminalConfig: JSON.stringify({ ...terminalConfig, autoTmux }),
});
res.json({ success: true, autoTmux });
} catch (error) {
sshLogger.error("Failed to update host terminal config", error, {
operation: "host_terminal_config_update",
hostId,
userId,
});
res.status(500).json({ error: "Failed to update terminal config" });
}
},
);
router.get(
"/db/host",
authenticateJWT,
@@ -1770,16 +1638,8 @@ router.get(
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
if (!value && field === "sudoPassword" && resolved.terminalConfig) {
try {
const tc =
typeof resolved.terminalConfig === "string"
? JSON.parse(resolved.terminalConfig)
: resolved.terminalConfig;
value = tc?.sudoPassword || null;
} catch {
// malformed JSON — leave value null
}
if (!value && field === "sudoPassword") {
value = hostTerminalExport(resolved).sudoPassword || null;
}
if (!value) {
@@ -1901,16 +1761,19 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword: resolvedHost.sudoPassword || null,
sudoPassword:
resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
@@ -2034,16 +1897,18 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
: resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
@@ -30,14 +30,35 @@ export type HostDefaults = {
socks5Password?: string;
credentialId?: number | null;
statusCheckEnabled?: boolean;
fontSize?: number;
fontFamily?: string;
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
autoTmux?: boolean;
};
/**
* Keys host_defaults held before the terminal's new-host defaults became the
* ssh-terminal plugin's admin settings. Kept in the stored row for the boot
* copy and a downgrade, never served or overwritten here.
*/
const LEGACY_TERMINAL_HOST_DEFAULTS = [
"fontSize",
"fontFamily",
"theme",
"cursorStyle",
"cursorBlink",
"autoTmux",
"enableCommandHistory",
];
function parseHostDefaults(value: string | null): Record<string, unknown> {
if (!value) return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
@@ -574,10 +595,11 @@ export function registerUserSettingsRoutes(
*/
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
try {
const value =
await createCurrentSettingsRepository().get("host_defaults");
const defaults: HostDefaults = value ? JSON.parse(value) : {};
res.json(defaults);
const stored = parseHostDefaults(
await createCurrentSettingsRepository().get("host_defaults"),
);
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) delete stored[key];
res.json(stored as HostDefaults);
} catch (err) {
authLogger.error("Failed to get host defaults", err);
res.status(500).json({ error: "Failed to get host defaults" });
@@ -613,10 +635,20 @@ export function registerUserSettingsRoutes(
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const defaults: HostDefaults = req.body;
await createCurrentSettingsRepository().set(
const defaults: HostDefaults = { ...(req.body ?? {}) };
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) {
delete (defaults as Record<string, unknown>)[key];
}
const settings = createCurrentSettingsRepository();
const stored = parseHostDefaults(await settings.get("host_defaults"));
const legacy = Object.fromEntries(
LEGACY_TERMINAL_HOST_DEFAULTS.filter((key) => key in stored).map(
(key) => [key, stored[key]],
),
);
await settings.set(
"host_defaults",
JSON.stringify(defaults),
JSON.stringify({ ...legacy, ...defaults }),
);
const { ipAddress, userAgent } = getRequestMeta(req);