mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -66,4 +66,8 @@ export async function runCoreBootMigrations(): Promise<void> {
|
||||
const { runHostStatusConfigMigration } =
|
||||
await import("./utils/crypto-migration/host-status-config-migration.js");
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
const { runSshOptionsMigration } =
|
||||
await import("./utils/crypto-migration/ssh-options-migration.js");
|
||||
await runSshOptionsMigration();
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
interface PurposeDefaults {
|
||||
keepaliveIntervalMs: number;
|
||||
keepaliveCountMax: number;
|
||||
/** Let terminalConfig.keepalive* override the defaults. */
|
||||
/** Let the host's sshOptions.keepalive* override the defaults. */
|
||||
hostKeepalive: boolean;
|
||||
readyTimeout: number;
|
||||
/** Socket idle timeout, when the transport set one. */
|
||||
@@ -146,13 +146,12 @@ export async function buildConnectConfig(
|
||||
const authType = host.authType || "none";
|
||||
const serverHostId = options.serverHostId ?? host.id;
|
||||
const isJumpHost = profile === "jump";
|
||||
const terminalConfig = (host.terminalConfig ?? undefined) as
|
||||
Record<string, unknown> | undefined;
|
||||
const sshOptions = host.sshOptions ?? undefined;
|
||||
|
||||
const keepalive = defaults.hostKeepalive
|
||||
? resolveSshKeepalive(
|
||||
terminalConfig?.keepaliveInterval as number | undefined,
|
||||
terminalConfig?.keepaliveCountMax as number | undefined,
|
||||
sshOptions?.keepaliveInterval,
|
||||
sshOptions?.keepaliveCountMax,
|
||||
defaults.keepaliveIntervalMs,
|
||||
defaults.keepaliveCountMax,
|
||||
)
|
||||
@@ -184,9 +183,7 @@ export async function buildConnectConfig(
|
||||
isJumpHost,
|
||||
preloaded,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(
|
||||
terminalConfig?.allowLegacyAlgorithms !== false,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(sshOptions?.allowLegacyAlgorithms !== false),
|
||||
};
|
||||
if (defaults.env) config.env = { ...defaults.env };
|
||||
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
|
||||
|
||||
@@ -179,10 +179,7 @@ const agentProvider: SshAuthProvider = {
|
||||
quickConnect: true,
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
host.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
const result = await applyAgentAuth(config, host.sshOptions);
|
||||
if ("error" in result) {
|
||||
return { status: "error", code: "failed", message: result.error };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { HostSshOptions } from "../ssh-options.js";
|
||||
|
||||
/**
|
||||
* What a connection is for, as a free label ("terminal", "metrics"). Auth
|
||||
@@ -35,7 +36,7 @@ export interface SshConnectHost {
|
||||
*/
|
||||
pluginSettings?: Record<string, Record<string, unknown>> | null;
|
||||
forceKeyboardInteractive?: boolean | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
useSocks5?: boolean | null;
|
||||
socks5Host?: string | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { parseSshOptions } from "./ssh-options.js";
|
||||
import { findUsableCredential } from "./usable-credential.js";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import {
|
||||
@@ -116,16 +117,25 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
if (
|
||||
!ownerEquivalent &&
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
) {
|
||||
host.terminalConfig = {
|
||||
...(host.terminalConfig as Record<string, unknown>),
|
||||
sudoPassword: null,
|
||||
};
|
||||
// 2.8 editors kept the sudo password inside terminal_config. It is only
|
||||
// ever handed out as sudoPassword, and only to the owner.
|
||||
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
|
||||
host.sudoPassword = legacySudo;
|
||||
}
|
||||
host.terminalConfig = rest;
|
||||
}
|
||||
// A row the boot copy has not reached yet still has them in terminal_config.
|
||||
host.sshOptions = parseSshOptions(
|
||||
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
|
||||
);
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* A host's SSH connection options (`ssh_data.ssh_options`).
|
||||
*
|
||||
* These lived in `terminal_config` next to the terminal's look until 2.9.0.
|
||||
* The connect pipeline reads them for every SSH connection, not only the
|
||||
* terminal's, so they are core's and have a column of their own.
|
||||
*/
|
||||
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type { HostSshOptions };
|
||||
|
||||
export const SSH_OPTION_KEYS = [
|
||||
"keepaliveInterval",
|
||||
"keepaliveCountMax",
|
||||
"allowLegacyAlgorithms",
|
||||
"agentSocketPath",
|
||||
"agentIdentity",
|
||||
"agentForwarding",
|
||||
"environmentVariables",
|
||||
] as const satisfies readonly (keyof HostSshOptions)[];
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
if (!parsed) return null;
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null | undefined {
|
||||
if (value === null) return null;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH options in a value (a JSON string or an object), typed and with
|
||||
* anything else dropped. A terminal_config value works too, which is how the
|
||||
* 2.8 keys are read.
|
||||
*/
|
||||
export function parseSshOptions(value: unknown): HostSshOptions {
|
||||
const source = asObject(value);
|
||||
if (!source) return {};
|
||||
const options: HostSshOptions = {};
|
||||
|
||||
const keepaliveInterval = finiteNumber(source.keepaliveInterval);
|
||||
if (keepaliveInterval !== undefined)
|
||||
options.keepaliveInterval = keepaliveInterval;
|
||||
const keepaliveCountMax = finiteNumber(source.keepaliveCountMax);
|
||||
if (keepaliveCountMax !== undefined)
|
||||
options.keepaliveCountMax = keepaliveCountMax;
|
||||
if (typeof source.allowLegacyAlgorithms === "boolean") {
|
||||
options.allowLegacyAlgorithms = source.allowLegacyAlgorithms;
|
||||
}
|
||||
const agentSocketPath = optionalString(source.agentSocketPath);
|
||||
if (agentSocketPath !== undefined) options.agentSocketPath = agentSocketPath;
|
||||
const agentIdentity = optionalString(source.agentIdentity);
|
||||
if (agentIdentity !== undefined) options.agentIdentity = agentIdentity;
|
||||
if (typeof source.agentForwarding === "boolean") {
|
||||
options.agentForwarding = source.agentForwarding;
|
||||
}
|
||||
if (Array.isArray(source.environmentVariables)) {
|
||||
options.environmentVariables = source.environmentVariables
|
||||
.filter(
|
||||
(entry): entry is { key: unknown; value: unknown } =>
|
||||
!!entry && typeof entry === "object",
|
||||
)
|
||||
.map((entry) => ({
|
||||
key: String(entry.key ?? ""),
|
||||
value: String(entry.value ?? ""),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Whether a value carries any SSH option at all. */
|
||||
export function hasSshOptions(value: unknown): boolean {
|
||||
return Object.keys(parseSshOptions(value)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column value to store for a host write: the payload's own sshOptions,
|
||||
* or, from a client that still sends the 2.8 shape, the options inside its
|
||||
* terminalConfig. Undefined when the payload carries neither, so an update
|
||||
* leaves the column alone.
|
||||
*/
|
||||
export function sshOptionsForWrite(payload: {
|
||||
sshOptions?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
}): string | null | undefined {
|
||||
if (payload.sshOptions !== undefined) {
|
||||
if (payload.sshOptions === null) return null;
|
||||
return JSON.stringify(parseSshOptions(payload.sshOptions));
|
||||
}
|
||||
if (hasSshOptions(payload.terminalConfig)) {
|
||||
return JSON.stringify(parseSshOptions(payload.terminalConfig));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import ssh2Pkg, {
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
|
||||
import type { HostSshOptions } from "./ssh-options.js";
|
||||
|
||||
type KnownPublicKey = KnownPublicKeys[number];
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
@@ -28,11 +30,9 @@ type PortKnockingOptions = {
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function resolveAgentSocket(
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
sshOptions: HostSshOptions | null | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
const explicit = (
|
||||
terminalConfig?.agentSocketPath as string | undefined
|
||||
)?.trim();
|
||||
const explicit = sshOptions?.agentSocketPath?.trim();
|
||||
const resolved = explicit || process.env.SSH_AUTH_SOCK;
|
||||
|
||||
if (!resolved) {
|
||||
@@ -135,17 +135,15 @@ function parseAgentIdentityBlob(agentIdentity: string): Buffer | null {
|
||||
|
||||
export async function applyAgentAuth(
|
||||
connectConfig: Record<string, unknown>,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
sshOptions: HostSshOptions | null | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
const result = await resolveAgentSocket(terminalConfig);
|
||||
const result = await resolveAgentSocket(sshOptions);
|
||||
if ("error" in result) return result;
|
||||
|
||||
const { createAgent } = ssh2Pkg;
|
||||
const agent = createAgent(result.socketPath);
|
||||
|
||||
const agentIdentity = (
|
||||
terminalConfig?.agentIdentity as string | undefined
|
||||
)?.trim();
|
||||
const agentIdentity = sshOptions?.agentIdentity?.trim();
|
||||
if (agentIdentity) {
|
||||
const publicKeyBlob = parseAgentIdentityBlob(agentIdentity);
|
||||
if (!publicKeyBlob) {
|
||||
|
||||
@@ -425,3 +425,57 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformHostResponse terminal fields", () => {
|
||||
const row = (extra: Record<string, unknown>) =>
|
||||
transformHostResponse({ id: 1, tags: "", ...extra });
|
||||
|
||||
it("sends the SSH options and core's own terminalConfig keys only", () => {
|
||||
const host = row({
|
||||
sshOptions: JSON.stringify({ keepaliveInterval: 20 }),
|
||||
terminalConfig: JSON.stringify({
|
||||
startupSnippetId: 3,
|
||||
theme: "nord",
|
||||
keepaliveInterval: 5,
|
||||
}),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 20 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 20,
|
||||
startupSnippetId: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the options out of terminal_config before the boot copy", () => {
|
||||
const host = row({
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({ agentForwarding: true }),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ agentForwarding: true });
|
||||
});
|
||||
|
||||
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
|
||||
const host = row({
|
||||
sudoPassword: null,
|
||||
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
|
||||
});
|
||||
const stripped = stripSensitiveFields(host);
|
||||
expect(stripped.hasSudoPassword).toBe(true);
|
||||
expect(stripped).not.toHaveProperty("sudoPassword");
|
||||
expect(JSON.stringify(stripped)).not.toContain("legacy");
|
||||
});
|
||||
|
||||
it("hides the owner's agent socket from a shared recipient", () => {
|
||||
const shared = sanitizeHostForRecipient(
|
||||
row({
|
||||
sshOptions: JSON.stringify({
|
||||
agentSocketPath: "/run/agent",
|
||||
keepaliveInterval: 9,
|
||||
}),
|
||||
}),
|
||||
"edit",
|
||||
);
|
||||
expect(shared.sshOptions).toEqual({ keepaliveInterval: 9 });
|
||||
expect(shared.terminalConfig).toEqual({ keepaliveInterval: 9 });
|
||||
});
|
||||
});
|
||||
@@ -415,6 +415,22 @@ describe("share levels, legacy fields and generic writes", () => {
|
||||
expect(host.name).toBe("web");
|
||||
});
|
||||
|
||||
it("adds a plugin's keys to an object core already sends, never over core's", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
registryProviders.set("docker.hostPayloadLegacy", () => ({
|
||||
terminalConfig: { theme: "nord", startupSnippetId: 99 },
|
||||
}));
|
||||
const host: Record<string, unknown> = {
|
||||
id: 2,
|
||||
terminalConfig: { startupSnippetId: 1 },
|
||||
};
|
||||
attachHostPluginSettings(
|
||||
[host],
|
||||
new Map([[2, { docker: { enableDocker: true } }]]),
|
||||
);
|
||||
expect(host.terminalConfig).toEqual({ theme: "nord", startupSnippetId: 1 });
|
||||
});
|
||||
|
||||
it("imports an export's plugin values, leaving secrets out", async () => {
|
||||
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
|
||||
await applyPluginHostImportSettings(9, {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stored: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async () => state.stored,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mergeStoredTerminalFields, parseTerminalConfig } =
|
||||
await import("../../../database/routes/host-terminal-fields.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stored = {
|
||||
terminalConfig: JSON.stringify({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 1,
|
||||
}),
|
||||
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
|
||||
};
|
||||
});
|
||||
|
||||
async function merge(
|
||||
hostData: Record<string, unknown>,
|
||||
isOwner = true,
|
||||
sshOptions?: string,
|
||||
) {
|
||||
const row: Record<string, unknown> = {
|
||||
terminalConfig: "replaced wholesale",
|
||||
...(sshOptions !== undefined ? { sshOptions } : {}),
|
||||
};
|
||||
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
|
||||
return { error, row };
|
||||
}
|
||||
|
||||
describe("parseTerminalConfig", () => {
|
||||
it("reads objects and JSON, and nothing else", () => {
|
||||
expect(parseTerminalConfig({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(parseTerminalConfig('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(parseTerminalConfig("nope")).toBeNull();
|
||||
expect(parseTerminalConfig([1])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStoredTerminalFields", () => {
|
||||
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
|
||||
const { error, row } = await merge({
|
||||
terminalConfig: { startupSnippetId: 4 },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(JSON.parse(row.terminalConfig as string)).toEqual({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves terminal_config alone when the payload has none", async () => {
|
||||
const { row } = await merge({});
|
||||
expect(row).not.toHaveProperty("terminalConfig");
|
||||
});
|
||||
|
||||
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
|
||||
const { row } = await merge({ sudoPassword: "" });
|
||||
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
|
||||
"sudoPassword",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the owner's private keys through a shared editor's save", async () => {
|
||||
const { row } = await merge(
|
||||
{ terminalConfig: { startupSnippetId: 2 } },
|
||||
false,
|
||||
JSON.stringify({ keepaliveInterval: 9 }),
|
||||
);
|
||||
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 2,
|
||||
});
|
||||
expect(JSON.parse(row.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 9,
|
||||
agentSocketPath: "/owner/agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a terminalConfig it cannot read", async () => {
|
||||
const { error } = await merge({ terminalConfig: "not json" });
|
||||
expect(error).toBe("Invalid terminal config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The admin host defaults. The terminal half moved to the ssh-terminal
|
||||
* plugin's new-host settings; its keys stay in the stored row for the boot
|
||||
* copy and a downgrade, but are never served or overwritten here.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
admin: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
getGlobalLogLevel: () => "info",
|
||||
setGlobalLogLevel: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/analytics.js", () => ({
|
||||
getTelemetryEnvOverride: () => null,
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
id,
|
||||
username: "admin",
|
||||
isAdmin: state.admin,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerUserSettingsRoutes } =
|
||||
await import("../../../database/routes/user-settings-routes.js");
|
||||
|
||||
const authenticate: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "u1";
|
||||
next();
|
||||
};
|
||||
|
||||
function app() {
|
||||
const router = express.Router();
|
||||
registerUserSettingsRoutes(router, authenticate);
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use("/users", router);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.admin = true;
|
||||
});
|
||||
|
||||
describe("/users/host-defaults", () => {
|
||||
it("serves core's defaults without the terminal keys the plugin took over", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
});
|
||||
const response = await request(app()).get("/users/host-defaults");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ useSocks5: true });
|
||||
});
|
||||
|
||||
it("keeps the stored terminal keys when an admin saves core's defaults", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
});
|
||||
const response = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(state.settings.host_defaults)).toEqual({
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
useSocks5: false,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a user who is not an admin", async () => {
|
||||
state.admin = false;
|
||||
const response = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: true });
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
+32
-1
@@ -186,6 +186,20 @@ const HOST_ROWS: Row[] = [
|
||||
enable_docker: 1,
|
||||
docker_config: JSON.stringify({ runtime: "podman" }),
|
||||
enable_ai_assistant: 1,
|
||||
// Its own terminal look, behavior and SSH options, all in one JSON.
|
||||
terminal_config: JSON.stringify({
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
}),
|
||||
mac_address: "AA:BB:CC:DD:EE:04",
|
||||
wol_broadcast_address: "10.4.0.255",
|
||||
stats_config: JSON.stringify({
|
||||
@@ -198,6 +212,8 @@ const HOST_ROWS: Row[] = [
|
||||
}),
|
||||
}),
|
||||
host(HOSTS.key, "d4-key", {
|
||||
// Behavior only: this host kept following the user's look.
|
||||
terminal_config: JSON.stringify({ passwordPromptAutoFill: false }),
|
||||
key: PRIVATE_KEY,
|
||||
key_password: "d4-key-pass",
|
||||
key_type: "ssh-ed25519",
|
||||
@@ -362,6 +378,15 @@ export const ROWS: Record<string, Row[]> = {
|
||||
resizeMethod: "reconnect",
|
||||
enableDrive: true,
|
||||
}),
|
||||
terminal_defaults: JSON.stringify({ fontSize: 18, cursorBlink: false }),
|
||||
custom_themes: JSON.stringify([
|
||||
{
|
||||
id: "d4-theme",
|
||||
name: "d4 theme",
|
||||
colors: { background: "#101010", foreground: "#f0f0f0" },
|
||||
},
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
},
|
||||
],
|
||||
sso_providers: [
|
||||
@@ -811,7 +836,13 @@ export const ROWS: Record<string, Row[]> = {
|
||||
["metrics_history_retention_days", "14"],
|
||||
[
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, enableCommandHistory: false }),
|
||||
JSON.stringify({
|
||||
metricsEnabled: false,
|
||||
enableCommandHistory: false,
|
||||
fontSize: 20,
|
||||
theme: "nord",
|
||||
autoTmux: true,
|
||||
}),
|
||||
],
|
||||
["ai_globally_enabled", "true"],
|
||||
["ai_private_endpoint_allowlist", JSON.stringify(["10.4.0.0/24"])],
|
||||
|
||||
@@ -412,6 +412,9 @@ DROP TABLE `sync_tombstones`;
|
||||
-- ==== core 0061_plugin_settings_scope_enum ====
|
||||
ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','secret') NOT NULL;
|
||||
|
||||
-- ==== core 0062_smooth_speed ====
|
||||
ALTER TABLE `ssh_data` ADD `ssh_options` text;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -432,6 +432,9 @@ CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id
|
||||
-- ==== core 0061_red_lila_cheney ====
|
||||
DROP TABLE "sync_tombstones" CASCADE;
|
||||
|
||||
-- ==== core 0062_curvy_killmonger ====
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -267,7 +267,7 @@ describe("buildConnectConfig per auth type", () => {
|
||||
host({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
terminalConfig: { agentSocketPath: "/tmp/agent.sock" },
|
||||
sshOptions: { agentSocketPath: "/tmp/agent.sock" },
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
@@ -388,7 +388,7 @@ describe("profile defaults", () => {
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
sshOptions: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
|
||||
@@ -307,9 +307,11 @@ describe("resolveHostById", () => {
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("agent");
|
||||
expect(host.sshOptions).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
});
|
||||
expect(host.terminalConfig).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
sudoPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,10 +451,43 @@ describe("resolveHostById", () => {
|
||||
expect(host.password).toBeNull();
|
||||
expect(host.key).toBeNull();
|
||||
expect(host.credentialId).toBeNull();
|
||||
expect(host.terminalConfig).toEqual({
|
||||
theme: "termix",
|
||||
// The owner's legacy sudo password never reaches a recipient.
|
||||
expect(host.terminalConfig).toEqual({ theme: "termix" });
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
|
||||
state.host = baseHost({
|
||||
sudoPassword: null,
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
sudoPassword: "legacy-sudo",
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
}),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sudoPassword).toBe("legacy-sudo");
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the ssh_options column once it is filled", async () => {
|
||||
state.host = baseHost({
|
||||
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
|
||||
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSshOptions,
|
||||
parseSshOptions,
|
||||
sshOptionsForWrite,
|
||||
} from "../../hosts/ssh-options.js";
|
||||
|
||||
describe("parseSshOptions", () => {
|
||||
it("keeps the connection options, typed, and drops everything else", () => {
|
||||
expect(
|
||||
parseSshOptions(
|
||||
JSON.stringify({
|
||||
keepaliveInterval: "30",
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: 1 }, null],
|
||||
theme: "nord",
|
||||
cfAccessClientId: "gone",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads twice-stringified rows and ignores garbage", () => {
|
||||
expect(
|
||||
parseSshOptions(JSON.stringify(JSON.stringify({ keepaliveInterval: 5 }))),
|
||||
).toEqual({ keepaliveInterval: 5 });
|
||||
expect(parseSshOptions("nope")).toEqual({});
|
||||
expect(parseSshOptions(null)).toEqual({});
|
||||
expect(parseSshOptions([1])).toEqual({});
|
||||
});
|
||||
|
||||
it("says whether a value carries any option", () => {
|
||||
expect(hasSshOptions({ theme: "nord" })).toBe(false);
|
||||
expect(hasSshOptions({ agentForwarding: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sshOptionsForWrite", () => {
|
||||
it("stores the payload's own options", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({ sshOptions: { keepaliveInterval: 9, junk: 1 } }),
|
||||
).toBe(JSON.stringify({ keepaliveInterval: 9 }));
|
||||
expect(sshOptionsForWrite({ sshOptions: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("takes them from a 2.8 terminalConfig when the payload has none", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: { keepaliveCountMax: 2, theme: "nord" },
|
||||
}),
|
||||
).toBe(JSON.stringify({ keepaliveCountMax: 2 }));
|
||||
});
|
||||
|
||||
it("leaves the column alone when neither carries any", () => {
|
||||
expect(sshOptionsForWrite({ terminalConfig: { theme: "nord" } })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(sshOptionsForWrite({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,18 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
// The look it saved in terminal_config, which took it off the user's.
|
||||
inheritAppearance: false,
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
|
||||
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
@@ -361,6 +372,9 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTerminalToolbar: true,
|
||||
inheritAppearance: true,
|
||||
autoTmux: false,
|
||||
passwordPromptAutoFill: true,
|
||||
});
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
@@ -390,6 +404,18 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
}
|
||||
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
|
||||
|
||||
// The connection options in terminal_config moved to core's ssh_options.
|
||||
const [options] = await selectRows<{ ssh_options: string | null }>(
|
||||
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
|
||||
);
|
||||
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const target = await asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
|
||||
@@ -439,6 +465,14 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
sessionPersistence: false,
|
||||
commandHistoryForNewHosts: false,
|
||||
imageMaxCount: 7,
|
||||
newHostFontSize: 20,
|
||||
newHostTheme: "nord",
|
||||
newHostAutoTmux: true,
|
||||
});
|
||||
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The terminal's look and behavior moving out of ssh_data.terminal_config and
|
||||
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
|
||||
* second run changes nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
rows: [] as Row[],
|
||||
installed: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.rows.find(
|
||||
(row) =>
|
||||
row.pluginId === "ssh-terminal" &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.installed && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
|
||||
state.rows.filter(
|
||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.rows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
runSshTerminalLookMigration,
|
||||
hostSettingsFromTerminalConfig,
|
||||
userSettingsFromPreferences,
|
||||
} = await import("../../upgrade/ssh-terminal-look-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
state.rows.length = 0;
|
||||
state.installed = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
terminal_defaults TEXT,
|
||||
custom_themes TEXT,
|
||||
command_autocomplete INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function user(id: string, key: string): unknown {
|
||||
const row = find("user", id, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
describe("hostSettingsFromTerminalConfig", () => {
|
||||
it("keeps a host that followed the user's look following it", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig(
|
||||
JSON.stringify({
|
||||
autoTmux: true,
|
||||
localEcho: "on",
|
||||
keepaliveInterval: 30,
|
||||
startupSnippetId: 3,
|
||||
}),
|
||||
),
|
||||
).toEqual({ autoTmux: true, localEcho: "on" });
|
||||
});
|
||||
|
||||
it("opts a host with its own look out of the user's defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig({
|
||||
theme: "Termix Dark",
|
||||
fontSize: "16",
|
||||
cursorStyle: "wobble",
|
||||
bellStyle: "visual",
|
||||
}),
|
||||
).toEqual({
|
||||
inheritAppearance: false,
|
||||
theme: "termix",
|
||||
fontSize: 16,
|
||||
bellStyle: "visual",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing out of garbage", () => {
|
||||
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
|
||||
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userSettingsFromPreferences", () => {
|
||||
it("takes the defaults, saved themes and autocomplete", () => {
|
||||
expect(
|
||||
userSettingsFromPreferences({
|
||||
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
|
||||
custom_themes: JSON.stringify([
|
||||
{ id: "a", name: "Mine", colors: { background: "#000" } },
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
terminalDefaults: { fontSize: 18, theme: "nord" },
|
||||
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
|
||||
commandAutocomplete: true,
|
||||
});
|
||||
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSshTerminalLookMigration", () => {
|
||||
function seed() {
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
|
||||
1,
|
||||
JSON.stringify({
|
||||
theme: "dracula",
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
keepaliveInterval: 30,
|
||||
}),
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run(
|
||||
"u1",
|
||||
JSON.stringify({ cursorBlink: false }),
|
||||
JSON.stringify([
|
||||
{ id: "t", name: "T", colors: { background: "#111" } },
|
||||
]),
|
||||
1,
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run("u2", null, null, null);
|
||||
}
|
||||
|
||||
it("copies each host's terminal keys and each user's terminal preferences", async () => {
|
||||
seed();
|
||||
const result = await runSshTerminalLookMigration();
|
||||
|
||||
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
|
||||
expect(host(1, "inheritAppearance")).toBe(false);
|
||||
expect(host(1, "theme")).toBe("dracula");
|
||||
expect(host(1, "fontSize")).toBe(18);
|
||||
expect(host(1, "autoTmux")).toBe(true);
|
||||
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
|
||||
expect(host(1, "keepaliveInterval")).toBeUndefined();
|
||||
expect(host(2, "passwordPromptAutoFill")).toBe(false);
|
||||
expect(host(2, "inheritAppearance")).toBeUndefined();
|
||||
expect(host(3, "keepaliveInterval")).toBeUndefined();
|
||||
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
|
||||
expect(user("u1", "customThemes")).toHaveLength(1);
|
||||
expect(user("u1", "commandAutocomplete")).toBe(true);
|
||||
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after values were edited", async () => {
|
||||
seed();
|
||||
await runSshTerminalLookMigration();
|
||||
find("host", "1", "fontSize")!.value = JSON.stringify(12);
|
||||
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
|
||||
const before = JSON.stringify(state.rows);
|
||||
|
||||
const second = await runSshTerminalLookMigration();
|
||||
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
|
||||
expect(JSON.stringify(state.rows)).toBe(before);
|
||||
expect(host(1, "fontSize")).toBe(12);
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
seed();
|
||||
state.installed = false;
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
expect(state.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies nothing from a database without the columns", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
state.db = drizzle(sqlite);
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,30 @@ describe("runSshTerminalSettingsMigration", () => {
|
||||
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
|
||||
});
|
||||
|
||||
it("moves the terminal half of the admin host defaults to the new-host settings", async () => {
|
||||
coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
fontFamily: "Fira Code",
|
||||
theme: "Termix Dark",
|
||||
cursorStyle: "wobble",
|
||||
cursorBlink: false,
|
||||
autoTmux: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(admin("newHostFontSize")).toBe(18);
|
||||
expect(admin("newHostFontFamily")).toBe("Fira Code");
|
||||
expect(admin("newHostTheme")).toBe("termix");
|
||||
expect(admin("newHostCursorStyle")).toBeUndefined();
|
||||
expect(admin("newHostCursorBlink")).toBe(false);
|
||||
expect(admin("newHostAutoTmux")).toBe(true);
|
||||
});
|
||||
|
||||
it("copies each host that switched something off, keeping the rest on", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The SSH connection options moving out of ssh_data.terminal_config into
|
||||
* the core ssh_options column, against a real SQLite database through the
|
||||
* same raw SQL helpers the boot path uses.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ db: null as unknown }));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runSshOptionsMigration } =
|
||||
await import("../../../utils/crypto-migration/ssh-options-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
terminal_config TEXT,
|
||||
ssh_options TEXT
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
function insert(id: number, terminalConfig: unknown, sshOptions?: string) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO ssh_data (id, terminal_config, ssh_options) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
terminalConfig === null ? null : JSON.stringify(terminalConfig),
|
||||
sshOptions ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function options(id: number): unknown {
|
||||
const row = sqlite
|
||||
.prepare("SELECT ssh_options FROM ssh_data WHERE id = ?")
|
||||
.get(id) as { ssh_options: string | null };
|
||||
return row.ssh_options === null ? null : JSON.parse(row.ssh_options);
|
||||
}
|
||||
|
||||
describe("runSshOptionsMigration", () => {
|
||||
it("copies only the connection options, leaving the look and old tunnel keys", async () => {
|
||||
insert(1, {
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent.sock",
|
||||
agentIdentity: "work",
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "LANG", value: "C" }],
|
||||
startupSnippetId: 4,
|
||||
cfAccessClientId: "old",
|
||||
cfTunnelHostname: "old.example.com",
|
||||
});
|
||||
|
||||
const result = await runSshOptionsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(1);
|
||||
expect(options(1)).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent.sock",
|
||||
agentIdentity: "work",
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "LANG", value: "C" }],
|
||||
});
|
||||
const terminalConfig = sqlite
|
||||
.prepare("SELECT terminal_config FROM ssh_data WHERE id = 1")
|
||||
.get() as { terminal_config: string };
|
||||
expect(JSON.parse(terminalConfig.terminal_config).keepaliveInterval).toBe(
|
||||
30,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a host with no options as done and skips hosts with no config", async () => {
|
||||
insert(1, { theme: "dracula" });
|
||||
insert(2, null);
|
||||
|
||||
const result = await runSshOptionsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(0);
|
||||
expect(options(1)).toEqual({});
|
||||
expect(options(2)).toBeNull();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after the options were edited", async () => {
|
||||
insert(1, { keepaliveInterval: 30 });
|
||||
insert(
|
||||
2,
|
||||
{ keepaliveInterval: 45 },
|
||||
JSON.stringify({ keepaliveCountMax: 9 }),
|
||||
);
|
||||
await runSshOptionsMigration();
|
||||
|
||||
sqlite
|
||||
.prepare("UPDATE ssh_data SET ssh_options = ? WHERE id = 1")
|
||||
.run(JSON.stringify({ keepaliveInterval: 90 }));
|
||||
const second = await runSshOptionsMigration();
|
||||
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(options(1)).toEqual({ keepaliveInterval: 90 });
|
||||
expect(options(2)).toEqual({ keepaliveCountMax: 9 });
|
||||
});
|
||||
|
||||
it("does not throw on a database without the column", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
state.db = drizzle(sqlite);
|
||||
await expect(runSshOptionsMigration()).resolves.toEqual({
|
||||
hostsMoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { runFileManagerSettingsMigration } from "./file-manager-settings-migrati
|
||||
import { runTunnelsSettingsMigration } from "./tunnels-settings-migration.js";
|
||||
import { runWebEndpointSettingsMigration } from "./web-endpoint-settings-migration.js";
|
||||
import { runSshTerminalSettingsMigration } from "./ssh-terminal-settings-migration.js";
|
||||
import { runSshTerminalLookMigration } from "./ssh-terminal-look-migration.js";
|
||||
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
|
||||
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
|
||||
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
|
||||
@@ -39,6 +40,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
|
||||
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
|
||||
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
|
||||
["runSshTerminalLookMigration", runSshTerminalLookMigration],
|
||||
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
|
||||
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Moves the terminal's look and behavior out of core and into the
|
||||
* ssh-terminal plugin's settings.
|
||||
*
|
||||
* Per host, the terminal keys of ssh_data.terminal_config become host
|
||||
* settings: every behavior key the host saved (tmux, mosh, auto-fill, link
|
||||
* and echo modes, syntax highlighting and the rest), and its look only when
|
||||
* it saved one. 2.8 stripped the look from a host that followed the user's
|
||||
* defaults, so a host with any look key had opted out: it gets
|
||||
* inheritAppearance false and its look copied. The SSH connection options in
|
||||
* the same JSON are core's and move to ssh_data.ssh_options
|
||||
* (ssh-options-migration.ts); startupSnippetId and a legacy sudo password
|
||||
* stay where they are.
|
||||
*
|
||||
* Per user, user_preferences.terminal_defaults, custom_themes and
|
||||
* command_autocomplete become user settings.
|
||||
*
|
||||
* The columns keep their values until 3.0.0 drops them.
|
||||
*
|
||||
* Idempotent: a host that already has any of these rows, and a user key that
|
||||
* is already set, are skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
|
||||
const APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
];
|
||||
|
||||
const BEHAVIOR_KEYS = [
|
||||
"rightClickSelectsWord",
|
||||
"macOptionIsMeta",
|
||||
"fastScrollModifier",
|
||||
"fastScrollSensitivity",
|
||||
"backspaceMode",
|
||||
"autoMosh",
|
||||
"moshCommand",
|
||||
"autoTmux",
|
||||
"useSSHTitle",
|
||||
"syntaxHighlighting",
|
||||
"syntaxHighlightingOptions",
|
||||
"linkClickBehavior",
|
||||
"localEcho",
|
||||
"passwordPromptAutoFill",
|
||||
"sudoPasswordAutoFill",
|
||||
];
|
||||
|
||||
const HOST_KEYS = ["inheritAppearance", ...APPEARANCE_KEYS, ...BEHAVIOR_KEYS];
|
||||
|
||||
/** The plugin's select fields, which refuse anything outside their options. */
|
||||
const ENUMS: Record<string, string[]> = {
|
||||
cursorStyle: ["block", "underline", "bar"],
|
||||
bellStyle: ["none", "sound", "visual", "both"],
|
||||
fastScrollModifier: ["alt", "ctrl", "shift"],
|
||||
backspaceMode: ["normal", "control-h"],
|
||||
linkClickBehavior: ["default", "confirm", "direct"],
|
||||
localEcho: ["default", "off", "auto", "on"],
|
||||
};
|
||||
|
||||
const NUMBER_KEYS = new Set([
|
||||
"fontSize",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImageOpacity",
|
||||
"fastScrollSensitivity",
|
||||
]);
|
||||
|
||||
const LEGACY_TERMIX_THEMES = [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
];
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(value);
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** One key's value in the type its plugin field declares, else undefined. */
|
||||
export function terminalValue(key: string, value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (ENUMS[key]) {
|
||||
return typeof value === "string" && ENUMS[key].includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
if (NUMBER_KEYS.has(key)) {
|
||||
if (value === null || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
switch (key) {
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "backgroundImage":
|
||||
case "moshCommand":
|
||||
return value === null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: undefined;
|
||||
case "customThemeColors":
|
||||
return isObject(value) ? value : undefined;
|
||||
case "syntaxHighlightingOptions":
|
||||
return isObject(value) ? value : undefined;
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ssh-terminal host settings one 2.8 terminal_config value carried. */
|
||||
export function hostSettingsFromTerminalConfig(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> {
|
||||
const config = typeof raw === "string" ? parseJson(raw) : raw;
|
||||
if (!isObject(config)) return {};
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of BEHAVIOR_KEYS) {
|
||||
const value = terminalValue(key, config[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
if (APPEARANCE_KEYS.some((key) => key in config)) {
|
||||
values.inheritAppearance = false;
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
const value = terminalValue(key, config[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** The ssh-terminal user settings one user_preferences row carried. */
|
||||
export function userSettingsFromPreferences(row: {
|
||||
terminal_defaults?: unknown;
|
||||
custom_themes?: unknown;
|
||||
command_autocomplete?: unknown;
|
||||
}): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const defaults = parseJson(row.terminal_defaults);
|
||||
if (isObject(defaults)) {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
const value = terminalValue(key, defaults[key]);
|
||||
if (value !== undefined) picked[key] = value;
|
||||
}
|
||||
if (Object.keys(picked).length > 0) values.terminalDefaults = picked;
|
||||
}
|
||||
const themes = parseJson(row.custom_themes);
|
||||
if (Array.isArray(themes) && themes.length > 0) values.customThemes = themes;
|
||||
const autocomplete = row.command_autocomplete;
|
||||
if (autocomplete !== null && autocomplete !== undefined) {
|
||||
values.commandAutocomplete =
|
||||
autocomplete === true ||
|
||||
autocomplete === 1 ||
|
||||
autocomplete === "1" ||
|
||||
autocomplete === "true";
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface SshTerminalLookMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
usersMoved: number;
|
||||
}
|
||||
|
||||
export async function runSshTerminalLookMigration(): Promise<SshTerminalLookMigrationResult> {
|
||||
const result: SshTerminalLookMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
id: number;
|
||||
terminal_config: string | null;
|
||||
}>(sql`
|
||||
SELECT id, terminal_config FROM ssh_data
|
||||
WHERE terminal_config IS NOT NULL
|
||||
`);
|
||||
const moves = rows
|
||||
.map((row) => ({
|
||||
scopeId: String(row.id),
|
||||
values: hostSettingsFromTerminalConfig(row.terminal_config),
|
||||
}))
|
||||
.filter((move) => Object.keys(move.values).length > 0);
|
||||
// A few reads for every candidate host rather than one per host.
|
||||
const done = new Set<string | null>();
|
||||
for (let i = 0; i < moves.length; i += 500) {
|
||||
const existing = await pluginSettings.getAllForScopeIds(
|
||||
"host",
|
||||
moves.slice(i, i + 500).map((move) => move.scopeId),
|
||||
);
|
||||
for (const setting of existing) {
|
||||
if (setting.pluginId === PLUGIN_ID && HOST_KEYS.includes(setting.key)) {
|
||||
done.add(setting.scopeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { scopeId, values } of moves) {
|
||||
if (done.has(scopeId)) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH terminal host look migration failed", {
|
||||
operation: "ssh_terminal_look_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await selectLegacyRows<{
|
||||
user_id: string;
|
||||
terminal_defaults: string | null;
|
||||
custom_themes: string | null;
|
||||
command_autocomplete: unknown;
|
||||
}>(sql`
|
||||
SELECT user_id, terminal_defaults, custom_themes, command_autocomplete
|
||||
FROM user_preferences
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values = userSettingsFromPreferences(row);
|
||||
let moved = false;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) result.usersMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH terminal user settings migration failed", {
|
||||
operation: "ssh_terminal_look_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0 || result.usersMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the terminal settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
|
||||
{ operation: "ssh_terminal_look_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from the core `settings` table: session persistence, command
|
||||
* history, touch input tuning and terminal image storage. The image storage
|
||||
* history, the terminal half of the admin host defaults (a new host's font,
|
||||
* theme, cursor and auto tmux), touch input tuning and terminal image
|
||||
* storage. The image storage
|
||||
* local directory was never sent to a browser, so it lands as a secret field,
|
||||
* which keeps it that way. The legacy rows are left in place for a release;
|
||||
* nothing reads them any more.
|
||||
@@ -52,6 +54,40 @@ const asJson: Parse = (raw) => {
|
||||
}
|
||||
};
|
||||
|
||||
const CURSOR_STYLES = ["block", "underline", "bar"];
|
||||
/** 2.8 spellings of the theme that follows the app. */
|
||||
const LEGACY_TERMIX_THEMES = [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
];
|
||||
|
||||
/** One terminal key of the admin host defaults, or undefined when unusable. */
|
||||
function newHostDefault(key: string, defaults: unknown): unknown {
|
||||
if (!defaults || typeof defaults !== "object") return undefined;
|
||||
const value = (defaults as Record<string, unknown>)[key];
|
||||
switch (key) {
|
||||
case "fontSize": {
|
||||
const size = Number(value);
|
||||
return Number.isFinite(size) && size >= 8 && size <= 36
|
||||
? size
|
||||
: undefined;
|
||||
}
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
|
||||
case "cursorStyle":
|
||||
return typeof value === "string" && CURSOR_STYLES.includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy core key -> the admin field the plugin declares. */
|
||||
const ADMIN_MOVES: {
|
||||
legacyKey: string;
|
||||
@@ -84,6 +120,21 @@ const ADMIN_MOVES: {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
},
|
||||
},
|
||||
...(
|
||||
[
|
||||
["fontSize", "newHostFontSize"],
|
||||
["fontFamily", "newHostFontFamily"],
|
||||
["theme", "newHostTheme"],
|
||||
["cursorStyle", "newHostCursorStyle"],
|
||||
["cursorBlink", "newHostCursorBlink"],
|
||||
["autoTmux", "newHostAutoTmux"],
|
||||
] as const
|
||||
).map(([legacyField, field]) => ({
|
||||
// The terminal half of the admin host defaults, for new hosts.
|
||||
legacyKey: "host_defaults",
|
||||
field,
|
||||
parse: (raw: string) => newHostDefault(legacyField, asJson(raw)),
|
||||
})),
|
||||
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
|
||||
{
|
||||
legacyKey: "terminal_image_storage_mode",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copies each host's SSH connection options out of ssh_data.terminal_config
|
||||
* into ssh_data.ssh_options: keepalive, legacy algorithms, the agent socket
|
||||
* and identity, agent forwarding and environment variables. The terminal's
|
||||
* look and behavior in the same JSON belong to the ssh-terminal plugin and
|
||||
* are moved by upgrade/ssh-terminal-host-settings-migration.ts.
|
||||
*
|
||||
* terminal_config keeps its values; nothing reads these keys from it any
|
||||
* more. Old Cloudflare Access keys are left behind on purpose.
|
||||
*
|
||||
* Idempotent: only rows whose ssh_options is still null are read, and every
|
||||
* row it reads gets a value, even an empty object.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
import { parseSshOptions } from "../../hosts/ssh-options.js";
|
||||
|
||||
export interface SshOptionsMigrationResult {
|
||||
hostsMoved: number;
|
||||
}
|
||||
|
||||
export async function runSshOptionsMigration(): Promise<SshOptionsMigrationResult> {
|
||||
let hostsMoved = 0;
|
||||
try {
|
||||
const rows = await selectRows<{
|
||||
id: number;
|
||||
terminal_config: string | null;
|
||||
}>(sql`
|
||||
SELECT id, terminal_config FROM ssh_data
|
||||
WHERE ssh_options IS NULL AND terminal_config IS NOT NULL
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const options = parseSshOptions(row.terminal_config);
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data SET ssh_options = ${JSON.stringify(options)}
|
||||
WHERE id = ${row.id} AND ssh_options IS NULL
|
||||
`);
|
||||
if (Object.keys(options).length > 0) hostsMoved++;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSH options migration failed", {
|
||||
operation: "ssh_options_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { hostsMoved };
|
||||
}
|
||||
|
||||
if (hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved SSH options for ${hostsMoved} host(s) out of terminal_config`,
|
||||
{ operation: "ssh_options_migration" },
|
||||
);
|
||||
}
|
||||
return { hostsMoved };
|
||||
}
|
||||
Reference in new issue
Block a user