feat: move fleets into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-21 20:44:00 -05:00
1 parent 76b2e5739a
commit a6f6a0439c
17 files changed
+281 -38

No files matched your search

+2 -2
View File
@@ -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);
@@ -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);
}
File diff suppressed because it is too large. Load diff
+8
View File
@@ -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<string> = new Set([
"ssh-terminal",
@@ -76,6 +83,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ai",
"proxmox",
"remote-desktop",
"fleets",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+6 -5
View File
@@ -299,11 +299,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// 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");
@@ -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/);
});
});
@@ -39,10 +39,11 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
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);
@@ -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<{