feat: serve plugin http and websockets from one mount instead of ports

This commit is contained in:
LukeGus committed 2026-09-22 16:39:41 -05:00
1 parent c496a093ca
commit 8431570ff7
102 files changed
+2894 -2308

No files matched your search

+54 -17
View File
@@ -126,13 +126,51 @@ frozen array for a server that predates the endpoint.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
`/plugin-ws/<id>/`, served by the main backend with core auth. No plugin gets
its own port or nginx block.
`/plugin-ws/<id>/<path>`, served by the main backend with core auth. No plugin
gets its own port or nginx block.
**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no
routes registered, so it returns 404. The bundled plugins still keep their
original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`,
and four still own ports of their own.
**A4 delivered this.** A plugin calls `ctx.http.router(options)` and gets an
Express Router core mounts at `/plugin-api/<id>/`, or `ctx.ws.route(path,
handler)` for a socket. Core runs the same middleware in front of every plugin:
1. **Auth**, unless the path is listed in `options.public`. A public path is
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`.
2. **The actor**, from the user core authenticated, never from the request body.
3. **An enabled check** returning 503 while the plugin is disabled. That is a
different fact from 404 ("no such plugin") and a caller can act on it.
4. **Body limits**, defaulting to the 2mb core itself accepts. `database.ts`
skips its own global parser for `/plugin-api` so a plugin's `bodyLimit`
actually applies; `rawBody` turns the parsers off for a router that parses
its own (multer, a raw body for signature checks).
5. **An error wrapper** that logs against the plugin, never leaks a stack, and
feeds the error budget.
`ctx.rbac.require(permission)` adds a per-route RBAC gate, and a plugin may only
require a permission it declares itself.
Both surfaces need `network:serve`. The declaration is checked when the router
or route is created; the **grant** is checked per request and per upgrade,
because it lives in the database and `router()` has to be synchronous. A revoked
grant therefore takes effect on the next request without a restart: HTTP answers
403, an upgrade is refused 403.
Sockets are served from the main server's `upgrade` event, on both the HTTP
server and the direct HTTPS one. Auth is the same `utils/ws-auth.ts` path every
other Termix socket uses, and a token still awaiting TOTP is refused exactly as
it is for HTTP. Binary frames and backpressure are untouched. Everything a
plugin registered is disposed on deactivate, and live sockets are **closed**,
not merely unrouted: a terminal session must not outlive the plugin that owns it.
`ctx.ws.upgrade(path, handler)` hands over the raw upgrade, after core has
authenticated it, for a library that insists on owning its own WebSocketServer.
guacamole-lite is why it exists. Three routes are public because they
authenticate themselves in a way core cannot: the terminal (a share-link guest
arrives with a share token), the Docker console (per-message auth), and the
Guacamole display (a single-use encrypted connection token in the query, minted
by an authenticated route).
### 7. Settings
@@ -407,13 +445,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.require` | own permissions only | **A4** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.settings.*` | `settings:read-core` | A6 |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.fetch` | `network:outbound` | A4 |
| `ctx.fetch` | `network:outbound` | B |
### The actor
@@ -581,7 +620,7 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin components | Warning | 16 files | A7 |
| A plugin importing core by relative path | Warning | 71 files | D1 |
| A plugin importing core by relative path | Warning | 70 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
@@ -610,11 +649,9 @@ Known specifics:
degrade silently when automations is absent, so host-metrics declares it as
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Eight plugins keep their original core URLs through the dispatchers in
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
moves them.
- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005,
docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main
server.
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage
30012. Each keeps its nginx block until its own Phase B step. No plugin owns
a port any more (A4).
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
+127
View File
@@ -150,6 +150,130 @@ export interface PluginSecrets {
) => Promise<string | null>;
}
/**
* Express types, structurally. The SDK does not depend on express: a plugin
* brings its own copy, and a nominal type from core's would not match it.
*/
export interface PluginRequestLike {
method: string;
url: string;
path: string;
headers: Record<string, unknown>;
[key: string]: unknown;
}
export interface PluginResponseLike {
status: (code: number) => PluginResponseLike;
json: (body: unknown) => unknown;
[key: string]: unknown;
}
export type PluginNextFunction = (error?: unknown) => void;
export type PluginMiddleware = (
req: PluginRequestLike,
res: PluginResponseLike,
next: PluginNextFunction,
) => void;
export interface PluginRouterOptions {
/**
* Paths served without authentication, relative to the plugin's mount point
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
*
* For the handful of routes an unauthenticated third party has to reach: an
* OIDC callback, an inbound webhook. Every entry is audited when the router
* is registered and every request that uses one is logged, because "this
* plugin opened a hole in auth" is exactly the thing an operator should be
* able to find later.
*/
public?: readonly string[];
/** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */
bodyLimit?: string;
/**
* Skips the built-in body parsers entirely, for a router that parses its own
* (multer, a raw body for signature checking, a proxied stream).
*/
rawBody?: boolean;
}
/**
* HTTP routes, served at /plugin-api/<id>/ by the main backend server.
*
* No plugin gets its own port or nginx block. Core runs auth, the actor, the
* enabled check, body limits and an error wrapper in front of every route, so
* a plugin's router only has to handle its own paths.
*/
export interface PluginHttp {
/**
* Returns an Express Router mounted at /plugin-api/<id>/. Call it once in
* activate and register routes on the result; it is unmounted automatically
* on deactivate.
*/
router: <T = unknown>(options?: PluginRouterOptions) => T;
}
/** RBAC middleware for a plugin's own routes. */
export interface PluginRbac {
/**
* Middleware that rejects a request whose user lacks `permission`.
*
* A plugin may only require a permission it declares in
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
* and borrow someone else's authority.
*/
require: (permission: string) => PluginMiddleware;
}
export interface PluginWebSocketConnection {
/** The authenticated user for this socket. */
readonly userId: string;
/** The upgrade request, for query parameters and headers. */
readonly request: unknown;
/** The ws WebSocket. Binary frames and backpressure work as usual. */
readonly socket: unknown;
}
export type PluginWebSocketHandler = (
connection: PluginWebSocketConnection,
) => void | Promise<void>;
export interface PluginWebSocketOptions {
/**
* Serves the upgrade without core authentication. The handler gets an empty
* userId and must authenticate the socket itself, which is what a share-link
* guest or a token-in-query protocol needs.
*/
public?: boolean;
}
/**
* WebSocket routes, served at /plugin-ws/<id>/<path> through the main server's
* upgrade event. Every socket a plugin opened is closed on deactivate.
*/
export interface PluginWebSockets {
route: (
path: string,
handler: PluginWebSocketHandler,
options?: PluginWebSocketOptions,
) => void;
/**
* Hands the raw upgrade to the plugin, for a library that insists on owning
* its own WebSocket server (guacamole-lite attaches this way). Auth and the
* actor still run first.
*/
upgrade: (
path: string,
handler: (
request: unknown,
socket: unknown,
head: unknown,
userId: string,
) => void,
options?: PluginWebSocketOptions,
) => void;
}
/**
* Anything the runtime should undo when the plugin deactivates. Everything
* registered through ctx is tracked automatically; this is for resources a
@@ -170,6 +294,9 @@ export interface PluginContext {
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
readonly http: PluginHttp;
readonly ws: PluginWebSockets;
readonly rbac: PluginRbac;
readonly disposables: PluginDisposables;
/**
+59 -1
View File
@@ -17,6 +17,7 @@ import type {
import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
export interface FakeContextOptions {
@@ -38,6 +39,10 @@ export interface FakePluginContext {
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
/** WebSocket routes registered through ctx.ws, in order. */
wsRoutes: Array<{ path: string; raw: boolean }>;
/** Router options passed to ctx.http.router, in order. */
httpRouters: Array<PluginRouterOptions | undefined>;
}
function noopLogger(): PluginLogger {
@@ -63,6 +68,8 @@ export function createFakeContext(
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
let actor = options.actor;
@@ -153,6 +160,30 @@ export function createFakeContext(
getShared: async () => null,
},
http: {
// No express here: a test that needs a real router should mount the
// plugin's own route module against its own express app.
router: (routerOptions) => {
httpRouters.push(routerOptions);
return undefined as never;
},
},
ws: {
route: (path) => {
wsRoutes.push({ path, raw: false });
},
upgrade: (path) => {
wsRoutes.push({ path, raw: true });
},
},
rbac: {
// Passes everything through: a test asserting a permission gate should
// drive PermissionManager, not this double.
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
},
disposables,
asUser: async (userId, fn) => {
@@ -168,7 +199,16 @@ export function createFakeContext(
currentActor: () => actor,
};
return { ctx, disposals, emitted, kv, tables, syncEntities };
return {
ctx,
disposals,
emitted,
kv,
tables,
syncEntities,
wsRoutes,
httpRouters,
};
}
export interface MockContextOptions {
@@ -285,6 +325,24 @@ export function createMockCtx(
},
on: (topic, listener) => ctx.events.on(topic, listener),
},
http: {
router: (routerOptions) => {
require("network:serve");
return ctx.http.router(routerOptions);
},
},
ws: {
route: (path, handler, wsOptions) => {
require("network:serve");
ctx.ws.route(path, handler, wsOptions);
},
upgrade: (path, handler, wsOptions) => {
require("network:serve");
ctx.ws.upgrade(path, handler, wsOptions);
},
},
};
return { ...base, ctx: gatedCtx, settings, checked };