diff --git a/plugins/fleets/backend/index.mjs b/plugins/fleets/backend/index.mjs new file mode 100644 index 000000000..b7096068b --- /dev/null +++ b/plugins/fleets/backend/index.mjs @@ -0,0 +1,63 @@ +/** + * Fleets - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker for the same + * reason proxmox and ai do (see src/backend/plugins/first-party.ts): its + * execute/transfer routes resolve and connect to many hosts concurrently over + * the shared SSH pool via resolveHostById and withConnection, read/write + * files over SFTP, and stream a zip archive back for pull requests. None of + * that fits the worker ctx -- ctx.hosts is a 13-field read-only view with no + * fleets surface at all, and ctx.http.route replies with a single postMessage + * value rather than a stream. + * + * Like proxmox, this plugin does not own a live transport (no WebSocket + * server, no dedicated port), so it registers its Express router with the + * always-mounted /fleets dispatcher in + * src/backend/database/routes/fleet-dispatch.ts instead of getting its own + * port. + * + * routes.ts is what used to be src/backend/database/routes/fleet-routes.ts, + * moved here and rewritten from `export default router` into + * startFleetsService()/stopFleetsService(), called from + * activate()/deactivate() below. The fleets/fleetMembers/fleetInventory + * tables and their repositories stay in core (src/backend/database/db/schema.ts, + * src/backend/database/repositories/fleet-repository.ts, + * fleet-inventory-repository.ts) -- this plugin system has no owned-table + * migration mechanism of its own, and these tables carry FKs into core's + * hosts/users tables. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for routes.js sits right next to this .mjs, and core modules resolve + * via ../../../backend/backend/... from there (dist/plugins/fleets/backend + * -> dist/backend/backend). Under vitest/dev nothing imports this plugin + * directly (the loader loads in-process plugins with a plain dynamic import, + * no tsx loader involved), so the only layout that needs to resolve here is + * the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startFleetsService(); + ctx.log.info("Fleets router registered at /fleets"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopFleetsService(); + routesModule = null; + } +} diff --git a/src/backend/database/routes/fleet-routes.ts b/plugins/fleets/backend/routes.ts similarity index 95% rename from src/backend/database/routes/fleet-routes.ts rename to plugins/fleets/backend/routes.ts index 2232e0c93..a7890fb24 100644 --- a/src/backend/database/routes/fleet-routes.ts +++ b/plugins/fleets/backend/routes.ts @@ -1,44 +1,51 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import type { AuthenticatedRequest } from "../../../src/types/index.js"; import express, { type Request, type Response } from "express"; import multer from "multer"; import JSZip from "jszip"; import type { Client, SFTPWrapper } from "ssh2"; -import { authLogger, databaseLogger } from "../../utils/logger.js"; -import { AuthManager } from "../../utils/auth-manager.js"; +import { + authLogger, + databaseLogger, +} from "../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; import { PermissionManager, type HostAction, -} from "../../utils/permission-manager.js"; +} from "../../../src/backend/utils/permission-manager.js"; import { isSharePermissionLevel, expiryFromDuration, parseShareTargets, -} from "./rbac.js"; +} from "../../../src/backend/database/routes/rbac.js"; import { createCurrentFleetRepository, createCurrentFleetInventoryRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentUserRepository, -} from "../repositories/factory.js"; -import { resolveHostById } from "../../hosts/host-resolver.js"; +} from "../../../src/backend/database/repositories/factory.js"; +import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; import { getFleetPoolKey, createFleetSshFactory, -} from "../../hosts/ssh-client-factory.js"; -import { withConnection } from "../../hosts/ssh-connection-pool.js"; -import { execCommand } from "../../hosts/metrics-shared/common-utils.js"; -import { detectPlatform } from "../../hosts/metrics-shared/platform.js"; +} from "../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js"; +import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/platform.js"; import { execElevated, ElevationError, -} from "../../hosts/metrics-shared/exec-elevated.js"; -import { buildPackageActionCommand } from "../../hosts/metrics-shared/package-commands.js"; -import { isValidPackageName } from "../../hosts/metrics-shared/validation.js"; -import { resolveSnippetCommand } from "./snippets-execution.js"; +} from "../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { buildPackageActionCommand } from "../../../src/backend/hosts/metrics-shared/package-commands.js"; +import { isValidPackageName } from "../../../src/backend/hosts/metrics-shared/validation.js"; +import { resolveSnippetCommand } from "../../../src/backend/database/routes/snippets-execution.js"; +import { + registerFleetsRouter, + unregisterFleetsRouter, +} from "../../../src/backend/database/routes/fleet-dispatch.js"; -const router = express.Router(); +export const router = express.Router(); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -622,7 +629,7 @@ router.post( const expiresAt = expiryFromDuration(durationHours); const rbacAccessRepository = createCurrentRbacAccessRepository(); const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); + await import("../../../src/backend/utils/shared-host-secrets-manager.js"); const secretsManager = SharedHostSecretsManager.getInstance(); const hostResults: Array<{ @@ -1496,4 +1503,13 @@ export function buildRemoveCommand( } } -export default router; +/** Called from activate(). Mounts this router at /fleets via the shared + * dispatcher. */ +export function startFleetsService(): void { + registerFleetsRouter(router); +} + +/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */ +export function stopFleetsService(): void { + unregisterFleetsRouter(); +} diff --git a/src/ui/sidebar/FleetInventoryTab.tsx b/plugins/fleets/frontend/FleetInventoryTab.tsx similarity index 99% rename from src/ui/sidebar/FleetInventoryTab.tsx rename to plugins/fleets/frontend/FleetInventoryTab.tsx index 4ac9b9447..c16641447 100644 --- a/src/ui/sidebar/FleetInventoryTab.tsx +++ b/plugins/fleets/frontend/FleetInventoryTab.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import { useEffect, useMemo, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; diff --git a/src/ui/sidebar/FleetsPanel.tsx b/plugins/fleets/frontend/FleetsPanel.tsx similarity index 99% rename from src/ui/sidebar/FleetsPanel.tsx rename to plugins/fleets/frontend/FleetsPanel.tsx index 438d8931b..3aa07545b 100644 --- a/src/ui/sidebar/FleetsPanel.tsx +++ b/plugins/fleets/frontend/FleetsPanel.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; diff --git a/plugins/fleets/frontend/index.mjs b/plugins/fleets/frontend/index.mjs new file mode 100644 index 000000000..945fa4da1 --- /dev/null +++ b/plugins/fleets/frontend/index.mjs @@ -0,0 +1,40 @@ +/** + * Frontend half of the fleets plugin. + * + * FleetsPanel is a left-sidebar railView panel (opened from the rail, not a + * Tab), the same shape as HostsPanel/CredentialsPanel/AiPanel. None of those + * go through registerRailItem/registerTabComponent either -- per + * src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is + * rendered directly by AppShell.tsx rather than registered at runtime, the + * same way ai's AiPanel is. FleetsPanel is reached today through a direct + * relative import in AppShell.tsx (../../plugins/fleets/frontend/FleetsPanel), + * not through register() below. + * + * register()/unregister() still follow the same shape every other plugin's + * frontend entry does, so this is ready the moment a real frontend plugin + * loader exists -- see plugins/ssh-terminal/frontend/index.mjs and + * src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright + * "there is no plugin loader yet". + * + * The fleet-inventory tab (opened from within FleetsPanel) is wired + * separately in src/ui/shell/tabUtils.tsx, the same way docker/host-metrics + * tabs are -- a plugin tab id that already exists in the built-in TabType + * union goes through the ordinary tabSurfaceLoaders/switch wiring there, not + * through registerTabComponent (which is for tab ids outside that union). + */ + +export const id = "fleets"; +export const railViewId = "fleets"; + +export async function register({ registerRailItem, icons }) { + registerRailItem?.({ + id: railViewId, + icon: icons.Boxes, + labelKey: "nav.fleets", + separatorAfter: true, + }); +} + +export async function unregister({ unregisterRailItem }) { + unregisterRailItem?.(railViewId); +} diff --git a/plugins/fleets/manifest.json b/plugins/fleets/manifest.json new file mode 100644 index 000000000..b98a8fdd6 --- /dev/null +++ b/plugins/fleets/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "fleets", + "name": "Fleets", + "version": "1.0.0", + "description": "Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Infrastructure", + "icon": "Boxes", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "sidecars": [] +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 27a4c7c77..88fde2f9c 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -10,7 +10,7 @@ import hostRoutes from "./routes/host.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; -import fleetRoutes from "./routes/fleet-routes.js"; +import fleetsDispatch from "./routes/fleet-dispatch.js"; import workspaceRoutes from "./routes/workspaces.js"; import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js"; import terminalRoutes from "./routes/terminal.js"; @@ -1753,7 +1753,7 @@ app.use("/host", hostRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); -app.use("/fleets", fleetRoutes); +app.use("/fleets", fleetsDispatch); app.use("/workspaces", workspaceRoutes); app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); diff --git a/src/backend/database/routes/fleet-dispatch.ts b/src/backend/database/routes/fleet-dispatch.ts new file mode 100644 index 000000000..9adc2bd10 --- /dev/null +++ b/src/backend/database/routes/fleet-dispatch.ts @@ -0,0 +1,48 @@ +// Mounted at /fleets. Fleets ships as a first-party in-process plugin +// (plugins/fleets) rather than a route file imported directly here, so this +// module exists to keep /fleets/* working at its original path without +// handing the plugin a reference to the shared app instance. The plugin +// runtime registers its router via registerFleetsRouter when it activates, +// and clears it on deactivate, so a disabled plugin falls back to the 404 +// below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Fleets plugin's own), and every +// route in that router already applies authenticateJWT/requireDataAccess +// itself, the same as every other router mounted alongside it here (/users, +// /host, /snippets, ...). /plugin-api gates at the mount point because it +// forwards to arbitrary third-party plugin code that cannot be assumed to +// gate itself; that reasoning does not apply to a single first-party router +// that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerFleetsRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered Fleets plugin router", { + operation: "fleets_dispatch_register", + }); +} + +export function unregisterFleetsRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered Fleets plugin router", { + operation: "fleets_dispatch_register", + }); +} + +export default function fleetsDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Fleets is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index aff62a689..32d4ff7c8 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -68,6 +68,13 @@ * /connect-host/:hostId route does multi-step credential/tunnel resolution * (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw * net.Socket plumbing that cannot cross a structured-clone boundary. + * + * fleets is here for the same reason as ai and proxmox: it does not own a + * transport, but its execute/transfer routes resolve and connect to many + * hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP + * reads/writes and a zip archive for its push/pull routes, and buffer + * multipart file uploads. None of that fits the worker's single-exec + * ctx.ssh or single-postMessage-reply ctx.http.route. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -76,6 +83,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ai", "proxmox", "remote-desktop", + "fleets", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 60a955623..bec7570e2 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -299,11 +299,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. - // AI, Proxmox and Remote Desktop are also absent for the same reason: - // each plugin registers its router with its own dispatcher in - // database.ts on activate (/ai, /proxmox, /guacamole), so disabling any - // of them makes its routes 404 instead of leaving a dead import here. - // See plugins/ai, plugins/proxmox and plugins/remote-desktop. + // AI, Proxmox, Remote Desktop and Fleets are also absent for the same + // reason: each plugin registers its router with its own dispatcher in + // database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so + // disabling any of them makes its routes 404 instead of leaving a dead + // import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop + // and plugins/fleets. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 12ca29e61..6dcc92777 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -244,3 +244,42 @@ describe("bundled remote-desktop plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled fleets plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "fleets"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest(readBundledManifest("fleets")); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("fleets"); + expect(manifest?.category).toBe("Infrastructure"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("fleets")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("fleets")).toBe(true); + expect(runsInProcess("fleets", manifest!.permissions)).toBe(true); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "fleets", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index 306032d73..ba13d1314 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,10 +39,11 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "docker", + "fleets", "host-metrics", "proxmox", "remote-desktop", @@ -50,13 +51,14 @@ describe("first-party allowlist", () => { ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); expect(isFirstParty("ai")).toBe(true); expect(isFirstParty("proxmox")).toBe(true); expect(isFirstParty("remote-desktop")).toBe(true); + expect(isFirstParty("fleets")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); diff --git a/src/backend/tests/database/routes/fleet-routes.test.ts b/src/backend/tests/plugins/fleets/routes.test.ts similarity index 97% rename from src/backend/tests/database/routes/fleet-routes.test.ts rename to src/backend/tests/plugins/fleets/routes.test.ts index 3b5e0f183..9ea6db47f 100644 --- a/src/backend/tests/database/routes/fleet-routes.test.ts +++ b/src/backend/tests/plugins/fleets/routes.test.ts @@ -135,11 +135,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({}), })); -const { - default: router, - parseInventoryProbe, - buildRemoveCommand, -} = await import("../../../database/routes/fleet-routes.js"); +vi.mock("../../../database/routes/fleet-dispatch.js", () => ({ + registerFleetsRouter: vi.fn(), + unregisterFleetsRouter: vi.fn(), +})); + +const { router, parseInventoryProbe, buildRemoveCommand } = + await import("../../../../../plugins/fleets/backend/routes.js"); function findLayer(method: string, path: string) { const stack = (router as unknown as Router).stack as Array<{ diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index e3791996e..c20b89169 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -92,7 +92,9 @@ const MacrosPanel = lazy(() => import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })), ); const FleetsPanel = lazy(() => - import("@/sidebar/FleetsPanel").then((m) => ({ default: m.FleetsPanel })), + import("../../plugins/fleets/frontend/FleetsPanel").then((m) => ({ + default: m.FleetsPanel, + })), ); const WorkspacesPanel = lazy(() => import("@/sidebar/WorkspacesPanel").then((m) => ({ diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index 83c92dce2..eee9a333d 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -42,6 +42,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record = { "ssh-terminal": ["terminal"], "remote-desktop": ["rdp", "vnc", "telnet"], ai: ["ai"], + fleets: ["fleet-inventory"], }; let pluginEnabled = new Map(); diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index ca7313347..0a7744e94 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -145,7 +145,7 @@ const Serial = lazy(() => })), ); const FleetInventoryTab = lazy(() => - import("@/sidebar/FleetInventoryTab").then((m) => ({ + import("../../../plugins/fleets/frontend/FleetInventoryTab").then((m) => ({ default: m.FleetInventoryTab, })), ); diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index e16c32256..873c38c89 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -31,7 +31,8 @@ "plugins/host-metrics/backend/**/*.ts", "plugins/ai/backend/**/*.ts", "plugins/proxmox/backend/**/*.ts", - "plugins/remote-desktop/backend/**/*.ts" + "plugins/remote-desktop/backend/**/*.ts", + "plugins/fleets/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }