mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* HTTP for plugins: one mount point, one set of middleware, no ports.
|
||||
*
|
||||
* A plugin calls ctx.http.router() and gets an Express Router that core mounts
|
||||
* at /plugin-api/<id>/. Everything a route needs in front of it runs here
|
||||
* rather than in the plugin:
|
||||
*
|
||||
* 1. auth, unless the path was declared public;
|
||||
* 2. the actor, so ctx and core APIs know who is calling;
|
||||
* 3. an enabled check, so a disabled plugin answers 503 rather than 404;
|
||||
* 4. body limits;
|
||||
* 5. an error wrapper that logs against the plugin and leaks nothing.
|
||||
*
|
||||
* The wrapper never touches the response body, so SSE, file downloads and
|
||||
* multipart uploads stream through untouched.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
type Router,
|
||||
} from "express";
|
||||
import type {
|
||||
PluginMiddleware,
|
||||
PluginRouterOptions,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import { hasCapability } from "./permissions.js";
|
||||
|
||||
/** Matches what core's own routes accept, so moving a route changes nothing. */
|
||||
const DEFAULT_BODY_LIMIT = "2mb";
|
||||
|
||||
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
|
||||
const routers = new Map<string, Router>();
|
||||
|
||||
export function getPluginRouter(pluginId: string): Router | undefined {
|
||||
return routers.get(pluginId);
|
||||
}
|
||||
|
||||
export function getRegisteredHttpPluginIds(): string[] {
|
||||
return [...routers.keys()];
|
||||
}
|
||||
|
||||
export function unregisterPluginHttp(pluginId: string): void {
|
||||
if (!routers.delete(pluginId)) return;
|
||||
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
|
||||
operation: "plugin_http_unmount",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the plugin is currently running.
|
||||
*
|
||||
* Injected rather than imported so this module does not depend on the loader,
|
||||
* which imports ctx.ts, which would close a cycle.
|
||||
*/
|
||||
type IsEnabled = (pluginId: string) => boolean;
|
||||
|
||||
let isPluginEnabled: IsEnabled = () => true;
|
||||
|
||||
export function setPluginEnabledCheck(check: IsEnabled): void {
|
||||
isPluginEnabled = check;
|
||||
}
|
||||
|
||||
/** Test seam, so one test's stub does not leak into the next. */
|
||||
export function resetPluginHttp(): void {
|
||||
routers.clear();
|
||||
isPluginEnabled = () => true;
|
||||
}
|
||||
|
||||
function normalizePublicPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.length > 1 && withSlash.endsWith("/")
|
||||
? withSlash.slice(0, -1)
|
||||
: withSlash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a declared public path into a matcher.
|
||||
*
|
||||
* Express-style ":param" segments are supported, because the routes that have
|
||||
* to be public are usually keyed by a token ("/webhook/:token"). A parameter
|
||||
* matches exactly one segment and never a slash, so ":token" cannot swallow
|
||||
* the rest of the path and turn one declared route into a prefix hole.
|
||||
*/
|
||||
function compilePublicPath(path: string): RegExp {
|
||||
const escaped = path
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (segment.startsWith(":")) return "[^/]+";
|
||||
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
})
|
||||
.join("/");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the request path against the declared public paths.
|
||||
*
|
||||
* Full-path match only. A prefix match would turn one declared route into a
|
||||
* hole covering everything below it, which is not what declaring a single
|
||||
* public path should mean.
|
||||
*/
|
||||
function isPublicRequest(req: Request, publicPaths: RegExp[]): boolean {
|
||||
if (publicPaths.length === 0) return false;
|
||||
const path = normalizePublicPath(req.path || "/");
|
||||
return publicPaths.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
export interface CreatePluginRouterArgs {
|
||||
manifest: PluginManifest;
|
||||
options?: PluginRouterOptions;
|
||||
/** Records a runtime error against the plugin's error budget. */
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function createPluginRouter({
|
||||
manifest,
|
||||
options,
|
||||
reportError,
|
||||
}: CreatePluginRouterArgs): Router {
|
||||
const pluginId = manifest.id;
|
||||
const declaredPublic = (options?.public ?? []).map((path) =>
|
||||
normalizePublicPath(path),
|
||||
);
|
||||
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
|
||||
|
||||
if (declaredPublic.length > 0) {
|
||||
// Audited once at registration: the set of unauthenticated routes a plugin
|
||||
// has is worth seeing even if nobody ever calls them.
|
||||
pluginLogger.warn(
|
||||
`Plugin ${pluginId} serves ${declaredPublic.length} route(s) without authentication: ${declaredPublic.join(", ")}`,
|
||||
{ operation: "plugin_http_public" },
|
||||
);
|
||||
void writePublicRouteAudit(manifest, declaredPublic);
|
||||
}
|
||||
|
||||
const outer = express.Router();
|
||||
const inner = express.Router();
|
||||
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPluginEnabled(pluginId)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(503).json({ error: "Plugin is not running", pluginId });
|
||||
});
|
||||
|
||||
// The grant lives in the database, so it cannot be checked in the
|
||||
// synchronous router() call a plugin makes inside activate. Checked per
|
||||
// request instead: a plugin whose network:serve was revoked keeps its mount
|
||||
// point and refuses every request through it, which is the same outcome a
|
||||
// revoke should have and takes effect without a restart.
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const granted = await hasCapability(
|
||||
pluginId,
|
||||
"network:serve",
|
||||
manifest.capabilities,
|
||||
);
|
||||
if (!granted) {
|
||||
res.status(403).json({
|
||||
error: "Plugin is not granted the network:serve capability",
|
||||
pluginId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const authenticate = AuthManager.getInstance().createAuthMiddleware();
|
||||
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPublicRequest(req, publicPaths)) {
|
||||
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
|
||||
operation: "plugin_http_public_request",
|
||||
});
|
||||
next();
|
||||
return;
|
||||
}
|
||||
authenticate(req, res, next);
|
||||
});
|
||||
|
||||
if (!options?.rawBody) {
|
||||
const limit = options?.bodyLimit ?? DEFAULT_BODY_LIMIT;
|
||||
// Both parsers ignore a body they do not claim, so multipart still reaches
|
||||
// multer untouched. database.ts skips its own global parser for
|
||||
// /plugin-api so this limit is the one that actually applies.
|
||||
outer.use(express.json({ limit }));
|
||||
outer.use(express.urlencoded({ limit, extended: true }));
|
||||
}
|
||||
|
||||
// After auth, so the actor is the user core authenticated rather than
|
||||
// whatever the request claimed.
|
||||
outer.use((req: Request, _res: Response, next: NextFunction) => {
|
||||
const userId = (req as Request & { userId?: string }).userId;
|
||||
if (!userId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
runAsActor(userId, "request", () => next());
|
||||
});
|
||||
|
||||
outer.use(inner);
|
||||
|
||||
outer.use(
|
||||
(
|
||||
error: unknown,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void => {
|
||||
void _next;
|
||||
reportError(error);
|
||||
pluginLogger.error(
|
||||
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_http_error" },
|
||||
);
|
||||
if (res.headersSent) {
|
||||
// Mid-stream: the only honest thing left is to end it.
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "Plugin request failed" });
|
||||
},
|
||||
);
|
||||
|
||||
routers.set(pluginId, outer);
|
||||
pluginLogger.info(`Mounted /plugin-api/${pluginId}`, {
|
||||
operation: "plugin_http_mount",
|
||||
});
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
async function writePublicRouteAudit(
|
||||
manifest: PluginManifest,
|
||||
paths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: "system",
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
resourceId: manifest.id,
|
||||
resourceName: manifest.name,
|
||||
details: `unauthenticated routes: ${paths.join(", ")}`,
|
||||
success: true,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never stop a plugin starting.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ctx.rbac.require.
|
||||
*
|
||||
* A plugin may only gate on a permission it declares itself. Without that a
|
||||
* route could require admin.users.manage and borrow someone else's authority,
|
||||
* which is the escalation shape the manifest validator already blocks for
|
||||
* role defaults.
|
||||
*/
|
||||
export function createRbacMiddleware(
|
||||
manifest: PluginManifest,
|
||||
permission: string,
|
||||
): PluginMiddleware {
|
||||
const declared = new Set(
|
||||
manifest.contributes?.permissionGroup?.permissions ?? [],
|
||||
);
|
||||
|
||||
if (!declared.has(permission)) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissionGroup.permissions`,
|
||||
);
|
||||
}
|
||||
|
||||
return ((req: Request, res: Response, next: NextFunction) => {
|
||||
void (async () => {
|
||||
const userId = (req as Request & { userId?: string }).userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const allowed = await PermissionManager.getInstance().hasPermission(
|
||||
userId,
|
||||
permission,
|
||||
);
|
||||
if (!allowed) {
|
||||
res.status(403).json({ error: "Permission denied", permission });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
}) as unknown as PluginMiddleware;
|
||||
}
|
||||
Reference in new issue
Block a user