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

+12 -2
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
import express from "express";
import http from "http";
import https from "https";
@@ -722,6 +723,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
terminal_config TEXT,
ssh_options TEXT,
quick_actions TEXT,
notes TEXT,
use_socks5 INTEGER,
@@ -799,8 +801,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -831,6 +833,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.terminalConfig || null,
decrypted.sshOptions || null,
decrypted.quickActions || null,
decrypted.notes || null,
decrypted.useSocks5 ? 1 : 0,
@@ -1148,6 +1151,13 @@ app.post(
jumpHosts: host.jump_hosts,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
// Exports from before 2.9.0 carry these in terminal_config.
sshOptions:
host.ssh_options ??
sshOptionsForWrite({
terminalConfig: host.terminal_config,
}) ??
null,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
+1
View File
@@ -1604,6 +1604,7 @@ const migrateSchema = () => {
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "shared_source", "TEXT");
addColumnIfNotExists("ssh_data", "ssh_options", "TEXT");
addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT");
addColumnIfNotExists(
"ssh_folders",
+3
View File
@@ -201,6 +201,9 @@ export const hosts = mysqlTable(
.default(true),
statusCheckInterval: int("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
+3
View File
@@ -201,6 +201,9 @@ export const hosts = pgTable(
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
+3
View File
@@ -193,6 +193,9 @@ export const hosts = sqliteTable(
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
@@ -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);