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
@@ -1,20 +1,50 @@
|
||||
import {
|
||||
hostSettingsFromTerminalConfig,
|
||||
terminalConfigFromHostSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
const SWITCH_KEYS = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
|
||||
* so a Termix-JSON host import keeps its terminal switches, which used to be
|
||||
* host columns and are this plugin's host settings now.
|
||||
* host columns, and its terminal look and behavior, which a 2.8 or early 2.9
|
||||
* export carried in terminalConfig. An export that already carries this
|
||||
* plugin's pluginSettings has had them written by core, so terminalConfig is
|
||||
* only read without them.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const keys = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
if (keys.every((key) => raw[key] === undefined)) return null;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
for (const key of SWITCH_KEYS) {
|
||||
if (raw[key] !== undefined) values[key] = raw[key] !== false;
|
||||
}
|
||||
return values;
|
||||
|
||||
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
|
||||
"ssh-terminal"
|
||||
];
|
||||
const hasCarried =
|
||||
!!carried && typeof carried === "object" && Object.keys(carried).length > 0;
|
||||
if (!hasCarried) {
|
||||
Object.assign(values, hostSettingsFromTerminalConfig(raw.terminalConfig));
|
||||
}
|
||||
|
||||
return Object.keys(values).length > 0 ? values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "ssh-terminal.hostPayloadLegacy": the terminal keys of the
|
||||
* 2.8 terminalConfig, for clients that still read it (Termix-Mobile). Core
|
||||
* merges them into the terminalConfig it sends, never over a key it set.
|
||||
* Remove once the mobile app reads pluginSettings["ssh-terminal"].
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { terminalConfig: terminalConfigFromHostSettings(values) };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createTerminalLogger } from "./helpers.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
|
||||
import { createHistoryRepository } from "./history-repository.js";
|
||||
import { registerTerminalRoutes } from "./routes.js";
|
||||
import {
|
||||
@@ -171,6 +171,7 @@ export async function activate(ctx: PluginContext) {
|
||||
"ssh-terminal.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.registry.provide("ssh-terminal.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { TerminalSessionManager } from "./session-manager.js";
|
||||
import {
|
||||
ADMIN_KEYS,
|
||||
HOST_KEYS,
|
||||
MOVABLE_USER_KEYS,
|
||||
readClientSettings,
|
||||
readImageStorageSettings,
|
||||
} from "./settings.js";
|
||||
@@ -524,6 +525,99 @@ export function registerTerminalRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/user-settings:
|
||||
* put:
|
||||
* summary: Save some of the caller's terminal settings
|
||||
* description: Writes the caller's local echo, link click and command autocomplete settings. The terminal uses it once, to move values a browser kept before 2.9.0.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* localEcho:
|
||||
* type: string
|
||||
* linkClickBehavior:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: A value the setting does not accept.
|
||||
*/
|
||||
router.put("/user-settings", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return res.status(401).json({ error: "Sign in first" });
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
for (const key of MOVABLE_USER_KEYS) {
|
||||
if (body[key] !== undefined) {
|
||||
await ctx.settings.setUser(userId, key, body[key]);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: getErrorMessage(err, "Invalid setting") });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/hosts/{hostId}/auto-tmux:
|
||||
* put:
|
||||
* summary: Turn auto tmux on or off for a host
|
||||
* description: Sets the host's autoTmux terminal setting. Used by the "enable Auto-Tmux" action shown when a persisted session expires. Needs edit access to the host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid host id or value.
|
||||
* 403:
|
||||
* description: No edit access to the host.
|
||||
*/
|
||||
router.put("/hosts/:hostId/auto-tmux", json, async (req, res) => {
|
||||
const hostId = Number(req.params.hostId);
|
||||
const enabled = (req.body ?? {}).enabled;
|
||||
if (!Number.isInteger(hostId) || typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid host or value" });
|
||||
}
|
||||
try {
|
||||
const access = await ctx.hosts.checkAccess(hostId, "edit");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied to host" });
|
||||
}
|
||||
await ctx.settings.setHost(hostId, HOST_KEYS.autoTmux, enabled);
|
||||
res.json({ success: true, autoTmux: enabled });
|
||||
} catch (err) {
|
||||
log.error("Failed to update auto tmux", err);
|
||||
res.status(500).json({ error: "Failed to update auto tmux" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history:
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
NEW_HOST_ADMIN_KEYS,
|
||||
pickTerminalValues,
|
||||
readUserSettings,
|
||||
type NewHostTerminalDefaults,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
/**
|
||||
* Admin setting keys, as declared in manifest.json. The image storage fields
|
||||
@@ -37,8 +45,16 @@ export const HOST_KEYS = {
|
||||
terminalToolbarShowStatus: "terminalToolbarShowStatus",
|
||||
terminalToolbarFade: "terminalToolbarFade",
|
||||
enableCommandHistory: "enableCommandHistory",
|
||||
autoTmux: "autoTmux",
|
||||
} as const;
|
||||
|
||||
/** User settings a browser kept in localStorage before 2.9.0. */
|
||||
export const MOVABLE_USER_KEYS = [
|
||||
"localEcho",
|
||||
"linkClickBehavior",
|
||||
"commandAutocomplete",
|
||||
] as const;
|
||||
|
||||
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
|
||||
@@ -68,6 +84,10 @@ export interface ClientSettings {
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
/** What a new host's terminal starts with. */
|
||||
newHostDefaults: NewHostTerminalDefaults;
|
||||
/** The caller's own terminal settings. */
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
/** What every signed-in user's terminal needs, whether or not they are an admin. */
|
||||
@@ -84,6 +104,8 @@ export async function readClientSettings(
|
||||
touch = null;
|
||||
}
|
||||
}
|
||||
const actor = ctx.currentActor();
|
||||
const user = actor ? await ctx.settings.getAll("user", actor) : {};
|
||||
return {
|
||||
sessionTimeoutMinutes:
|
||||
Number.isFinite(timeout) && timeout > 0
|
||||
@@ -92,5 +114,26 @@ export async function readClientSettings(
|
||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||
touchInput: normalizeTouchInputSettings(touch),
|
||||
newHostDefaults: readNewHostDefaults(all),
|
||||
user: readUserSettings(user),
|
||||
};
|
||||
}
|
||||
|
||||
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
|
||||
export function readNewHostDefaults(
|
||||
admin: Record<string, unknown>,
|
||||
): NewHostTerminalDefaults {
|
||||
const byHostKey: Record<string, unknown> = {};
|
||||
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
|
||||
byHostKey[hostKey] = admin[adminKey];
|
||||
}
|
||||
return {
|
||||
fontSize: DEFAULT_APPEARANCE.fontSize,
|
||||
fontFamily: DEFAULT_APPEARANCE.fontFamily,
|
||||
theme: DEFAULT_APPEARANCE.theme,
|
||||
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
|
||||
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
|
||||
autoTmux: false,
|
||||
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
|
||||
} as NewHostTerminalDefaults;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import ssh2Pkg, {
|
||||
} from "ssh2";
|
||||
import {
|
||||
PluginSshInteractionError,
|
||||
type HostSshOptions,
|
||||
type PluginContext,
|
||||
type PluginSshHost,
|
||||
type PluginSshPrepared,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type TerminalLogger,
|
||||
} from "./helpers.js";
|
||||
import { SSHAuthManager } from "./keyboard-prompt.js";
|
||||
import { HOST_KEYS } from "./settings.js";
|
||||
import {
|
||||
isMessageAllowedForParticipant,
|
||||
type TerminalSessionManager,
|
||||
@@ -76,11 +78,8 @@ interface ConnectToHostData {
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
terminalConfig?: {
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Keepalive, legacy algorithms, agent and environment options. */
|
||||
sshOptions?: HostSshOptions | null;
|
||||
/** When true, ignore key material and force password auth (fallback path). */
|
||||
passwordFallbackOnly?: boolean;
|
||||
};
|
||||
@@ -1434,7 +1433,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
|
||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||
sshOptions?: HostSshOptions | null;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
@@ -1520,8 +1519,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
|
||||
}
|
||||
|
||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
|
||||
if (!hostConfig.sshOptions && resolvedHostData.sshOptions) {
|
||||
hostConfig.sshOptions = resolvedHostData.sshOptions;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1549,6 +1548,17 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
|
||||
const serverHostId = resolveServerHostId(id, resolvedHostData);
|
||||
|
||||
let autoTmuxEnabled = false;
|
||||
if (serverHostId != null) {
|
||||
try {
|
||||
autoTmuxEnabled =
|
||||
(await ctx.settings.getHost(serverHostId, HOST_KEYS.autoTmux)) ===
|
||||
true;
|
||||
} catch {
|
||||
autoTmuxEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = {
|
||||
username,
|
||||
@@ -2115,7 +2125,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
}
|
||||
});
|
||||
|
||||
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
|
||||
const autoTmux = autoTmuxEnabled;
|
||||
|
||||
// Helper to run initialPath/executeCommand after the shell
|
||||
// (or tmux session) is ready
|
||||
@@ -2692,7 +2702,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
keyType: resolvedCredentials.keyType,
|
||||
certPublicKey: resolvedCredentials.certPublicKey,
|
||||
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
|
||||
terminalConfig: hostConfig.terminalConfig,
|
||||
sshOptions: hostConfig.sshOptions,
|
||||
jumpHosts: hostConfig.jumpHosts,
|
||||
useSocks5: hostConfig.useSocks5,
|
||||
socks5Host: hostConfig.socks5Host,
|
||||
@@ -2758,7 +2768,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostConfig.terminalConfig?.agentForwarding) {
|
||||
if (hostConfig.sshOptions?.agentForwarding) {
|
||||
if (connectConfig.privateKey) {
|
||||
try {
|
||||
const parsed = ssh2Utils.parseKey(
|
||||
|
||||
Reference in new issue
Block a user