/** * 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(); /** What the server ran at the last sync, for the sync panel. */ export function lastRemotePlugins(): RemotePlugin[] { return lastRemote; } async function isEnabledLocally(pluginId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 { 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); }