mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user