feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:02 -05:00
1 parent 4311a0e98a
commit 4e1dec8435
7 files changed
+695 -5

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
/**
* Turns a plugin's registered routes into an Express router mounted on the
* existing /plugin-api/:pluginId dispatcher.
*
* The plugin never sees the Express req/res objects. It gets a plain,
* structured-cloneable summary of the request, and returns a plain object that
* this module turns back into a response. That keeps sockets, headers carrying
* session cookies, and the rest of the server's request context on this side of
* the boundary.
*/
import express, { type Request, type Response, type Router } from "express";
import { pluginLogger } from "../utils/logger.js";
import type { PluginBroker, PluginRuntime } from "./broker.js";
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
/** Headers a plugin is allowed to see. Anything carrying auth is withheld. */
const FORWARDED_HEADERS = ["content-type", "accept", "user-agent"];
export function buildPluginRouter(
broker: PluginBroker,
runtime: PluginRuntime,
): Router {
const router = express.Router();
router.use(express.json({ limit: "1mb" }));
for (const route of runtime.routes) {
const key = `${route.method} ${route.path}`;
const method = route.method.toLowerCase() as
"get" | "post" | "put" | "patch" | "delete";
router[method](route.path, async (req: Request, res: Response) => {
try {
const result = await broker.invokeRoute(runtime, key, {
method: route.method,
path: req.path,
params: req.params,
query: req.query,
body: req.body ?? null,
headers: pickHeaders(req),
// The acting user, resolved by the server's own auth middleware.
// A plugin cannot set or spoof this.
userId: (req as Request & { userId?: string }).userId ?? null,
});
sendPluginResponse(res, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.error(
`Plugin ${runtime.plugin.id} route ${key} failed`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_http" },
);
res.status(502).json({ error: "Plugin route failed", detail: message });
}
});
}
return router;
}
function pickHeaders(req: Request): Record<string, string> {
const headers: Record<string, string> = {};
for (const name of FORWARDED_HEADERS) {
const value = req.headers[name];
if (typeof value === "string") headers[name] = value;
}
return headers;
}
function sendPluginResponse(res: Response, result: unknown): void {
if (result === null || result === undefined) {
res.status(204).end();
return;
}
const shape = result as {
status?: unknown;
body?: unknown;
headers?: Record<string, unknown>;
};
// An envelope is only recognised when `status` is present. Keying off `body`
// alone would swallow an ordinary payload that happens to have a body field,
// which is a perfectly normal thing for a handler to return.
const hasEnvelope =
typeof shape === "object" && shape !== null && "status" in shape;
const status = hasEnvelope ? Number(shape.status ?? 200) : 200;
const body = hasEnvelope ? (shape.body ?? null) : result;
if (!Number.isInteger(status) || status < 100 || status > 599) {
res.status(502).json({ error: "Plugin returned an invalid status code" });
return;
}
const serialised = JSON.stringify(body);
if (
serialised &&
Buffer.byteLength(serialised, "utf8") > MAX_RESPONSE_BYTES
) {
res.status(502).json({ error: "Plugin response exceeded the size limit" });
return;
}
res.status(status).json(body);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Composition root for the plugin runtime: wires the loader, the broker and the
* /plugin-api dispatcher together and exposes the handful of calls the rest of
* the server needs.
*
* The heavy dependencies (host resolution, the SSH pool, the repositories) are
* imported lazily inside the dep functions rather than at module scope. Several
* of them pull in the repository layer, and a static import here would create
* the same cycle hosts/automation-events.ts documents.
*/
import { pluginLogger } from "../utils/logger.js";
import {
registerPluginRouter,
unregisterPluginRouter,
} from "../database/routes/plugin-api-routes.js";
import { PluginBroker, type PluginRuntime } from "./broker.js";
import { buildPluginRouter } from "./http-bridge.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
let loader: PluginLoader | null = null;
let broker: PluginBroker | null = null;
function createBroker(): PluginBroker {
return new PluginBroker({
listHosts: async (userId) => {
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
// listByUserId, not listDecryptedByUserId: a plugin only ever gets host
// metadata, so there is no reason to decrypt secrets just to drop them.
const hosts = await createCurrentHostRepository().listByUserId(userId);
return hosts as unknown as Record<string, unknown>[];
},
resolveHost: async (hostId, userId) => {
// resolveHostById is the access gate and the decryption step in one. The
// object it returns carries plaintext secrets, so it must never leave
// this closure -- the broker maps it through toPluginHostView first.
const { resolveHostById } = await import("../hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
return host as unknown as Record<string, unknown> | null;
},
storage: {
get: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().get(pluginId, key);
},
set: async (pluginId, key, value) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(pluginId, key, value);
},
delete: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
listKeys: async (pluginId) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
},
ssh: {
/**
* The single most security-sensitive function in the plugin runtime.
*
* `host` below is the object resolveHostById returns: it carries the
* plaintext password, private key and key passphrase. It is a local
* inside this closure and is referenced only by createFleetSshFactory.
* Nothing derived from it is returned. The caller gets two closures --
* release and exec -- and exec resolves to stdout/stderr/code strings
* only, so there is no path from the worker back to the credential.
*/
open: async (hostId, userId) => {
const [
{ resolveHostById },
{ connectionPool },
{ createFleetSshFactory, getFleetPoolKey },
{ execCommand },
] = await Promise.all([
import("../hosts/host-resolver.js"),
import("../hosts/ssh-connection-pool.js"),
import("../hosts/ssh-client-factory.js"),
import("../hosts/metrics/widgets/common-utils.js"),
]);
// resolveHostById performs the RBAC check (canAccessHost) and returns
// null for both "missing" and "forbidden".
const host = await resolveHostById(hostId, userId);
if (!host) {
throw new Error(`Host ${hostId} not found or not accessible`);
}
const poolKey = getFleetPoolKey(host);
const client = await connectionPool.getConnection(
poolKey,
createFleetSshFactory(host),
);
return {
release: () => connectionPool.releaseConnection(poolKey, client),
exec: (command, timeoutMs) => execCommand(client, command, timeoutMs),
};
},
},
onRoutesChanged: (runtime) => remountRoutes(runtime),
});
}
function remountRoutes(runtime: PluginRuntime): void {
if (runtime.routes.length === 0) return;
registerPluginRouter(runtime.plugin.id, buildPluginRouter(broker!, runtime));
}
export function getPluginRuntime(): {
loader: PluginLoader;
broker: PluginBroker;
} {
if (!loader || !broker) {
broker = createBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
unregisterPluginRouter(plugin.id);
},
});
}
return { loader, broker };
}
/**
* Loads every plugin on disk and activates the ones marked enabled in the
* database. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
const {
createCurrentPluginRepository,
createCurrentPluginPermissionGrantRepository,
} = await import("../database/repositories/factory.js");
const records = await createCurrentPluginRepository().listAll();
const byId = new Map(records.map((record) => [record.id, record]));
for (const plugin of loaded) {
const record = byId.get(plugin.id);
if (!record || record.state !== "enabled") continue;
// The plugins table records no installer, so the plugin acts under the
// authority of whoever granted its capabilities. Without a grant there is
// no owner and nothing the plugin could do with one, so it stays stopped.
const grants =
await createCurrentPluginPermissionGrantRepository().listByPlugin(
plugin.id,
);
const ownerUserId = grants[0]?.grantedBy;
if (!ownerUserId) {
pluginLogger.warn(
`Plugin ${plugin.id} is enabled but has no capability grants, leaving it stopped`,
{ operation: "plugin_activate" },
);
continue;
}
try {
await activatePlugin(plugin.id, ownerUserId);
} catch (error) {
pluginLogger.error(
`Failed to activate plugin ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_activate" },
);
}
}
return loaded;
}
export async function activatePlugin(
pluginId: string,
ownerUserId: string,
): Promise<void> {
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
await pluginLoader.activate(pluginId, ownerUserId);
const runtime = pluginBroker.get(pluginId);
if (runtime) remountRoutes(runtime);
}
export async function deactivatePlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
await pluginLoader.deactivate(pluginId);
unregisterPluginRouter(pluginId);
}
export async function shutdownPlugins(): Promise<void> {
if (!loader) return;
for (const plugin of loader.list()) unregisterPluginRouter(plugin.id);
await loader.shutdown();
}