mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
1 parent
e040b12045
commit
c94a453f1e
43 files changed
+2426
-38
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# SSH Terminal
|
||||
|
||||
The interactive SSH terminal, packaged as a first-party plugin.
|
||||
|
||||
## Why this plugin runs in-process
|
||||
|
||||
Every other plugin runs in a `worker_threads` worker and reaches the server
|
||||
through a structured-clone `postMessage` boundary. That boundary is what makes
|
||||
the capability gate and the audit trail mean anything: a worker plugin cannot
|
||||
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
|
||||
has to go through the broker.
|
||||
|
||||
This plugin cannot work that way. It _is_ the SSH transport rather than a
|
||||
consumer of one:
|
||||
|
||||
- it owns a `WebSocketServer` on port 30002,
|
||||
- it holds long-lived `ssh2.Client` objects and PTY streams,
|
||||
- it needs plaintext host credentials to connect at all.
|
||||
|
||||
None of those can cross a structured-clone boundary, and `ctx.hosts` returns a
|
||||
13-field allowlist that deliberately excludes `password`, `key` and
|
||||
`keyPassword`. So `ssh-terminal` runs on the main thread instead, under the
|
||||
`process:transport-owner` tier.
|
||||
|
||||
Membership in that tier is a hardcoded allowlist in
|
||||
`src/backend/plugins/first-party.ts`. A manifest cannot ask for it: declaring
|
||||
`process:transport-owner` without being on the list is a validation error. The
|
||||
tier has no capability gate and no audit interception, which is only defensible
|
||||
because nothing installable can enter it.
|
||||
|
||||
## What lives where
|
||||
|
||||
The terminal's implementation stays in `src/backend/hosts/terminal/`. Only the
|
||||
lifecycle lives here. Two reasons it was not copied in:
|
||||
|
||||
- `session-manager.ts` has six consumers outside the terminal (terminal routes,
|
||||
open-tabs, user-image-storage, collab, and session-sharing twice). It is
|
||||
shared infrastructure, not plugin-private code.
|
||||
- host key verification (`host-key-verifier.ts`), jump host chaining
|
||||
(`jump-host-chain.ts`) and host resolution (`host-resolver.ts`) each have
|
||||
around eight consumers already. They keep working by _not_ moving.
|
||||
|
||||
The SSH connection pool also stays in core. The terminal never used it: it
|
||||
calls `new Client()` directly. The pool's consumers are metrics, tmux, tunnel,
|
||||
fleet routes, AI tools, automations and `ctx.ssh` itself, so moving it here
|
||||
would have made all of them depend on the terminal being enabled.
|
||||
|
||||
Only `SSHAuthManager` is terminal-exclusive.
|
||||
|
||||
## WebSocket: still port 30002, not /plugin-api/
|
||||
|
||||
The terminal WS was deliberately left on its own port and path.
|
||||
|
||||
`/plugin-api` has no auth middleware, so routing terminal auth through it would
|
||||
be a regression. Beyond that, `/ssh/websocket/` is mapped in both nginx configs,
|
||||
three frontend call sites build that URL (`Terminal.tsx`, `SharedSessionView`,
|
||||
`CollabRoomTab`), Electron's `buildOriginWsUrl` pins `localPort: 30002`, and the
|
||||
8-attempt reconnect plus detach/reattach buffering all depend on the current
|
||||
path.
|
||||
|
||||
The plugin owns the port's lifecycle instead: enabling it calls
|
||||
`startTerminalServer()`, disabling it calls `stopTerminalServer()`, which closes
|
||||
live sessions and frees the port.
|
||||
|
||||
## Disabling
|
||||
|
||||
Disabling behaves like any other plugin. No confirmation dialog, no special
|
||||
casing. The terminal tab stops being offered from the host list, command
|
||||
palette and quick connect, the port stops listening, and every other feature
|
||||
(metrics, docker, tunnels, file manager) keeps working off its own connections.
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SSH Terminal - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker, because it IS
|
||||
* the SSH transport rather than a consumer of one: it owns a WebSocket server
|
||||
* and long-lived ssh2 clients, and neither a live socket nor an ssh2 Client can
|
||||
* cross the structured-clone postMessage boundary a worker plugin talks over.
|
||||
* See src/backend/plugins/first-party.ts for why that tier is a hardcoded
|
||||
* allowlist and not something a manifest can ask for.
|
||||
*
|
||||
* The terminal's implementation stays in core (src/backend/hosts/terminal/)
|
||||
* rather than being copied in here. Two reasons:
|
||||
* - session-manager has six consumers outside the terminal (terminal routes,
|
||||
* open-tabs, user-image-storage, collab, and session-sharing twice), so it
|
||||
* is shared infrastructure, not plugin-private code.
|
||||
* - the terminal needs plaintext host credentials to connect, which ctx.hosts
|
||||
* deliberately never returns (PluginHostView is a 13-field allowlist that
|
||||
* excludes password, key and passphrase).
|
||||
*
|
||||
* What this plugin owns is the lifecycle: enabling it starts the WS server,
|
||||
* disabling it closes live sessions and frees the port.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Resolves the compiled core terminal module.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the module is at
|
||||
* dist/backend/backend/hosts/terminal/index.js. Under vitest and dev the
|
||||
* plugin is loaded from the repo instead, where the source .ts is the thing
|
||||
* that exists and tsx is already handling it.
|
||||
*/
|
||||
async function loadTerminalModule() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const candidates = [
|
||||
// Built: plugins/ssh-terminal/backend -> dist/backend/backend/...
|
||||
path.resolve(here, "../../../backend/backend/hosts/terminal/index.js"),
|
||||
// Repo layout, compiled output present.
|
||||
path.resolve(here, "../../../dist/backend/backend/hosts/terminal/index.js"),
|
||||
// Repo layout, running from source under tsx.
|
||||
path.resolve(here, "../../../src/backend/hosts/terminal/index.ts"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
require.resolve(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return import(new URL(`file://${candidate.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ssh-terminal could not locate the terminal module. Looked in:\n ${candidates.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
let terminal = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
terminal = await loadTerminalModule();
|
||||
|
||||
await terminal.startTerminalServer();
|
||||
|
||||
// Published so collab and session-sharing can reach live sessions without
|
||||
// importing a plugin. Revoked automatically when this plugin is disabled.
|
||||
ctx.registry.provide("terminal.sessions", terminal.sessionManager ?? null);
|
||||
|
||||
ctx.log.info(
|
||||
`SSH terminal listening on ${terminal.TERMINAL_WS_PORT ?? 30002}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (!terminal) return;
|
||||
await terminal.stopTerminalServer();
|
||||
terminal = null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Frontend half of the ssh-terminal plugin.
|
||||
*
|
||||
* Registers the existing Terminal component through the extension seams that
|
||||
* already exist in the shell (registerRailItem / registerTabComponent). The
|
||||
* component itself is unchanged and still lives in src/ui/features/terminal -
|
||||
* this only controls whether the shell knows about it.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled, so the tab and rail entry appear and disappear with the plugin.
|
||||
*/
|
||||
|
||||
export const id = "ssh-terminal";
|
||||
export const tabId = "terminal";
|
||||
|
||||
export async function register({
|
||||
registerRailItem,
|
||||
registerTabComponent,
|
||||
icons,
|
||||
}) {
|
||||
registerTabComponent(tabId, () =>
|
||||
import("@/features/terminal/Terminal").then((m) => ({
|
||||
default: m.Terminal,
|
||||
})),
|
||||
);
|
||||
|
||||
registerRailItem({
|
||||
id: tabId,
|
||||
icon: icons.SquareTerminal,
|
||||
labelKey: "nav.terminal",
|
||||
kind: "tab",
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({
|
||||
unregisterRailItem,
|
||||
unregisterTabComponent,
|
||||
}) {
|
||||
unregisterRailItem(tabId);
|
||||
unregisterTabComponent(tabId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"name": "SSH Terminal",
|
||||
"version": "1.0.0",
|
||||
"description": "Interactive SSH terminal sessions, with jump hosts, host key verification and session sharing.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Terminal",
|
||||
"icon": "SquareTerminal",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "terminal",
|
||||
"titleKey": "nav.terminal",
|
||||
"icon": "SquareTerminal",
|
||||
"openFrom": ["rail", "host-context-menu", "palette"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
Reference in new issue
Block a user