mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
/**
|
|
* A linked desktop runs the features its server runs.
|
|
*
|
|
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
|
* there is installed here, on or off to match, with the capabilities an
|
|
* admin granted it there. Plugins marked "local" are left to the desktop and
|
|
* "server" ones never run on a linked desktop. A user plugin no key trusted
|
|
* here signed is only ever switched off; the desktop's admin turns it on.
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import semver from "semver";
|
|
import {
|
|
resolvePluginDesktopMode,
|
|
type PluginDesktopMode,
|
|
} from "@termix-ssh/plugin-sdk/manifest";
|
|
import { syncLogger } from "../../utils/logger.js";
|
|
import { sendCoreAlert } from "../../notify/core-notify.js";
|
|
import {
|
|
getPluginRuntime,
|
|
installPluginArtifact,
|
|
registerLoadedPlugins,
|
|
setPluginEnabled,
|
|
unloadPlugin,
|
|
} from "../../plugins/index.js";
|
|
import { getPluginsDir } from "../../plugins/paths.js";
|
|
import {
|
|
isLocallyTrustedPluginKey,
|
|
setSyncedRegistryKeys,
|
|
} from "../../plugins/registries.js";
|
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
|
import {
|
|
createCurrentPluginPermissionGrantRepository,
|
|
createCurrentPluginRepository,
|
|
} from "../../database/repositories/factory.js";
|
|
import { remoteFetch, remoteJson } from "./http.js";
|
|
import type { SyncLink } from "./link-store.js";
|
|
|
|
export interface RemotePlugin {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
source: "bundled" | "user";
|
|
/** The server can send a signed .tmxplug for it. */
|
|
artifact?: boolean;
|
|
enabled: boolean;
|
|
active: boolean;
|
|
desktop: PluginDesktopMode;
|
|
syncEntities: string[];
|
|
granted: string[];
|
|
}
|
|
|
|
let lastRemote: RemotePlugin[] = [];
|
|
const alertedVersions = new Set<string>();
|
|
|
|
/** What the server ran at the last sync, for the sync panel. */
|
|
export function lastRemotePlugins(): RemotePlugin[] {
|
|
return lastRemote;
|
|
}
|
|
|
|
async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
|
const record = await createCurrentPluginRepository().findById(pluginId);
|
|
return record?.state === "enabled";
|
|
}
|
|
|
|
/**
|
|
* Signed by a key this desktop trusts on its own. A key the server sent
|
|
* would let the server's admin sign anything, so it does not count.
|
|
*/
|
|
function vouchedHere(local: LoadedPlugin): boolean {
|
|
return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy);
|
|
}
|
|
|
|
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
|
/** Downloads next to the installed copy, which stays until the swap. */
|
|
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
|
|
const response = await remoteFetch(
|
|
link,
|
|
`/sync/v2/plugins/${encodeURIComponent(plugin.id)}/package`,
|
|
{ timeoutMs: 120_000 },
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Could not download ${plugin.id} (${response.status})`);
|
|
}
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const dir = getPluginsDir();
|
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
const file = path.join(dir, `${plugin.id}.tmxplug.download`);
|
|
await fs.promises.writeFile(file, buffer);
|
|
const signature = response.headers.get("x-termix-signature");
|
|
const sigFile = `${file}.sig`;
|
|
if (signature) {
|
|
await fs.promises.writeFile(
|
|
sigFile,
|
|
Buffer.from(signature, "base64").toString("utf8"),
|
|
);
|
|
} else {
|
|
await fs.promises.rm(sigFile, { force: true });
|
|
}
|
|
return file;
|
|
}
|
|
|
|
async function moveIfThere(from: string, to: string): Promise<void> {
|
|
await fs.promises.rm(to, { force: true });
|
|
if (fs.existsSync(from)) await fs.promises.rename(from, to);
|
|
}
|
|
|
|
/**
|
|
* Swaps a downloaded copy in for the installed one. If it fails to install,
|
|
* the old copy goes back on disk and is loaded again, so a bad download never
|
|
* leaves the desktop without the plugin.
|
|
*/
|
|
async function replaceWithDownload(
|
|
pluginId: string,
|
|
downloaded: string,
|
|
previous: LoadedPlugin | undefined,
|
|
wasEnabled: boolean,
|
|
): Promise<LoadedPlugin> {
|
|
const file = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
|
const backup = `${file}.previous`;
|
|
await moveIfThere(file, backup);
|
|
await moveIfThere(`${file}.sig`, `${backup}.sig`);
|
|
await moveIfThere(downloaded, file);
|
|
await moveIfThere(`${downloaded}.sig`, `${file}.sig`);
|
|
if (previous) await unloadPlugin(pluginId);
|
|
try {
|
|
const installed = await installPluginArtifact(file);
|
|
// Approval belongs to the reviewed version, regardless of remote metadata.
|
|
if (wasEnabled && installed.source === "user" && !vouchedHere(installed)) {
|
|
await setPluginEnabled(pluginId, false);
|
|
}
|
|
await fs.promises.rm(backup, { force: true });
|
|
await fs.promises.rm(`${backup}.sig`, { force: true });
|
|
return installed;
|
|
} catch (error) {
|
|
await moveIfThere(backup, file);
|
|
await moveIfThere(`${backup}.sig`, `${file}.sig`);
|
|
if (previous) await restorePrevious(pluginId, previous, file, wasEnabled);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function restorePrevious(
|
|
pluginId: string,
|
|
previous: LoadedPlugin,
|
|
file: string,
|
|
wasEnabled: boolean,
|
|
): Promise<void> {
|
|
const { loader } = getPluginRuntime();
|
|
if (loader.get(pluginId)) return;
|
|
try {
|
|
if (fs.existsSync(file)) {
|
|
await installPluginArtifact(file);
|
|
} else if (previous.source === "bundled") {
|
|
await registerLoadedPlugins([await loader.loadBundled(pluginId)]);
|
|
}
|
|
if (wasEnabled && loader.get(pluginId)) {
|
|
await setPluginEnabled(pluginId, true);
|
|
}
|
|
} catch (error) {
|
|
syncLogger.warn("Could not restore a feature after a failed update", {
|
|
operation: "sync_plugin_restore",
|
|
pluginId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
|
const repository = createCurrentPluginPermissionGrantRepository();
|
|
const local = getPluginRuntime().loader.get(plugin.id);
|
|
if (!local || local.source !== "user") return;
|
|
// Grants are only copied for code a trusted key signed. Anything else the
|
|
// desktop's own admin has to review, as with any plugin dropped on disk.
|
|
if (!vouchedHere(local)) return;
|
|
const declared = new Set(local.manifest.capabilities);
|
|
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
|
const existing = await repository.listByPlugin(plugin.id);
|
|
for (const grant of existing) {
|
|
if (!wanted.has(grant.capability)) {
|
|
await repository.revoke(plugin.id, grant.capability);
|
|
}
|
|
}
|
|
const have = new Set(existing.map((grant) => grant.capability));
|
|
for (const capability of wanted) {
|
|
if (have.has(capability)) continue;
|
|
await repository.grant({
|
|
pluginId: plugin.id,
|
|
capability,
|
|
grantedBy: null,
|
|
source: "admin",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function alertOlderDesktop(plugin: RemotePlugin): Promise<void> {
|
|
const key = `${plugin.id}@${plugin.version}`;
|
|
if (alertedVersions.has(key)) return;
|
|
alertedVersions.add(key);
|
|
await sendCoreAlert({
|
|
title: "Update the desktop app",
|
|
body: `The server runs a newer ${plugin.name} (${plugin.version}) than this app has. Update the app to get it.`,
|
|
severity: "info",
|
|
category: "termix.sync.desktop_outdated",
|
|
dedupeKey: `sync:outdated:${key}`,
|
|
audience: "admins",
|
|
});
|
|
}
|
|
|
|
async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
|
const key = `unsigned:${plugin.id}@${plugin.version}`;
|
|
if (alertedVersions.has(key)) return;
|
|
alertedVersions.add(key);
|
|
await sendCoreAlert({
|
|
title: "Review a feature from your server",
|
|
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`,
|
|
severity: "warning",
|
|
category: "termix.sync.unsigned_plugin",
|
|
dedupeKey: `sync:${key}`,
|
|
audience: "admins",
|
|
});
|
|
}
|
|
|
|
async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|
// The id becomes a file name below, so it must be a real plugin id.
|
|
if (typeof plugin.id !== "string" || !PLUGIN_ID_PATTERN.test(plugin.id)) {
|
|
throw new Error("The server sent a plugin with an invalid id");
|
|
}
|
|
const { loader } = getPluginRuntime();
|
|
let local = loader.get(plugin.id);
|
|
const mode = local
|
|
? resolvePluginDesktopMode(local.manifest)
|
|
: (plugin.desktop ?? "mirror");
|
|
|
|
if (mode === "server") {
|
|
if (local && (await isEnabledLocally(plugin.id))) {
|
|
await setPluginEnabled(plugin.id, false);
|
|
}
|
|
return;
|
|
}
|
|
if (mode !== "mirror") return;
|
|
|
|
if (plugin.source === "bundled") {
|
|
const newer = !local || semver.gt(plugin.version, local.manifest.version);
|
|
if (newer && plugin.artifact && local?.state !== "activating") {
|
|
// A signed update of a bundled plugin. The loader checks the signature
|
|
// and that the version really is newer.
|
|
const wasEnabled = await isEnabledLocally(plugin.id);
|
|
try {
|
|
const downloaded = await download(link, plugin);
|
|
local = await replaceWithDownload(
|
|
plugin.id,
|
|
downloaded,
|
|
local,
|
|
wasEnabled,
|
|
);
|
|
} catch (error) {
|
|
await alertOlderDesktop(plugin);
|
|
throw error;
|
|
}
|
|
if (wasEnabled && local.source === "bundled") {
|
|
await setPluginEnabled(plugin.id, true);
|
|
}
|
|
} else if (newer) {
|
|
await alertOlderDesktop(plugin);
|
|
}
|
|
if (!local) return;
|
|
} else if (plugin.enabled || local) {
|
|
const outdated =
|
|
!local ||
|
|
local.source !== "user" ||
|
|
local.manifest.version !== plugin.version;
|
|
if (outdated && (!local || local.source === "user")) {
|
|
const wasEnabled = local ? await isEnabledLocally(plugin.id) : false;
|
|
const downloaded = await download(link, plugin);
|
|
local = await replaceWithDownload(
|
|
plugin.id,
|
|
downloaded,
|
|
local,
|
|
wasEnabled,
|
|
);
|
|
if (wasEnabled && !plugin.enabled) {
|
|
await setPluginEnabled(plugin.id, false);
|
|
}
|
|
}
|
|
await matchGrants(plugin);
|
|
}
|
|
|
|
if (!local) return;
|
|
// Remote metadata cannot confer bundled trust on a locally loaded user plugin.
|
|
if (local.source === "user" && !vouchedHere(local)) {
|
|
const enabledHere = await isEnabledLocally(plugin.id);
|
|
if (!plugin.enabled && enabledHere) {
|
|
await setPluginEnabled(plugin.id, false);
|
|
} else if (plugin.enabled && !enabledHere) {
|
|
await alertUnsigned(plugin);
|
|
}
|
|
return;
|
|
}
|
|
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
|
await setPluginEnabled(plugin.id, plugin.enabled);
|
|
} else if (plugin.enabled && local.state !== "active") {
|
|
await setPluginEnabled(plugin.id, true);
|
|
}
|
|
}
|
|
|
|
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
|
const { serialized } = await import("../../plugins/manage.js");
|
|
const { plugins, registryKeys } = await remoteJson<{
|
|
plugins: RemotePlugin[];
|
|
registryKeys?: unknown[];
|
|
}>(link, "/sync/v2/plugins");
|
|
// Before any download: a plugin from a custom registry needs its key.
|
|
await setSyncedRegistryKeys(Array.isArray(registryKeys) ? registryKeys : []);
|
|
lastRemote = plugins;
|
|
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
|
|
|
|
for (const plugin of plugins) {
|
|
try {
|
|
// Share the consent/enable lock so an update cannot swap code mid-approval.
|
|
await serialized(() => mirrorOne(link, plugin));
|
|
} catch (error) {
|
|
syncLogger.warn("Could not match a feature to the server", {
|
|
operation: "sync_plugin_mirror",
|
|
pluginId: plugin.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
// A plugin the server no longer has is switched off here. Its data stays.
|
|
for (const local of getPluginRuntime().loader.list()) {
|
|
if (remoteIds.has(local.id) || local.source !== "user") continue;
|
|
if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue;
|
|
if (await isEnabledLocally(local.id)) {
|
|
await setPluginEnabled(local.id, false).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a plugin's on/off state belongs to the linked server. Code no key
|
|
* trusted here vouches for is the desktop's own admin's call to turn on.
|
|
*/
|
|
export function isServerManaged(pluginId: string): boolean {
|
|
const local = getPluginRuntime().loader.get(pluginId);
|
|
const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror";
|
|
if (mode !== "mirror") return mode === "server";
|
|
return local?.source !== "user" || vouchedHere(local);
|
|
}
|