mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move terminal settings, look and ssh tools into ssh-terminal plugin
This commit is contained in:
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user