diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 07d714487..3318d70cd 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -161,7 +161,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-mtjYp/6raD7Dv7qw+nVeK90WjCAjr1mYFcUy3FI8cm4='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-zKdkhhPi/Ts2hmKgRCOB9Lfl+DkWJeRhR+a0sSotsBc='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/docker/nginx.conf b/docker/nginx.conf index bbb45f9d7..5f205e42c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -142,7 +142,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-mtjYp/6raD7Dv7qw+nVeK90WjCAjr1mYFcUy3FI8cm4='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-zKdkhhPi/Ts2hmKgRCOB9Lfl+DkWJeRhR+a0sSotsBc='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/electron/web-endpoint-window.cjs b/electron/isolated-window.cjs similarity index 71% rename from electron/web-endpoint-window.cjs rename to electron/isolated-window.cjs index ddc09813f..ff3c4d508 100644 --- a/electron/web-endpoint-window.cjs +++ b/electron/isolated-window.cjs @@ -1,6 +1,6 @@ const { randomUUID } = require("node:crypto"); -function webUrl(value) { +function isolatedUrl(value) { const url = new URL(value); if ( !["http:", "https:"].includes(url.protocol) || @@ -8,27 +8,35 @@ function webUrl(value) { url.password ) { throw new Error( - "Web endpoints require an HTTP(S) URL without embedded credentials", + "Isolated windows require an HTTP(S) URL without embedded credentials", ); } return url; } -function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) { +/** + * Opens a BrowserWindow in its own non-persistent session, isolated from the + * main Termix window's cookies and storage. Used for any URL a plugin wants + * shown without sharing Termix's own session (a tunnelled or direct host web + * UI today; core has no other caller yet). + * + * Two ways in, both trusted: the renderer's own IPC call (checked against the + * main window's webContents before reaching here) and the embedded backend's + * fork IPC request (trusted because it is this app's own forked process, not + * arbitrary web content -- see the "backend-request" handler below). + */ +function createIsolatedWindows({ BrowserWindow, session, getMainWindow }) { const certificates = new WeakMap(); - async function open(event, options = {}) { + async function open(options = {}) { const main = getMainWindow(); - if ( - !main || - event.sender !== main.webContents || - event.senderFrame !== main.webContents.mainFrame - ) { - throw new Error("Only the main Termix window can open web endpoints"); + if (!main) { + throw new Error("No window to attach an isolated window to"); } - const url = webUrl(options.url); - const isolated = session.fromPartition(`web-endpoint-${randomUUID()}`, { - cache: false, - }); + const url = isolatedUrl(options.url); + const isolated = session.fromPartition( + options.partition || `isolated-window-${randomUUID()}`, + { cache: false }, + ); isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false), ); @@ -57,7 +65,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) { win.setMenu(null); const canNavigate = (target) => { try { - return target === "about:blank" || !!webUrl(target); + return target === "about:blank" || !!isolatedUrl(target); } catch { return false; } @@ -90,7 +98,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) { const win = new BrowserWindow({ width: 1100, height: 800, - title: `Web endpoint — ${url.hostname}`, + title: options.title || url.hostname, webPreferences: preferences, }); configure(win); @@ -125,4 +133,4 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) { } return { open, handleCertificateError }; } -module.exports = { createWebEndpointWindows, webUrl }; +module.exports = { createIsolatedWindows, isolatedUrl }; diff --git a/electron/main.cjs b/electron/main.cjs index 498fb696d..9ad740e26 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -633,15 +633,45 @@ if (isInsecureModeEnabled()) { app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; -const { createWebEndpointWindows } = require("./web-endpoint-window.cjs"); -const webEndpointWindows = createWebEndpointWindows({ +const { createIsolatedWindows } = require("./isolated-window.cjs"); +const isolatedWindows = createIsolatedWindows({ BrowserWindow, session, getMainWindow: () => mainWindow, }); -ipcMain.handle("open-isolated-web-endpoint", (event, options) => - webEndpointWindows.open(event, options), -); + +// Requests from the embedded backend over the fork IPC channel (the other +// direction from the "shutdown" message main already sends it). A plugin's +// ctx.desktop.openIsolatedWindow is the one caller today. +const BACKEND_REQUEST_HANDLERS = { + "open-isolated-window": (payload) => isolatedWindows.open(payload), +}; + +async function handleBackendRequest(msg) { + if (!msg || msg.type !== "backend-request") return; + const { id, channel, payload } = msg; + const handler = BACKEND_REQUEST_HANDLERS[channel]; + const reply = (response) => { + if (backendProcess && !backendProcess.killed) { + try { + backendProcess.send({ type: "backend-response", id, ...response }); + } catch (error) { + logToFile("Failed to reply to backend request:", error.message); + } + } + }; + if (!handler) { + reply({ ok: false, error: `Unknown backend request channel: ${channel}` }); + return; + } + try { + const result = await handler(payload); + reply({ ok: true, result }); + } catch (error) { + reply({ ok: false, error: error.message || String(error) }); + } +} + let backendProcess = null; let backendStartFailed = false; // Why the embedded backend died, once it has. Null while it is healthy @@ -826,12 +856,7 @@ app.on( "certificate-error", (event, _webContents, url, error, certificate, callback) => { if ( - webEndpointWindows.handleCertificateError( - event, - _webContents, - url, - callback, - ) + isolatedWindows.handleCertificateError(event, _webContents, url, callback) ) return; if (isWebEndpointCertificateAllowed(url)) { @@ -1155,6 +1180,10 @@ function startBackendServer() { logToFile("[backend:stderr]", chunk.trim()); }); + backendProcess.on("message", (msg) => { + void handleBackendRequest(msg); + }); + backendProcess.on("exit", (code, signal) => { logToFile(`Backend process exited with code ${code}, signal ${signal}`); if (!resolved && code !== 0) { diff --git a/electron/preload.js b/electron/preload.js index 26893db95..14c766654 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -16,7 +16,6 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ "save-remote-sync-jwt", "test-server-connection", "allow-invalid-certificate-for-origin", - "open-isolated-web-endpoint", ]); function invokeAllowed(channel, ...args) { diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 92b5fa92d..0f2adce82 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -511,6 +511,20 @@ to poll the plugin's state. A plugin can also listen for another plugin's `plugin..*` events without any capability: automations turns `plugin.tunnels.tunnel_disconnected` into its `tunnel_disconnected` trigger. +**B8** added a second, narrower use of `ctx.registry`: a core route that +touches every plugin's host-scope settings generically (without importing any +one of them) consumes `ctx.registry.provide(".hostImportNormalizer", +fn)`. `host-bulk-routes.ts`'s Termix-JSON import path is the first caller, +through `applyPluginHostImportSettings` in `host-plugin-settings.ts`: for +every enabled plugin that declares `contributes.settings.host`, it looks up +`".hostImportNormalizer"` and, if the plugin registered one, calls it with +the raw imported row and writes back whatever it returns (or nothing, for +`null`). This is not part of the SDK's typed `ctx` surface - it is a plain +`ctx.registry` convention, the same mechanism `terminal.sessions` and +`remote-desktop.sessions` already use, just with a name core's own code knows +to look for. web-endpoint (`src/backend/host-import.ts`) is the first plugin +to register one. + ### 10. Lifecycle `activate(ctx)` creates all state. Everything registered through `ctx` or `app` @@ -555,10 +569,14 @@ against the real schema's property names works unchanged in a test. Pass its `createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and `persisted` counts `persist()` calls. The mock also takes `router` (for example `() => express.Router()`, so routes are served for real), `permissions` -(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything), -and returns `setActor(userId)` for a test middleware and `services` for what the -plugin provided. `plugins/workspaces/tests/backend/helpers.ts` is the worked -example. +(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything) +and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess` +serve - **B8** added this option, since it existed on `createFakeContext` +already but was not forwarded), and returns `setActor(userId)` for a test +middleware and `services` for what the plugin provided. **B8** also gave +`ctx.registry` on both doubles a real in-memory backing (it was a no-op stub +before), so `provide`/`consume`/`revoke` round-trip the way the real registry +does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example. `renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`), `layout` (what `app.tabs.getLayout()` returns) and `ready` (fire @@ -1063,6 +1081,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `ctx.notify.*` | `notify:send` | A6 | | `ctx.auth.*` | `auth:provide` | **A8** | +| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `ctx.fetch` | `network:outbound` | B | `ctx.ssh` was pulled forward from B so plugin transports go through core's @@ -1150,6 +1169,20 @@ existing ones (proxmox's discovery and sync flow is the first caller): token): a plugin that creates a host picks an `authType` and, for `"credential"`, a `credentialId` it does not need to see the contents of. +**B8** added `ctx.desktop.openIsolatedWindow({ url, partition?, title?, +ignoreCert? })`, for a plugin that wants a URL shown outside the main +renderer's own session (a tunnelled or direct host web UI, so far). The +backend cannot open a `BrowserWindow` itself: it runs as a separate forked +process (`electron/main.cjs` forks it with `stdio: [..., "ipc"]`), so the call +is relayed over that same fork IPC channel to a small request/response +protocol in `src/backend/utils/electron-ipc-bridge.ts`, and handled in +`electron/main.cjs` by `createIsolatedWindows` (`electron/isolated-window.cjs`, +the module both the renderer's own isolated-window IPC call and this bridge +share). Rejects outside the desktop app (`ELECTRON_EMBEDDED` unset, or no +`process.send`). Each window gets its own non-persistent session, so it never +shares cookies or storage with the main window or with another isolated +window. + ### The actor Never comes from plugin code. It is held in `AsyncLocalStorage` and set two @@ -1633,8 +1666,8 @@ 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 code | **Error** | 0 | - | -| A plugin frontend importing core through `@/` | Warning | 138 files | D1 | -| A plugin importing core by relative path | Warning | 68 files | D1 | +| A plugin frontend importing core through `@/` | Warning | 135 files | D1 | +| A plugin importing core by relative path | Warning | 67 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 diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 3a90ac619..655dd5ed5 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -110,3 +110,37 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite remote-origin plugin API client. `subscribeTunnelStatuses` already takes a `fetchRemote` and merges with local statuses winning, so it only needs a client for `/plugin-api/tunnels/status` on the remote server. Owner: D1. +- **B8 (web-endpoint):** `enable_web_ui` and `web_ui_config` are still live + `ssh_data` columns; only the copy-into-plugin-settings migration shipped + (`web-endpoint-settings-migration.ts`). Unlike B6/B7, this step DID convert + every core read/write of those two fields (`host.ts`'s create/update and + export routes, `host-normalizers.ts`, `host-bulk-routes.ts`, + `database.ts`'s encrypt/decrypt round trip is the one exception - it copies + whatever is already in the column for the raw DB export/backup tool, which + needs no change since nothing reads that copy through `pluginSettings`). + Dropping the columns in lockstep (`schema.ts`, `db/index.ts`, a drizzle + migration per dialect, `schema:generate`) is the only remaining piece. + Owner: a dedicated follow-up step, or D0. +- **B8 (web-endpoint):** added the first generic cross-plugin hook for core + routes that touch host-scope plugin settings without importing a plugin: + `ctx.registry.provide(".hostImportNormalizer", fn)` plus + `applyPluginHostImportSettings` in `host-plugin-settings.ts`, called from + `host-bulk-routes.ts`'s Termix-JSON import path. Only import validation is + covered; B7's TODO above about the Hosts panel feature filter and the bulk + enable/disable menu (`hostCapability`-driven, not import-driven) is + unrelated and still open. Owner: D1 for extending the same pattern there. +- **B8 (web-endpoint):** noticed while sanity-checking with `npm run lint` + (not required by this step): `eslint.config.mjs`'s + `globalIgnores(["dist", ...])` only matches a top-level `dist/` folder, not + nested `packages/*/dist` or `plugins/*/dist`, so `npm run lint` fails on + bundled output repo-wide with "Definition for rule X was not found" + errors. Pre-existing, not introduced by this or any single plugin step. + Owner: whoever next needs a clean `npm run lint`, or D0. +- **B8 (web-endpoint):** the isolated-window Electron IPC bridge + (`src/backend/utils/electron-ipc-bridge.ts`, `ctx.desktop.openIsolatedWindow`) + is a single request/response channel keyed by a random id with one + registered backend-request handler (`open-isolated-window`) in + `electron/main.cjs`. Fine for today's one caller; if a second plugin needs + to ask Electron's main process for something, extend + `BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel. + No owner needed unless a second caller appears. diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 5fff6b7c2..c1bf7526e 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -964,6 +964,28 @@ export interface PluginAuth { removeEnrollment: (userId: string, factorId: string) => Promise; } +export interface PluginOpenIsolatedWindowRequest { + /** http(s) only. Refused when it points anywhere else. */ + url: string; + /** Isolated Electron session partition; a fresh one when omitted. */ + partition?: string; + title?: string; + /** Present an invalid TLS certificate on this window's own origin only. */ + ignoreCert?: boolean; +} + +/** + * Opens a desktop window outside the main renderer, for a target a plugin + * does not want sharing Termix's own session (a tunnelled or direct web UI). + * Electron only: rejects when the server is not running embedded in the + * desktop app. Needs desktop:window. + */ +export interface PluginDesktop { + openIsolatedWindow: ( + request: PluginOpenIsolatedWindowRequest, + ) => Promise<{ success: true }>; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -986,6 +1008,8 @@ export interface PluginContext { readonly ssh: PluginSsh; /** Login methods, second factors and SSH auth types. Needs auth:provide. */ readonly auth: PluginAuth; + /** Opens Electron windows outside the main renderer. Needs desktop:window. */ + readonly desktop: PluginDesktop; /** * Runs `fn` with `userId` as the acting user, for background work that has diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 84dc077e4..53d5c3061 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -109,6 +109,13 @@ export interface FakePluginContext { }>; /** Everything registered through ctx.auth. */ auth: FakeAuthRegistrations; + /** Every ctx.desktop.openIsolatedWindow call, in order. */ + desktopWindows: Array<{ + url: string; + partition?: string; + title?: string; + ignoreCert?: boolean; + }>; /** Changes the acting user, as core's request middleware would. */ setActor: (userId: string | undefined) => void; /** Implementations provided through ctx.services.provide, by service name. */ @@ -179,12 +186,14 @@ export function createFakeContext( const settingsListeners = new Map void>>(); const sshConnections: FakePluginContext["sshConnections"] = []; const hostShares: FakePluginContext["hostShares"] = []; + const desktopWindows: FakePluginContext["desktopWindows"] = []; const hostsById = new Map( (options.hosts ?? []).map((h) => [h.id, h]), ); const hostRecordsById = new Map(); let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1; const services = new Map(); + const registryProviders = new Map(); const auth: FakeAuthRegistrations = { sshAuthProviders: [], loginMethods: [], @@ -308,9 +317,17 @@ export function createFakeContext( }, registry: { - provide: () => {}, - consume: () => undefined, - revoke: () => false, + provide: (key, value) => { + registryProviders.set(key, value); + }, + consume: (key) => registryProviders.get(key) as never, + revoke: (key, value) => { + if (!registryProviders.has(key)) return false; + if (value !== undefined && registryProviders.get(key) !== value) { + return false; + } + return registryProviders.delete(key); + }, }, services: { @@ -533,6 +550,13 @@ export function createFakeContext( }, }, + desktop: { + openIsolatedWindow: async (request) => { + desktopWindows.push(request); + return { success: true }; + }, + }, + asUser: async (userId, fn) => { const previous = actor; actor = userId; @@ -561,6 +585,7 @@ export function createFakeContext( sshConnections, hostShares, auth, + desktopWindows, setActor: (userId) => { actor = userId; }, @@ -589,6 +614,8 @@ export interface MockContextOptions { router?: () => unknown; /** Role permissions the acting user holds. See FakeContextOptions. */ permissions?: string[]; + /** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */ + hosts?: PluginHostSummary[]; } export interface MockPluginContext extends FakePluginContext { @@ -622,6 +649,7 @@ export function createMockCtx( db: options.db, router: options.router, permissions: options.permissions, + hosts: options.hosts, manifest: { capabilities: options.capabilities ?? [], ...options.manifest, @@ -819,6 +847,13 @@ export function createMockCtx( return ctx.auth.removeEnrollment(userId, factorId); }, }, + + desktop: { + openIsolatedWindow: async (request) => { + require("desktop:window"); + return ctx.desktop.openIsolatedWindow(request); + }, + }, }; return { ...base, ctx: gatedCtx, checked }; diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json index 62828333f..96faa08d3 100644 --- a/plugins/web-endpoint/manifest.json +++ b/plugins/web-endpoint/manifest.json @@ -14,7 +14,12 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": ["hosts:read", "network:serve", "ui:surface"], + "capabilities": [ + "hosts:read", + "network:serve", + "ui:surface", + "desktop:window" + ], "dependencies": { "tunnels": "^1.0.0" }, @@ -33,6 +38,20 @@ "openFrom": ["host-context-menu"] } ], + "settings": { + "host": { + "enableKey": "enableWebUi", + "enableLabelKey": "hosts.enableWebUi", + "fields": [ + { + "key": "webUiConfig", + "type": "json", + "labelKey": "hosts.webUiEndpointsSection", + "requires": "enableWebUi" + } + ] + } + }, "hostCapability": { "key": "enableWebUi", "labelKey": "hosts.tabWebUi", diff --git a/plugins/web-endpoint/src/backend/host-import.ts b/plugins/web-endpoint/src/backend/host-import.ts new file mode 100644 index 000000000..26202e4ae --- /dev/null +++ b/plugins/web-endpoint/src/backend/host-import.ts @@ -0,0 +1,19 @@ +import { serializeWebUiConfig } from "../shared/web-endpoint-config.js"; + +/** + * Registered as ctx.registry.provide("web-endpoint.hostImportNormalizer", ...) + * so host-bulk-routes.ts's Termix-JSON import path can validate this + * plugin's fields without importing anything from the plugin. Matches the + * shape core's PluginHostImportNormalizer type expects. + */ +export function hostImportNormalizer( + raw: Record, +): Record | null { + if (raw.enableWebUi === undefined && raw.webUiConfig === undefined) { + return null; + } + return { + enableWebUi: !!raw.enableWebUi, + webUiConfig: raw.webUiConfig ? serializeWebUiConfig(raw.webUiConfig) : null, + }; +} diff --git a/plugins/web-endpoint/src/backend/index.ts b/plugins/web-endpoint/src/backend/index.ts index fb3c9834f..165590280 100644 --- a/plugins/web-endpoint/src/backend/index.ts +++ b/plugins/web-endpoint/src/backend/index.ts @@ -1,12 +1,25 @@ +import type { Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; -import { startWebEndpointService, stopWebEndpointService } from "./routes.js"; +import { createWebEndpointRoutes } from "./routes.js"; +import { hostImportNormalizer } from "./host-import.js"; export async function activate(ctx: PluginContext) { - startWebEndpointService(ctx.http.router(), ctx); - ctx.disposables.add(() => stopWebEndpointService()); + const router = ctx.http.router(); + router.use(createWebEndpointRoutes(ctx)); + + ctx.registry.provide( + "web-endpoint.hostImportNormalizer", + hostImportNormalizer, + ); + ctx.disposables.add( + () => + void ctx.registry.revoke( + "web-endpoint.hostImportNormalizer", + hostImportNormalizer, + ), + ); + ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint"); } -export async function deactivate() { - stopWebEndpointService(); -} +export async function deactivate() {} diff --git a/plugins/web-endpoint/src/backend/routes.ts b/plugins/web-endpoint/src/backend/routes.ts index 8bf781347..59e7750f5 100644 --- a/plugins/web-endpoint/src/backend/routes.ts +++ b/plugins/web-endpoint/src/backend/routes.ts @@ -1,6 +1,9 @@ import express, { type Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; -import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js"; +import { + parseWebUiConfig, + type WebEndpoint, +} from "../shared/web-endpoint-config.js"; /** Matches the spec's ten minutes. */ const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; @@ -31,83 +34,76 @@ export function webEndpointTunnelName( return `web:${hostId}:${endpointId}`; } -let current: PluginContext | null = null; - -export async function handleWebEndpointOpen( - req: express.Request, - res: express.Response, -): Promise { - const ctx = current; - const userId = ctx?.currentActor(); - if (!ctx || !userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - const { hostId, endpointId } = req.body ?? {}; - if ( - !Number.isInteger(hostId) || - hostId < 1 || - typeof endpointId !== "string" - ) { - return res.status(400).json({ error: "Invalid web endpoint request" }); - } - - // Deliberately NOT gated to the desktop. The forward binds wherever this - // backend runs, and the endpoint's own bindHost decides whether that is - // reachable from the browser. - - const { resolveHostById } = - await import("../../../../src/backend/hosts/host-resolver.js"); - const host = await resolveHostById(hostId, userId); +async function loadEndpoint( + ctx: PluginContext, + hostId: number, + endpointId: string, +): Promise< + { error: { status: number; message: string } } | { endpoint: WebEndpoint } +> { + const host = await ctx.hosts.get(hostId); if (!host) { - return res.status(403).json({ error: "Host not found or access denied" }); + return { + error: { status: 403, message: "Host not found or access denied" }, + }; } - // A bulk update that sends only { webUiConfig } can leave the endpoint in - // the config while the feature is off. The UI reads as off in that state, - // so the endpoint being listed is not on its own a licence to open a tunnel. - if (!host.enableWebUi) { - return res - .status(400) - .json({ error: "Web endpoints are not enabled for this host" }); + // A bulk update that only flips enableWebUi off can leave stale endpoints + // in webUiConfig. The UI reads as off in that state, so a listed endpoint + // is not on its own a licence to open a tunnel. + const enabled = await ctx.settings.getHost(hostId, "enableWebUi"); + if (!enabled) { + return { + error: { + status: 400, + message: "Web endpoints are not enabled for this host", + }, + }; } // Re-normalized rather than trusted: the stored value predates any later - // tightening of the rules, and this is the value a forward is built from. - const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find( + // tightening of the rules, and this is the value a forward or a window is + // built from. + const rawConfig = await ctx.settings.getHost(hostId, "webUiConfig"); + const endpoint = parseWebUiConfig(rawConfig).endpoints.find( (candidate) => candidate.id === endpointId, ); if (!endpoint) { - return res.status(400).json({ error: "Web endpoint not found" }); - } - if (endpoint.access !== "tunnel") { - return res - .status(400) - .json({ error: "This endpoint does not use a tunnel" }); + return { error: { status: 400, message: "Web endpoint not found" } }; } + return { endpoint }; +} + +async function openTunnel( + ctx: PluginContext, + hostId: number, + endpointId: string, + endpoint: WebEndpoint, +): Promise<{ bindHost: string; bindPort: number }> { + const userId = ctx.currentActor(); let tunnels: TunnelsAccess; try { tunnels = ctx.services.get("tunnels.access", { userId }); } catch { - return res - .status(503) - .json({ error: "The tunnels plugin is not available" }); + throw Object.assign(new Error("The tunnels plugin is not available"), { + status: 503, + }); } try { - const handle = await tunnels.forward( + return await tunnels.forward( hostId, { targetHost: "127.0.0.1", targetPort: endpoint.port, // Loopback unless the endpoint asks otherwise. A non-loopback bind - // publishes the target's web UI to anyone who can reach the port, with - // no authentication in front of it, and is only ever an explicit - // per-endpoint choice. + // publishes the target's web UI to anyone who can reach the port, + // with no authentication in front of it, and is only ever an + // explicit per-endpoint choice. bindHost: endpoint.bindHost || "127.0.0.1", - // A fixed port when the endpoint names one, since a container can only - // publish ports it knows in advance. Otherwise the kernel picks. + // A fixed port when the endpoint names one, since a container can + // only publish ports it knows in advance. Otherwise the kernel picks. bindPort: endpoint.localPort ?? undefined, }, { @@ -115,33 +111,141 @@ export async function handleWebEndpointOpen( idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS, }, ); - return res.status(200).json({ port: handle.bindPort }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.log.error( `Failed to open web endpoint tunnel for host ${hostId}: ${reason}`, error instanceof Error ? error : undefined, ); - return res.status(502).json({ error: reason }); + throw Object.assign(new Error(reason), { status: 502 }); } } -export const router = express.Router(); +/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */ +function bracketIfIpv6(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} -let started = false; - -export function startWebEndpointService( - mountOn: Router, - ctx: PluginContext, -): void { - current = ctx; - if (!started) { - router.post("/open", handleWebEndpointOpen); - started = true; +function endpointUrl( + hostAddress: string, + endpoint: WebEndpoint, + localPort?: number, +): string { + const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/"; + if (endpoint.access === "tunnel") { + // Electron's isolated window always dials the backend's own loopback, + // exactly like the renderer's own currentTunnelHost(true) does. + return `${endpoint.scheme}://127.0.0.1:${localPort}${path}`; } - mountOn.use(router); + return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`; } -export function stopWebEndpointService(): void { - current = null; +export function createWebEndpointRoutes(ctx: PluginContext): Router { + const router = express.Router(); + + router.post("/open", async (req, res) => { + const userId = ctx.currentActor(); + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { hostId, endpointId } = req.body ?? {}; + if ( + !Number.isInteger(hostId) || + hostId < 1 || + typeof endpointId !== "string" + ) { + return res.status(400).json({ error: "Invalid web endpoint request" }); + } + + // Deliberately NOT gated to the desktop. The forward binds wherever this + // backend runs, and the endpoint's own bindHost decides whether that is + // reachable from the browser. + const resolved = await loadEndpoint(ctx, hostId, endpointId); + if ("error" in resolved) { + return res + .status(resolved.error.status) + .json({ error: resolved.error.message }); + } + if (resolved.endpoint.access !== "tunnel") { + return res + .status(400) + .json({ error: "This endpoint does not use a tunnel" }); + } + + try { + const handle = await openTunnel( + ctx, + hostId, + endpointId, + resolved.endpoint, + ); + return res.status(200).json({ port: handle.bindPort }); + } catch (error) { + const status = (error as { status?: number }).status ?? 500; + const message = error instanceof Error ? error.message : String(error); + return res.status(status).json({ error: message }); + } + }); + + /** + * Opens the endpoint in an isolated Electron window instead of a tab. + * Every part of the target URL is resolved server-side (the host's own + * declared address for a direct endpoint, or the tunnel port this route + * just opened for a tunnel one) so ctx.desktop.openIsolatedWindow's + * capability check and audit line cover the whole decision, not just the + * final "open a window" step. + */ + router.post("/open-window", async (req, res) => { + const userId = ctx.currentActor(); + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { hostId, endpointId, ignoreCert } = req.body ?? {}; + if ( + !Number.isInteger(hostId) || + hostId < 1 || + typeof endpointId !== "string" + ) { + return res.status(400).json({ error: "Invalid web endpoint request" }); + } + + const resolved = await loadEndpoint(ctx, hostId, endpointId); + if ("error" in resolved) { + return res + .status(resolved.error.status) + .json({ error: resolved.error.message }); + } + const { endpoint } = resolved; + + try { + let localPort: number | undefined; + if (endpoint.access === "tunnel") { + const handle = await openTunnel(ctx, hostId, endpointId, endpoint); + localPort = handle.bindPort; + } + + const host = await ctx.hosts.get(hostId); + if (!host) { + return res + .status(403) + .json({ error: "Host not found or access denied" }); + } + + const url = endpointUrl(host.ip, endpoint, localPort); + const result = await ctx.desktop.openIsolatedWindow({ + url, + title: endpoint.label, + ignoreCert: endpoint.access === "direct" && ignoreCert === true, + }); + return res.status(200).json(result); + } catch (error) { + const status = (error as { status?: number }).status ?? 502; + const message = error instanceof Error ? error.message : String(error); + return res.status(status).json({ error: message }); + } + }); + + return router; } diff --git a/plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx b/plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx index c713a0a5f..932efad12 100644 --- a/plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx +++ b/plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx @@ -1,27 +1,33 @@ +import { useEffect, useState } from "react"; import { Globe } from "lucide-react"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; -import { isElectron } from "@/lib/electron"; import { - webEndpointRefusalReason, - type WebEndpointRefusalReason, -} from "./web-endpoint-url"; -import { Button } from "@/components/button"; -import { Input } from "@/components/input"; -import { Checkbox } from "@/components/checkbox"; + useTranslation, + type HostEditorSectionProps, +} from "@termix/plugin-sdk/frontend"; import { + Button, + Checkbox, + FakeSwitch, + Input, + SectionCard, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/select"; -import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; + SettingRow, +} from "@termix/plugin-sdk/ui"; +import { resolveConnectionOrigin } from "@/lib/connection-origin"; +import { isElectron } from "@/lib/electron"; import { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH, type WebEndpoint, - type WebUiConfig, -} from "@/types/index"; +} from "../shared/web-endpoint-config"; +import { + webEndpointRefusalReason, + type WebEndpointRefusalReason, +} from "./web-endpoint-url"; import { MAX_WEB_ENDPOINT_PORT, MIN_WEB_ENDPOINT_PORT, @@ -30,11 +36,12 @@ import { webEndpointRowError, } from "./web-endpoint-validation"; +type PluginSettingsForm = Record>; + /** * crypto.randomUUID is undefined outside a secure context, so a plain-http web * deployment -- a first-class target for the direct+external path -- would - * throw on "Add endpoint". Matches the guarded form already used in - * MacrosPanel, PanePreview, KeybindingsDialog and AppShell. + * throw on "Add endpoint". */ function endpointId(): string { return typeof crypto.randomUUID === "function" @@ -79,22 +86,74 @@ function newEndpoint(label: string): WebEndpoint { }; } +/** + * The host editor's Web UI tab. Writes into the form's pluginSettings for + * this plugin, which the editor saves through the plugin's host settings + * route after the host itself, exactly as the tunnels plugin's own section + * does. + * + * Owns the async connection-origin resolution for the tunnel gate itself + * (tunnelAvailable = enableSsh && originIsLocal), deliberately NOT gated on + * isElectron(): the forward binds wherever the backend runs, exactly as the + * tunnels plugin does, and a web deployment reaches it at the host serving + * Termix provided the endpoint opted out of a loopback bind. + * + * connectionType is passed as "ssh" deliberately: a tunnel endpoint always + * rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its + * RDP/VNC/Telnet guacamole special case, which always resolves to "remote". + * + * Note the deliberate asymmetry with the sidebar: the sidebar entry appears on + * enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is + * an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at + * all. That is the accepted behaviour, not an oversight. + */ export function HostEditorWebUiSection({ - enableWebUi, - webUiConfig, - tunnelAvailable, - setField, -}: { - enableWebUi: boolean; - webUiConfig: WebUiConfig; - tunnelAvailable: boolean; - setField: (field: string, value: unknown) => void; -}) { + form, + updateForm, + protocols, +}: HostEditorSectionProps) { const { t } = useTranslation(); - const endpoints = webUiConfig?.endpoints ?? []; + const [originIsLocal, setOriginIsLocal] = useState(false); + + const connectionOrigin = (form?.connectionOrigin as string | null) ?? null; + useEffect(() => { + let cancelled = false; + void resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: connectionOrigin as "local" | "remote" | null, + }).then((origin) => { + if (!cancelled) setOriginIsLocal(origin === "local"); + }); + return () => { + cancelled = true; + }; + }, [connectionOrigin]); + + const tunnelAvailable = protocols.enableSsh && originIsLocal; + + const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[ + "web-endpoint" + ] ?? {}) as Record; + const enableWebUi = settings.enableWebUi === true; + const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as { + endpoints: WebEndpoint[]; + }; + const endpoints = webUiConfig.endpoints ?? []; + + const setSettings = (patch: Record) => + updateForm((current) => { + const all = (current.pluginSettings ?? {}) as PluginSettingsForm; + return { + ...current, + pluginSettings: { + ...all, + "web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch }, + }, + }; + }); const commit = (next: WebEndpoint[]) => - setField("webUiConfig", { endpoints: next }); + setSettings({ webUiConfig: { endpoints: next } }); const update = (index: number, patch: Partial) => commit( @@ -135,7 +194,7 @@ export function HostEditorWebUiSection({ > setField("enableWebUi", v)} + onChange={(v: boolean) => setSettings({ enableWebUi: v })} />
diff --git a/plugins/web-endpoint/src/frontend/HostWebUiTab.tsx b/plugins/web-endpoint/src/frontend/HostWebUiTab.tsx deleted file mode 100644 index 38d13bcd2..000000000 --- a/plugins/web-endpoint/src/frontend/HostWebUiTab.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useState } from "react"; -import { resolveConnectionOrigin } from "@/lib/connection-origin"; -import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData"; -import { HostEditorWebUiSection } from "./HostEditorWebUiSection"; - -type SetHostField = ( - key: K, - value: HostEditorForm[K], -) => void; - -/** - * Owns the async connection-origin resolution for the Web UI tab's tunnel - * gate, then hands the resolved boolean to the presentational section. - * - * tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on - * isElectron(): the forward binds wherever the backend runs, exactly as the - * server tunnels feature does, and a web deployment reaches it at the host - * serving Termix provided the endpoint opted out of a loopback bind. Gating - * this on the desktop would be stricter than the tunnels feature it mirrors. - * - * connectionType is passed as "ssh" deliberately: a tunnel endpoint always - * rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its - * RDP/VNC/Telnet guacamole special case, which always resolves to "remote". - * - * The origin state lives here rather than in HostEditor.tsx, which is already - * very long; this follows HostEditorGeneralTab's precedent of taking - * `protocols` as a prop and gating its own connection-origin control. - * - * Note the deliberate asymmetry with the sidebar: the sidebar entry appears on - * enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is - * an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at - * all. That is the accepted behaviour, not an oversight. - */ -export function HostWebUiTab({ - form, - setField, - protocols, -}: { - form: HostEditorForm; - setField: SetHostField; - protocols: HostProtocols; -}) { - const [originIsLocal, setOriginIsLocal] = useState(false); - - useEffect(() => { - let cancelled = false; - void resolveConnectionOrigin({ - connectionType: "ssh", - connectionOrigin: form.connectionOrigin, - }).then((origin) => { - if (!cancelled) setOriginIsLocal(origin === "local"); - }); - return () => { - cancelled = true; - }; - }, [form.connectionOrigin]); - - return ( - void} - /> - ); -} diff --git a/plugins/web-endpoint/src/frontend/WebEndpointTab.tsx b/plugins/web-endpoint/src/frontend/WebEndpointTab.tsx index 18da0417e..167d03d4d 100644 --- a/plugins/web-endpoint/src/frontend/WebEndpointTab.tsx +++ b/plugins/web-endpoint/src/frontend/WebEndpointTab.tsx @@ -18,6 +18,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import { isElectron } from "@/lib/electron"; import { Button } from "@/components/button"; import type { Host } from "@/types/ui-types"; +import type { WebEndpoint } from "../shared/web-endpoint-config"; const REFUSAL_MESSAGES: Record = { "loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser", @@ -42,7 +43,10 @@ export function WebEndpointTab({ endpointId?: string; }) { const { t } = useTranslation(); - const endpoint = (host.webUiConfig?.endpoints ?? []).find( + const webUiConfig = ( + host.pluginSettings as Record> | undefined + )?.["web-endpoint"]?.webUiConfig as { endpoints?: WebEndpoint[] } | undefined; + const endpoint = (webUiConfig?.endpoints ?? []).find( (candidate) => candidate.id === endpointId, ); diff --git a/plugins/web-endpoint/src/frontend/index.tsx b/plugins/web-endpoint/src/frontend/index.tsx index a10ee103e..15b6c3305 100644 --- a/plugins/web-endpoint/src/frontend/index.tsx +++ b/plugins/web-endpoint/src/frontend/index.tsx @@ -9,9 +9,9 @@ import type { TermixApp, } from "@termix/plugin-sdk/frontend"; import type { Host } from "@/types/ui-types"; -import type { WebEndpoint } from "@/types"; +import type { WebEndpoint } from "../shared/web-endpoint-config"; import { WebEndpointTab } from "./WebEndpointTab"; -import { HostWebUiTab } from "./HostWebUiTab"; +import { HostEditorWebUiSection } from "./HostEditorWebUiSection"; import { openWebEndpointExternally, setWebEndpointApi, @@ -19,10 +19,26 @@ import { const TAB_TYPE = "web-endpoint"; +function webEndpointSettings(host: PluginHostRecord): { + enableWebUi: boolean; + webUiConfig: { endpoints: WebEndpoint[] }; +} { + const settings = ( + host.pluginSettings as Record> | undefined + )?.["web-endpoint"]; + return { + enableWebUi: settings?.enableWebUi === true, + webUiConfig: (settings?.webUiConfig as + { endpoints: WebEndpoint[] } | undefined) ?? { + endpoints: [], + }, + }; +} + function endpointsOf(host: PluginHostRecord): WebEndpoint[] { - if (!host.enableWebUi) return []; - const config = host.webUiConfig as { endpoints?: WebEndpoint[] } | undefined; - return config?.endpoints ?? []; + const { enableWebUi, webUiConfig } = webEndpointSettings(host); + if (!enableWebUi) return []; + return webUiConfig.endpoints ?? []; } function openEndpoint( @@ -31,8 +47,8 @@ function openEndpoint( shell: ShellApi, ): void { if (endpoint.render === "external") { - // No tab at all: the real browser opens it. On the desktop the main - // process routes it to shell.openExternal. + // No tab at all: the real browser opens it. On the desktop the plugin + // asks the backend to open it in an isolated window. openWebEndpointExternally( host as unknown as { id: string; ip: string }, endpoint, @@ -63,16 +79,8 @@ function EndpointTab({ host, tab }: TabProps) { ); } -function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) { - return ( - [0]["setField"]} - protocols={ - protocols as unknown as Parameters[0]["protocols"] - } - /> - ); +function WebUiSection(props: HostEditorSectionProps) { + return ; } export function activate(app: TermixApp): void { diff --git a/plugins/web-endpoint/src/frontend/web-endpoint-api.ts b/plugins/web-endpoint/src/frontend/web-endpoint-api.ts index 80d7fbbd6..cba286606 100644 --- a/plugins/web-endpoint/src/frontend/web-endpoint-api.ts +++ b/plugins/web-endpoint/src/frontend/web-endpoint-api.ts @@ -1,9 +1,8 @@ -import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url"; import axios from "axios"; import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; import { handleApiError } from "@/main-axios"; import { isElectron } from "@/lib/electron"; -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../shared/web-endpoint-config"; let pluginApi: PluginApiClient | null = null; @@ -109,35 +108,43 @@ export function requireNumericHostId(id: string): number { return numericId; } -/** Desktop windows use a dedicated ephemeral session, including login popups. */ +/** + * Desktop windows use a dedicated ephemeral session, including login popups. + * + * The backend resolves and validates the target URL (the host's own declared + * address for a direct endpoint, or the tunnel port it opens for a tunnel + * one) and opens the window itself through ctx.desktop.openIsolatedWindow, so + * the capability check and audit line cover the whole decision -- this call + * only asks for it and reports whether it worked. + */ export async function openWebEndpointExternally( host: { id: string; ip: string }, endpoint: WebEndpoint, ): Promise { - if (!isElectron() || !window.electronAPI?.invoke) { + if (!isElectron()) { throw new Error( "Isolated windows require the desktop app. Choose Embedded in the endpoint settings.", ); } - const localPort = - endpoint.access === "tunnel" - ? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id) - : undefined; - const url = resolveWebEndpointUrl({ - hostAddress: host.ip, - endpoint, - localPort, - tunnelHost: currentTunnelHost(true) ?? undefined, - }); - const result = await window.electronAPI.invoke("open-isolated-web-endpoint", { - url, - ignoreCert: endpoint.ignoreCert === true, - }); - if ( - !result || - typeof result !== "object" || - !("success" in result) || - result.success !== true - ) - throw new Error("Failed to open isolated web endpoint"); + if (!pluginApi) throw new Error("The web endpoint plugin is not active"); + try { + await pluginApi.post("/open-window", { + hostId: requireNumericHostId(host.id), + endpointId: endpoint.id, + ignoreCert: endpoint.ignoreCert === true, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + const data = error.response?.data as + { error?: unknown; message?: unknown } | undefined; + const backendMessage = data?.error ?? data?.message; + if (typeof backendMessage === "string" && backendMessage) { + throw new WebEndpointTunnelError( + backendMessage, + error.response?.status, + ); + } + } + return handleApiError(error, "open isolated web endpoint"); + } } diff --git a/plugins/web-endpoint/src/frontend/web-endpoint-url.ts b/plugins/web-endpoint/src/frontend/web-endpoint-url.ts index 9d00e1392..cc2bfebbd 100644 --- a/plugins/web-endpoint/src/frontend/web-endpoint-url.ts +++ b/plugins/web-endpoint/src/frontend/web-endpoint-url.ts @@ -1,4 +1,4 @@ -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../shared/web-endpoint-config"; /** * Builds the URL a web endpoint opens at. diff --git a/plugins/web-endpoint/src/frontend/web-endpoint-validation.ts b/plugins/web-endpoint/src/frontend/web-endpoint-validation.ts index 4919e23a8..f5df39d24 100644 --- a/plugins/web-endpoint/src/frontend/web-endpoint-validation.ts +++ b/plugins/web-endpoint/src/frontend/web-endpoint-validation.ts @@ -1,20 +1,18 @@ -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../shared/web-endpoint-config"; /** * Editor-side validation for web endpoint rows. * * The authority on what a stored endpoint may look like is - * `normalizeWebEndpoints` in - * src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it - * refuses rather than reporting it -- so without a check here the user adds a - * row, saves, sees no error, and finds the endpoint gone on reload. + * `normalizeWebEndpoints` in ../shared/web-endpoint-config.ts, and it DROPS + * any row it refuses rather than reporting it -- so without a check here the + * user adds a row, saves, sees no error, and finds the endpoint gone on + * reload. * - * This deliberately does NOT restate that module's full rule set, and does not - * import it: the renderer does not import backend route modules (the same - * reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the - * validator). It covers only the conditions a user can reach by typing. - * src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations - * over the same samples so the two cannot drift apart silently. + * This deliberately does NOT restate that module's full rule set: it covers + * only the conditions a user can reach by typing, with per-field errors a + * drop/keep normalizer cannot give. tests/backend/web-endpoint-config.test.ts + * covers the normalizer; this file's own tests cover the same inputs here. */ export const MIN_WEB_ENDPOINT_PORT = 1; diff --git a/src/backend/database/routes/host-web-endpoints.ts b/plugins/web-endpoint/src/shared/web-endpoint-config.ts similarity index 69% rename from src/backend/database/routes/host-web-endpoints.ts rename to plugins/web-endpoint/src/shared/web-endpoint-config.ts index 16e6c18a3..90cc1c272 100644 --- a/src/backend/database/routes/host-web-endpoints.ts +++ b/plugins/web-endpoint/src/shared/web-endpoint-config.ts @@ -1,10 +1,63 @@ -import { - MAX_WEB_ENDPOINTS, - MAX_WEB_ENDPOINT_LABEL_LENGTH, -} from "../../../types/index.js"; -import type { WebEndpoint, WebUiConfig } from "../../../types/index.js"; +/** + * The plugin's own copy of what used to be core's host-web-endpoints.ts, + * now that webUiConfig lives in this plugin's host-scope settings instead of + * an ssh_data column. Same rules, same shape. + * + * Shared between backend and frontend (validation runs in both: the backend + * is the real enforcement point, the editor mirrors it for instant feedback), + * so this file has no runtime dependencies of its own. + */ -export { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH }; +export type WebEndpointAccess = "direct" | "tunnel"; +export type WebEndpointRender = "external" | "embedded"; + +/** One web UI a host serves, declared in this plugin's host settings. */ +export interface WebEndpoint { + /** + * Stable identifier. Must NOT be derived from the port: it keys both the + * tunnel name and the tab identity, so editing a port has to leave a live + * tunnel findable under the same name. + */ + id: string; + label: string; + scheme: "http" | "https"; + port: number; + /** Defaults to "/". Normalized at the storage boundary, never here. */ + path?: string; + access: WebEndpointAccess; + render: WebEndpointRender; + /** + * Direct endpoints only. Allows an invalid TLS certificate for this + * endpoint's exact origin. A no-op for tunnel access, whose host component + * is loopback and therefore already exempt. + */ + ignoreCert?: boolean; + /** + * Tunnel endpoints only. Where the backend binds the forward, exactly as + * the tunnels plugin exposes it. Defaults to 127.0.0.1, reachable only from + * the machine running the backend. A web deployment runs the backend on a + * server, so reaching the forward from a browser needs an address that + * machine answers on -- which also exposes the target's web UI to anyone + * who can reach the port, with no login in front of it. + */ + bindHost?: string; + /** + * Tunnel endpoints only. Which port the forward listens on, as the tunnels + * plugin's Source Port does. Left unset the kernel picks a free one, which + * is fine when backend and browser share a machine -- but a container can + * only publish ports it knows in advance. + */ + localPort?: number; +} + +export interface WebUiConfig { + endpoints: WebEndpoint[]; +} + +/** A host may declare at most this many web endpoints. */ +export const MAX_WEB_ENDPOINTS = 16; +/** Endpoint labels are truncated to this length. */ +export const MAX_WEB_ENDPOINT_LABEL_LENGTH = 64; const SCHEMES = new Set(["http", "https"]); const ACCESS_VALUES = new Set(["direct", "tunnel"]); @@ -146,7 +199,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null { * Drops any endpoint it refuses rather than rejecting the whole host -- one * bad row must not make a host unsaveable or unlistable. The editor is * responsible for telling the user before that happens - * (plugins/web-endpoint/src/frontend/web-endpoint-validation.ts). + * (src/frontend/web-endpoint-validation.ts). */ export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] { if (!Array.isArray(raw)) return []; @@ -168,8 +221,7 @@ export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] { /** * Never throws. A malformed stored value yields an empty endpoint list, so a - * half-written config cannot take out the whole host listing -- which is what - * dockerConfig's bare JSON.parse does today. + * half-written config cannot take out the whole host listing. */ export function parseWebUiConfig(raw: unknown): WebUiConfig { if (raw === undefined || raw === null) return { endpoints: [] }; @@ -191,11 +243,11 @@ export function parseWebUiConfig(raw: unknown): WebUiConfig { }; } -/** Null when nothing survives normalization, so the column is cleared. */ -export function serializeWebUiConfig(config: unknown): string | null { +/** Null when nothing survives normalization, so the setting is cleared. */ +export function serializeWebUiConfig(config: unknown): WebUiConfig | null { const endpoints = normalizeWebEndpoints( (config as { endpoints?: unknown } | null | undefined)?.endpoints, ); if (endpoints.length === 0) return null; - return JSON.stringify({ endpoints }); + return { endpoints }; } diff --git a/plugins/web-endpoint/tests/backend/activate.test.ts b/plugins/web-endpoint/tests/backend/activate.test.ts new file mode 100644 index 000000000..45e1320d2 --- /dev/null +++ b/plugins/web-endpoint/tests/backend/activate.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import express from "express"; +import { + createMockCtx, + type MockPluginContext, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; +import { activate } from "../../src/backend/index.js"; + +const manifest = manifestJson as unknown as PluginManifest; + +let mock: MockPluginContext | null = null; + +afterEach(async () => { + for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose(); + mock = null; +}); + +function activateWith(capabilities: string[]) { + mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities, + router: () => express.Router(), + }); + return activate(mock.ctx); +} + +describe("web-endpoint activate", () => { + it("mounts its router at /plugin-api/web-endpoint", async () => { + await activateWith(manifest.capabilities); + expect(mock?.httpRouters).toHaveLength(1); + }); + + it("registers a hostImportNormalizer and revokes it on deactivate", async () => { + await activateWith(manifest.capabilities); + expect( + typeof mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"), + ).toBe("function"); + + for (const dispose of [...(mock?.disposals ?? [])].reverse()) + await dispose(); + expect( + mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"), + ).toBeUndefined(); + }); + + it("fails closed without network:serve", async () => { + await expect( + activateWith(manifest.capabilities.filter((c) => c !== "network:serve")), + ).rejects.toThrow(/network:serve/); + }); +}); diff --git a/plugins/web-endpoint/tests/backend/host-import.test.ts b/plugins/web-endpoint/tests/backend/host-import.test.ts new file mode 100644 index 000000000..9b987b15e --- /dev/null +++ b/plugins/web-endpoint/tests/backend/host-import.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { hostImportNormalizer } from "../../src/backend/host-import.js"; + +describe("hostImportNormalizer", () => { + it("returns null when the row carries neither field", () => { + expect(hostImportNormalizer({})).toBeNull(); + }); + + it("normalizes and validates webUiConfig, dropping invalid endpoints", () => { + const result = hostImportNormalizer({ + enableWebUi: true, + webUiConfig: { + endpoints: [ + { + id: "e1", + label: "Proxmox", + scheme: "https", + port: 8006, + access: "direct", + render: "embedded", + }, + { + id: "e2", + label: "", + scheme: "https", + port: 1, + access: "direct", + render: "embedded", + }, + ], + }, + }); + expect(result?.enableWebUi).toBe(true); + expect(result?.webUiConfig).toEqual({ + endpoints: [ + { + id: "e1", + label: "Proxmox", + scheme: "https", + port: 8006, + path: "/", + access: "direct", + render: "embedded", + ignoreCert: false, + }, + ], + }); + }); + + it("clears webUiConfig to null when enabling with no endpoints", () => { + const result = hostImportNormalizer({ enableWebUi: true }); + expect(result).toEqual({ enableWebUi: true, webUiConfig: null }); + }); + + it("normalizes enableWebUi to a boolean", () => { + expect(hostImportNormalizer({ enableWebUi: "yes" })).toEqual({ + enableWebUi: true, + webUiConfig: null, + }); + }); +}); diff --git a/plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts b/plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts deleted file mode 100644 index 72c86b6fd..000000000 --- a/plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -/** - * This codebase enumerates host columns by hand in a lot of places, and - * "added a column, missed one update path" is its most repeated bug -- during - * the first attempt at this feature a field was missed at one of these points - * six separate times, each surfacing as a different mystery (endpoints that - * saved but never appeared, config that vanished on reload). - * - * Every one of these files already enumerates the Docker pair, which is the - * exact shape the web endpoint pair copies. So "mentions enableDocker but not - * enableWebUi" is a reliable proxy for "was not updated", and it fails loudly - * the moment someone adds the tenth enumeration point without this one. - * - * This is a coarse guard on purpose: it cannot tell whether the field is used - * CORRECTLY, only that the file knows it exists. Behavioural coverage lives in - * the route tests. - */ -// Repo-root relative, resolved from this file rather than cwd: the plugin -// suite runs with the plugin directory as cwd. -const REPO_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../../..", -); - -function source(relative: string): string { - return readFileSync(path.resolve(REPO_ROOT, relative), "utf8"); -} - -const ENUMERATION_POINTS = [ - "src/backend/database/database.ts", - "src/backend/database/db/index.ts", - "src/backend/database/routes/host-normalizers.ts", - "src/backend/database/routes/host.ts", - "src/backend/database/routes/host-bulk-routes.ts", - "src/ui/sidebar/host-export-payload.ts", - "src/ui/sidebar/HostManagerData.ts", - "src/ui/sidebar/HostEditorData.ts", - "src/ui/lib/host-to-ssh-host.ts", -]; - -describe("web endpoint column enumeration", () => { - it.each(ENUMERATION_POINTS)( - "%s enumerates the enable flag alongside enableDocker", - (file) => { - const text = source(file); - // Guards the guard: if the Docker anchor ever disappears from a file, - // this test would otherwise pass vacuously forever. - expect(text).toMatch(/enableDocker|enable_docker/); - expect(text).toMatch(/enableWebUi|enable_web_ui/); - }, - ); - - it.each(ENUMERATION_POINTS)( - "%s enumerates the config column alongside dockerConfig", - (file) => { - const text = source(file); - expect(text).toMatch(/dockerConfig|docker_config/); - expect(text).toMatch(/webUiConfig|web_ui_config/); - }, - ); - - it("parses webUiConfig through the guarded parser, not a bare JSON.parse", () => { - // dockerConfig uses a bare JSON.parse, so one malformed value takes out - // the whole host listing. Do not copy that. - const text = source("src/backend/database/routes/host-normalizers.ts"); - expect(text).toContain("parseWebUiConfig"); - expect(text).not.toMatch(/JSON\.parse\(\s*host\.webUiConfig/); - }); - - it("clears webUiConfig on disable in both host.ts write paths", () => { - // Three write paths disagreed on this during the first attempt. The export - // payload ships webUiConfig unconditionally, so a host disabled without - // clearing still exports its endpoint list -- internal hostnames, ports and - // paths -- while the UI reads as off everywhere, and re-enabling silently - // resurrects stale endpoints. - const text = source("src/backend/database/routes/host.ts"); - // Create and update. Anchored on the exact conditional, not a loose - // pattern: a looser regex here passed even with the guard deleted. - const occurrences = text.split("webUiConfig: enableWebUi").length - 1; - expect(occurrences).toBe(2); - }); - - it("clears webUiConfig on a bulk disable", () => { - const text = source("src/backend/database/routes/host-bulk-routes.ts"); - expect(text).toContain( - "if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;", - ); - }); - - it("shares webUiConfig with connect-level recipients", () => { - // A connect-level recipient is ALREADY authorized to open these tunnels - // (the open route gates on canAccessHost(..., "connect")), so withholding - // the config only breaks discovery while the flag advertises the feature. - // The dockerConfig precedent does not transfer: Docker's tab works without - // its config, whereas a web endpoint IS its config. - const text = source("src/backend/database/routes/host-normalizers.ts"); - const block = text.slice(text.indexOf("CONNECT_LEVEL_FIELDS")); - const list = block.slice(0, block.indexOf("]")); - expect(list).toContain("enableWebUi"); - expect(list).toContain("webUiConfig"); - }); -}); diff --git a/plugins/web-endpoint/tests/backend/routes.test.ts b/plugins/web-endpoint/tests/backend/routes.test.ts index d9f36643d..c5306dac3 100644 --- a/plugins/web-endpoint/tests/backend/routes.test.ts +++ b/plugins/web-endpoint/tests/backend/routes.test.ts @@ -1,135 +1,160 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; import express from "express"; -import { readFile } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createFakeContext } from "@termix/plugin-sdk/testing"; +import { + createMockCtx, + type MockPluginContext, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; +import { createWebEndpointRoutes } from "../../src/backend/routes.js"; -const resolveHostById = vi.hoisted(() => vi.fn()); -vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({ - resolveHostById, -})); +const manifest = manifestJson as unknown as PluginManifest; const forward = vi.fn(); +const openIsolatedWindow = vi.fn(); -function response() { - const res = { - statusCode: 0, - body: undefined as unknown, - status(code: number) { - this.statusCode = code; - return this; - }, - json(payload: unknown) { - this.body = payload; - return this; - }, - }; - return res as unknown as express.Response & { - statusCode: number; - body: { port?: number; error?: string }; +function endpoint(overrides: Record = {}) { + return { + id: "e1", + label: "Proxmox", + scheme: "http", + port: 8006, + path: "/", + access: "tunnel", + render: "embedded", + ...overrides, }; } -function request(body: unknown) { - return { body } as unknown as express.Request; -} - -const endpoint = (overrides: Record = {}) => ({ - id: "e1", - label: "Proxmox", - scheme: "http", - port: 8006, - path: "/", - access: "tunnel", - render: "embedded", - ...overrides, -}); - function host(overrides: Record = {}) { return { id: 7, userId: "u1", + name: "nas", ip: "10.0.0.5", port: 22, username: "root", + tags: null, + folder: null, authType: "password", - enableWebUi: true, - webUiConfig: JSON.stringify({ endpoints: [endpoint()] }), ...overrides, }; } -/** Activates the routes against a fake ctx, with or without the tunnels service. */ -async function load(options: { tunnels?: boolean; actor?: string } = {}) { - const fake = createFakeContext({ +let mock: MockPluginContext; +let server: http.Server; +let baseUrl: string; + +async function start( + options: { + hostOverrides?: Record; + noHost?: boolean; + enableWebUi?: boolean; + endpoints?: unknown[]; + capabilities?: string[]; + tunnelsAvailable?: boolean; + } = {}, +) { + mock = createMockCtx({ + manifest, pluginId: "web-endpoint", - actor: options.actor ?? "u1", + actor: "u1", + capabilities: options.capabilities ?? [ + "hosts:read", + "network:serve", + "ui:surface", + "desktop:window", + ], + hosts: options.noHost ? [] : [host(options.hostOverrides)], }); - if (options.tunnels !== false) { - fake.ctx.services.provide("tunnels.access", { forward }); + const hostId = (options.hostOverrides?.id as number) ?? 7; + if (options.enableWebUi !== false) { + await mock.ctx.settings.setHost(hostId, "enableWebUi", true); + await mock.ctx.settings.setHost(hostId, "webUiConfig", { + endpoints: options.endpoints ?? [endpoint()], + }); } - const routes = await import("../../src/backend/routes.js"); - routes.startWebEndpointService(express.Router(), fake.ctx); - return routes; + if (options.tunnelsAvailable !== false) { + mock.ctx.services.provide("tunnels.access", { forward }); + } + + const app = express(); + app.use(express.json()); + app.use(createWebEndpointRoutes(mock.ctx)); + + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; } beforeEach(() => { forward.mockReset(); forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 }); - resolveHostById.mockReset(); - resolveHostById.mockResolvedValue(host()); + openIsolatedWindow.mockReset(); + openIsolatedWindow.mockResolvedValue({ success: true }); }); -afterEach(() => { - vi.resetModules(); +afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); }); describe("POST /open", () => { it("refuses a host the user cannot resolve, without opening anything", async () => { - resolveHostById.mockResolvedValue(null); - const { handleWebEndpointOpen } = await load(); - const res = response(); - await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); - - expect(res.statusCode).toBe(403); + await start({ noHost: true }); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(403); expect(forward).not.toHaveBeenCalled(); }); it("refuses when the feature is disabled even though the config still lists the endpoint", async () => { - resolveHostById.mockResolvedValue(host({ enableWebUi: false })); - const { handleWebEndpointOpen } = await load(); - const res = response(); - await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); - - expect(res.statusCode).toBe(400); + await start({ enableWebUi: false }); + await mock.ctx.settings.setHost(7, "webUiConfig", { + endpoints: [endpoint()], + }); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(400); expect(forward).not.toHaveBeenCalled(); }); it("refuses an endpoint id that matches nothing", async () => { - const { handleWebEndpointOpen } = await load(); - const res = response(); - await handleWebEndpointOpen( - request({ hostId: 7, endpointId: "nope" }), - res, - ); - expect(res.statusCode).toBe(400); + await start(); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "nope" }), + }); + expect(res.status).toBe(400); }); it("returns the port the tunnels service bound", async () => { - const { handleWebEndpointOpen } = await load(); - const res = response(); - await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); - - expect(res.statusCode).toBe(200); - expect(res.body.port).toBe(41234); + await start(); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(200); + expect((await res.json()).port).toBe(41234); }); it("asks for a loopback forward under the reserved web name", async () => { - const { handleWebEndpointOpen } = await load(); - await handleWebEndpointOpen( - request({ hostId: 7, endpointId: "e1" }), - response(), - ); - + await start(); + await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); expect(forward).toHaveBeenCalledWith( 7, { @@ -143,85 +168,137 @@ describe("POST /open", () => { }); it("passes the endpoint's own bind address and fixed port through", async () => { - resolveHostById.mockResolvedValue( - host({ - webUiConfig: JSON.stringify({ - endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })], - }), - }), - ); - const { handleWebEndpointOpen } = await load(); - await handleWebEndpointOpen( - request({ hostId: 7, endpointId: "e1" }), - response(), - ); - + await start({ + endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })], + }); + await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); const target = forward.mock.calls[0][1]; expect(target.bindHost).toBe("0.0.0.0"); expect(target.bindPort).toBe(38080); }); it("reports the service's reason as a 502 instead of a dead 200", async () => { + await start(); forward.mockRejectedValue( new Error("Channel open failure: connect failed"), ); - const { handleWebEndpointOpen } = await load(); - const res = response(); - await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); - - expect(res.statusCode).toBe(502); - expect(String(res.body.error)).toMatch(/connect failed/); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(502); + expect(String((await res.json()).error)).toMatch(/connect failed/); }); it("answers 503 while the tunnels plugin is not available", async () => { - const { handleWebEndpointOpen } = await load({ tunnels: false }); - const res = response(); - await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); - - expect(res.statusCode).toBe(503); + await start({ tunnelsAvailable: false }); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(503); }); - it("rejects a malformed request before touching the database", async () => { - const { handleWebEndpointOpen } = await load(); + it("rejects a malformed request before touching settings", async () => { + await start(); + const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost"); for (const body of [ {}, { hostId: "7", endpointId: "e1" }, { hostId: 0, endpointId: "e1" }, ]) { - const res = response(); - await handleWebEndpointOpen(request(body), res); - expect(res.statusCode).toBe(400); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); } - expect(resolveHostById).not.toHaveBeenCalled(); + expect(getHostSpy).not.toHaveBeenCalled(); + }); + + it("refuses a direct-access endpoint", async () => { + await start({ endpoints: [endpoint({ access: "direct" })] }); + const res = await fetch(`${baseUrl}/open`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(400); }); }); -/** - * The client path and the server path have to agree. Core mounts every - * plugin at /plugin-api/, so the client calls /plugin-api/web-endpoint/open - * and the only half this plugin owns is "/open". - */ -describe("route registration", () => { - it("registers /open on the router core hands it", async () => { - const routes = await load(); +describe("POST /open-window", () => { + it("refuses without desktop:window", async () => { + await start({ + capabilities: ["hosts:read", "network:serve", "ui:surface"], + }); + const res = await fetch(`${baseUrl}/open-window`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(502); + }); + it("opens a tunnel first, then the window at the resolved port", async () => { + await start(); + const res = await fetch(`${baseUrl}/open-window`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1" }), + }); + expect(res.status).toBe(200); + expect(forward).toHaveBeenCalledOnce(); + expect(mock.desktopWindows).toEqual([ + { url: "http://127.0.0.1:41234/", title: "Proxmox", ignoreCert: false }, + ]); + }); + + it("opens a direct endpoint at the host's own address without a tunnel", async () => { + await start({ + endpoints: [endpoint({ access: "direct", scheme: "https", port: 443 })], + }); + const res = await fetch(`${baseUrl}/open-window`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }), + }); + expect(res.status).toBe(200); + expect(forward).not.toHaveBeenCalled(); + expect(mock.desktopWindows).toEqual([ + { url: "https://10.0.0.5:443/", title: "Proxmox", ignoreCert: true }, + ]); + }); + + it("ignores ignoreCert for a tunnel endpoint, whose host component is already loopback", async () => { + await start(); + await fetch(`${baseUrl}/open-window`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }), + }); + expect(mock.desktopWindows[0].ignoreCert).toBe(false); + }); +}); + +describe("route registration", () => { + it("registers /open and /open-window", async () => { + await start(); + const routes = createWebEndpointRoutes(mock.ctx); const paths = ( - routes.router as unknown as { + routes as unknown as { stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>; } ).stack .filter((layer) => layer.route?.methods.post) .map((layer) => layer.route?.path); - - expect(paths).toContain("/open"); - }); - - it("is reached through the plugin mount rather than a port of its own", async () => { - const source = await readFile( - new URL("../../src/backend/index.ts", import.meta.url), - "utf8", - ); - - expect(source).toContain("ctx.http.router()"); + expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"])); }); }); diff --git a/plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts b/plugins/web-endpoint/tests/backend/web-endpoint-config.test.ts similarity index 97% rename from plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts rename to plugins/web-endpoint/tests/backend/web-endpoint-config.test.ts index ead89d7f6..3274ba4b6 100644 --- a/plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts +++ b/plugins/web-endpoint/tests/backend/web-endpoint-config.test.ts @@ -4,7 +4,7 @@ import { normalizeWebEndpoints, parseWebUiConfig, serializeWebUiConfig, -} from "../../../../src/backend/database/routes/host-web-endpoints.js"; +} from "../../src/shared/web-endpoint-config.js"; function valid(overrides: Record = {}) { return { @@ -249,8 +249,7 @@ describe("parseWebUiConfig", () => { }); it("returns empty endpoints for malformed JSON instead of throwing", () => { - // A half-written config must not take out the whole host listing, which - // is what dockerConfig's bare JSON.parse does today. + // A half-written config must not take out the whole host listing. expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] }); }); @@ -263,8 +262,7 @@ describe("parseWebUiConfig", () => { describe("serializeWebUiConfig", () => { it("round-trips through normalization", () => { expect( - parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] })) - .endpoints[0].id, + serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id, ).toBe("e1"); }); diff --git a/plugins/web-endpoint/tests/frontend/HostEditorWebUiSection.test.tsx b/plugins/web-endpoint/tests/frontend/HostEditorWebUiSection.test.tsx deleted file mode 100644 index 3a1142af2..000000000 --- a/plugins/web-endpoint/tests/frontend/HostEditorWebUiSection.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import "@testing-library/jest-dom/vitest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { WebEndpoint, WebUiConfig } from "@/types/index"; - -const isElectron = vi.hoisted(() => vi.fn(() => true)); -vi.mock("@/lib/electron", () => ({ isElectron })); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, vars?: Record) => - vars ? `${key}:${JSON.stringify(vars)}` : key, - }), -})); - -import { HostEditorWebUiSection } from "../../src/frontend/HostEditorWebUiSection"; - -function endpoint(overrides: Partial = {}): WebEndpoint { - return { - id: "e1", - label: "Proxmox", - scheme: "https", - port: 8006, - path: "/", - access: "direct", - render: "external", - ...overrides, - }; -} - -function setup( - config: WebUiConfig = { endpoints: [] }, - { - enableWebUi = true, - tunnelAvailable = true, - }: { enableWebUi?: boolean; tunnelAvailable?: boolean } = {}, -) { - const setField = vi.fn(); - render( - , - ); - return setField; -} - -beforeEach(() => { - isElectron.mockReturnValue(true); -}); - -afterEach(cleanup); - -describe("HostEditorWebUiSection", () => { - it("hides the endpoint list until the feature is enabled", () => { - setup({ endpoints: [endpoint()] }, { enableWebUi: false }); - expect(screen.queryByLabelText("hosts.webUiLabel")).not.toBeInTheDocument(); - expect( - screen.queryByText("hosts.webUiAddEndpoint"), - ).not.toBeInTheDocument(); - }); - - it("adds an endpoint with a non-colliding label", () => { - const setField = setup({ - endpoints: [endpoint({ label: "hosts.webUiNewEndpointLabel" })], - }); - fireEvent.click(screen.getByText("hosts.webUiAddEndpoint")); - - const [, value] = setField.mock.calls[0]; - const added = (value as WebUiConfig).endpoints[1]; - // Labels identify an endpoint in the sidebar picker, so a fresh row must - // not silently duplicate one already there. - expect(added.label).toBe("hosts.webUiNewEndpointLabel 2"); - expect(added.id).toBeTruthy(); - }); - - it("stops adding at the cap", () => { - const many = Array.from({ length: 16 }, (_, i) => - endpoint({ id: `e${i}`, label: `E${i}` }), - ); - setup({ endpoints: many }); - expect(screen.getByText("hosts.webUiAddEndpoint")).toBeDisabled(); - }); - - it("refuses to commit a port the normalizer would drop", () => { - // Number("") is 0, which the normalizer rejects -- committing it would - // silently discard the endpoint on save with no error shown. - const setField = setup({ endpoints: [endpoint()] }); - fireEvent.change(screen.getByLabelText("hosts.webUiPort"), { - target: { value: "" }, - }); - expect(setField).not.toHaveBeenCalled(); - - fireEvent.change(screen.getByLabelText("hosts.webUiPort"), { - target: { value: "9000" }, - }); - expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].port).toBe( - 9000, - ); - }); - - it("shows the bind-host fields only for a tunnel endpoint", () => { - setup({ endpoints: [endpoint({ access: "direct" })] }); - expect(screen.queryByLabelText("hosts.bindHost")).not.toBeInTheDocument(); - - cleanup(); - setup({ endpoints: [endpoint({ access: "tunnel" })] }); - expect(screen.getByLabelText("hosts.bindHost")).toBeInTheDocument(); - expect(screen.getByLabelText("hosts.webUiLocalPort")).toBeInTheDocument(); - }); - - it("warns that a non-loopback bind is unauthenticated exposure", () => { - setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] }); - expect(screen.getByText("hosts.webUiBindHostExposed")).toBeInTheDocument(); - }); - - it("does not warn about exposure for a loopback bind", () => { - setup({ - endpoints: [endpoint({ access: "tunnel", bindHost: "127.0.0.1" })], - }); - expect( - screen.queryByText("hosts.webUiBindHostExposed"), - ).not.toBeInTheDocument(); - }); - - it("warns at config time that a loopback bind is unreachable from a browser", () => { - isElectron.mockReturnValue(false); - setup({ endpoints: [endpoint({ access: "tunnel" })] }); - // Said while configuring, not only when the tab fails to load. - expect( - screen.getByText("hosts.webUiBindHostUnreachable"), - ).toBeInTheDocument(); - }); - - it("warns at config time about the session-cookie collision", () => { - // The page host in jsdom is "localhost", which HAS a loopback alias, so - // force a host that does not to reach the second refusal. - isElectron.mockReturnValue(false); - const realLocation = window.location; - Object.defineProperty(window, "location", { - configurable: true, - get: () => ({ ...realLocation, hostname: "termix.example.com" }), - }); - - setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] }); - expect( - screen.getByText("hosts.webUiBindHostSharesSessionCookie"), - ).toBeInTheDocument(); - - Object.defineProperty(window, "location", { - configurable: true, - value: realLocation, - }); - }); - - it("explains why tunnelling is unavailable rather than leaving a dead option", () => { - // A disabled control with no stated reason reads as the dropdown being - // broken. - setup({ endpoints: [endpoint()] }, { tunnelAvailable: false }); - expect( - screen.getByText("hosts.webUiAccessTunnelUnavailable"), - ).toBeInTheDocument(); - }); - - it("reports a row the normalizer would drop", () => { - setup({ endpoints: [endpoint({ label: " " })] }); - expect( - screen.getByText(/hosts.webUiErrorLabelRequired/), - ).toBeInTheDocument(); - }); - - it("removes an endpoint", () => { - const setField = setup({ - endpoints: [endpoint(), endpoint({ id: "e2", label: "NAS" })], - }); - fireEvent.click(screen.getAllByLabelText("hosts.webUiRemoveEndpoint")[0]); - expect((setField.mock.calls[0][1] as WebUiConfig).endpoints).toHaveLength( - 1, - ); - expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].id).toBe( - "e2", - ); - }); -}); diff --git a/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx b/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx index 2b57df513..a374c134f 100644 --- a/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx +++ b/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx @@ -7,7 +7,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../../src/shared/web-endpoint-config"; import type { Host } from "@/types/ui-types"; const openWebEndpointTunnel = vi.hoisted(() => vi.fn()); @@ -59,7 +59,7 @@ function host( return { id: "7", ip: "192.168.1.10", - webUiConfig: { endpoints }, + pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } }, ...overrides, } as unknown as Host; } diff --git a/plugins/web-endpoint/tests/frontend/activate.test.tsx b/plugins/web-endpoint/tests/frontend/activate.test.tsx index a4b943e74..34e3d3c4c 100644 --- a/plugins/web-endpoint/tests/frontend/activate.test.tsx +++ b/plugins/web-endpoint/tests/frontend/activate.test.tsx @@ -1,14 +1,16 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent } from "@testing-library/react"; import { renderWithApp, type RenderedPluginApp, } from "@termix/plugin-sdk/testing"; import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions"; import type { Host } from "@/types/ui-types"; -import type { WebEndpoint } from "@/types"; import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import type { WebEndpoint } from "../../src/shared/web-endpoint-config"; import * as plugin from "../../src/frontend/index"; import manifestJson from "../../manifest.json"; +import locales from "../../locales/en.json"; const manifest = manifestJson as unknown as PluginManifest; @@ -25,13 +27,19 @@ function endpoint(overrides: Partial = {}): WebEndpoint { }; } -function host(overrides: Partial = {}): Host { +function host( + endpoints: WebEndpoint[] | undefined, + overrides: Partial = {}, +): Host { return { id: "7", ip: "10.0.0.5", name: "nas", enableSsh: false, - enableWebUi: false, + pluginSettings: + endpoints === undefined + ? {} + : { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } }, ...overrides, } as unknown as Host; } @@ -58,37 +66,32 @@ describe("web-endpoint activate", () => { }); it("offers no Web UI entry when the feature is off, even with endpoints", async () => { - expect( - await webAction( - host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }), - ), - ).toBeUndefined(); + const target = host([endpoint()], { + pluginSettings: { + "web-endpoint": { + enableWebUi: false, + webUiConfig: { endpoints: [endpoint()] }, + }, + }, + }); + expect(await webAction(target)).toBeUndefined(); }); it("offers no entry when enabled but no endpoints exist", async () => { - expect( - await webAction( - host({ enableWebUi: true, webUiConfig: { endpoints: [] } }), - ), - ).toBeUndefined(); + expect(await webAction(host([]))).toBeUndefined(); + }); + + it("offers no entry when the host has no web-endpoint settings at all", async () => { + expect(await webAction(host(undefined))).toBeUndefined(); }); it("appears without SSH", async () => { - const action = await webAction( - host({ - enableSsh: false, - enableWebUi: true, - webUiConfig: { endpoints: [endpoint()] }, - }), - ); + const action = await webAction(host([endpoint()], { enableSsh: false })); expect(action).toBeDefined(); }); it("labels the entry with the endpoint when there is only one", async () => { - const target = host({ - enableWebUi: true, - webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] }, - }); + const target = host([endpoint({ label: "Proxmox" })]); const action = await webAction(target); expect(action?.label?.(target)).toBe("Proxmox"); expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]); @@ -98,17 +101,14 @@ describe("web-endpoint activate", () => { const endpoints = Array.from({ length: 16 }, (_, i) => endpoint({ id: `e${i}`, label: `Endpoint ${i}` }), ); - const target = host({ enableWebUi: true, webUiConfig: { endpoints } }); + const target = host(endpoints); const action = await webAction(target); expect(action?.label?.(target)).toBeUndefined(); expect(action?.items?.(target)).toHaveLength(16); }); it("opens an embedded endpoint as a tab carrying its id", async () => { - const target = host({ - enableWebUi: true, - webUiConfig: { endpoints: [endpoint()] }, - }); + const target = host([endpoint()]); const action = await webAction(target); const calls: unknown[][] = []; const shell = { @@ -124,3 +124,36 @@ describe("web-endpoint activate", () => { ]); }); }); + +describe("host editor section", () => { + it("writes into the form's web-endpoint plugin settings", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + let form: Record = { + name: "nas", + pluginSettings: { "web-endpoint": { enableWebUi: false } }, + }; + const updateForm = vi.fn( + ( + patch: (current: Record) => Record, + ) => { + form = patch(form); + }, + ); + + const section = rendered.renderHostEditorSection("web-ui", { + form, + setField: vi.fn(), + updateForm, + protocols: { enableSsh: true }, + }); + + // The enable switch is the only button on the section before any + // endpoint rows exist (which only render once enabled). + fireEvent.click(section.querySelector("button") as HTMLButtonElement); + + const settings = (form.pluginSettings as Record)[ + "web-endpoint" + ] as { enableWebUi: boolean }; + expect(settings.enableWebUi).toBe(true); + }); +}); diff --git a/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts index c16e6045b..e5a94dd49 100644 --- a/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../../src/shared/web-endpoint-config"; const isElectron = vi.hoisted(() => vi.fn(() => false)); vi.mock("@/lib/electron", () => ({ isElectron })); -const tunnelPost = vi.hoisted(() => vi.fn()); +const pluginPost = vi.hoisted(() => vi.fn()); vi.mock("@/main-axios", () => ({ handleApiError: (error: unknown) => { throw new Error(`generic: ${String(error)}`); @@ -27,32 +27,17 @@ function endpoint(overrides: Partial = {}): WebEndpoint { const host = { id: "7", ip: "192.168.1.10" }; -let pageHostname = "localhost"; -const realLocation = window.location; -const windowOpen = vi.fn(); - beforeEach(async () => { const { setWebEndpointApi } = await import("../../src/frontend/web-endpoint-api"); - setWebEndpointApi({ post: tunnelPost } as never); - pageHostname = "localhost"; + setWebEndpointApi({ post: pluginPost } as never); isElectron.mockReturnValue(false); - tunnelPost.mockReset(); - tunnelPost.mockResolvedValue({ data: { port: 41234 } }); - Object.defineProperty(window, "location", { - configurable: true, - get: () => ({ ...realLocation, hostname: pageHostname }), - }); - vi.stubGlobal("open", windowOpen); + pluginPost.mockReset(); + pluginPost.mockResolvedValue({ data: { port: 41234 } }); }); afterEach(() => { - Object.defineProperty(window, "location", { - configurable: true, - value: realLocation, - }); vi.unstubAllGlobals(); - windowOpen.mockReset(); }); describe("openWebEndpointTunnel", () => { @@ -63,7 +48,7 @@ describe("openWebEndpointTunnel", () => { await import("../../src/frontend/web-endpoint-api"); await openWebEndpointTunnel(7, "e1"); - expect(tunnelPost).toHaveBeenCalledWith("/open", { + expect(pluginPost).toHaveBeenCalledWith("/open", { hostId: 7, endpointId: "e1", }); @@ -86,7 +71,7 @@ describe("openWebEndpointTunnel", () => { data: { error: "Timed out reaching the endpoint port" }, }, }); - tunnelPost.mockRejectedValue(axiosError); + pluginPost.mockRejectedValue(axiosError); const { openWebEndpointTunnel, WebEndpointTunnelError } = await import("../../src/frontend/web-endpoint-api"); @@ -101,7 +86,7 @@ describe("openWebEndpointTunnel", () => { it("falls back to the shared handler when the body carries no string reason", async () => { // A body shaped { error: } would otherwise render as // "[object Object]" to the user. - tunnelPost.mockRejectedValue( + pluginPost.mockRejectedValue( Object.assign(new Error("boom"), { isAxiosError: true, response: { status: 500, data: { error: { nested: true } } }, @@ -113,7 +98,7 @@ describe("openWebEndpointTunnel", () => { }); it("treats a missing port as a failure rather than returning undefined", async () => { - tunnelPost.mockResolvedValue({ data: {} }); + pluginPost.mockResolvedValue({ data: {} }); const { openWebEndpointTunnel } = await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/); @@ -132,35 +117,45 @@ describe("requireNumericHostId", () => { }); /** - * "Open externally" is not the safer path. The cookie jar belongs to the - * browser either way, so a tunnel URL on the host string serving Termix hands - * the tunnelled service this session exactly as an embedded frame would. + * The backend now resolves and validates the target URL itself (host address, + * tunnel port, loopback/session-cookie checks) before ever calling + * ctx.desktop.openIsolatedWindow, so this call is a thin ask-and-report over + * the plugin's own /open-window route rather than a client-side URL builder. */ describe("openWebEndpointExternally", () => { - it("refuses shared-cookie browser windows before opening a tunnel", async () => { + it("refuses outside the desktop app before calling the backend", async () => { const { openWebEndpointExternally } = await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow( /desktop app/, ); - expect(windowOpen).not.toHaveBeenCalled(); - expect(tunnelPost).not.toHaveBeenCalled(); + expect(pluginPost).not.toHaveBeenCalled(); }); - it("opens desktop endpoints through the isolated-window bridge", async () => { + + it("posts hostId, endpointId and ignoreCert to /open-window", async () => { isElectron.mockReturnValue(true); - const invoke = vi.fn().mockResolvedValue({ success: true }); - Object.defineProperty(window, "electronAPI", { - configurable: true, - value: { invoke }, - }); + pluginPost.mockResolvedValue({ data: { success: true } }); const { openWebEndpointExternally } = await import("../../src/frontend/web-endpoint-api"); await openWebEndpointExternally(host, endpoint({ ignoreCert: true })); - expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", { - url: "https://127.0.0.1:41234/", + expect(pluginPost).toHaveBeenCalledWith("/open-window", { + hostId: 7, + endpointId: "e1", ignoreCert: true, }); - expect(windowOpen).not.toHaveBeenCalled(); - delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + it("preserves the backend's reason instead of the generic message", async () => { + isElectron.mockReturnValue(true); + const axiosError = Object.assign(new Error("Request failed"), { + isAxiosError: true, + response: { status: 502, data: { error: "No window to attach to" } }, + }); + pluginPost.mockRejectedValue(axiosError); + const { openWebEndpointExternally, WebEndpointTunnelError } = + await import("../../src/frontend/web-endpoint-api"); + await expect( + openWebEndpointExternally(host, endpoint()), + ).rejects.toBeInstanceOf(WebEndpointTunnelError); }); }); diff --git a/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts index b58a680a7..b97988f7c 100644 --- a/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts @@ -6,7 +6,7 @@ import { sharesCookieSiteWithPage, webEndpointRefusalReason, } from "../../src/frontend/web-endpoint-url"; -import type { WebEndpoint } from "@/types/index"; +import type { WebEndpoint } from "../../src/shared/web-endpoint-config"; function endpoint(overrides: Partial = {}): WebEndpoint { return { diff --git a/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts index 3bff5ce14..48d28cb8f 100644 --- a/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts @@ -6,8 +6,8 @@ import { webEndpointErrorKey, webEndpointRowError, } from "../../src/frontend/web-endpoint-validation"; -import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js"; -import type { WebEndpoint } from "@/types/index"; +import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config"; +import type { WebEndpoint } from "../../src/shared/web-endpoint-config"; function endpoint(overrides: Partial = {}): WebEndpoint { return { diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index 3d4703038..ec6de3e0e 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -133,12 +133,9 @@ "plugins/tailscale/src/frontend/index.tsx", "plugins/tailscale/src/frontend/tailscale-api.ts", "plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx", - "plugins/web-endpoint/src/frontend/HostWebUiTab.tsx", "plugins/web-endpoint/src/frontend/WebEndpointTab.tsx", "plugins/web-endpoint/src/frontend/index.tsx", - "plugins/web-endpoint/src/frontend/web-endpoint-api.ts", - "plugins/web-endpoint/src/frontend/web-endpoint-url.ts", - "plugins/web-endpoint/src/frontend/web-endpoint-validation.ts" + "plugins/web-endpoint/src/frontend/web-endpoint-api.ts" ], "plugin-to-core": [ "plugins/ai/src/backend/egress.ts", @@ -207,8 +204,7 @@ "plugins/remote-desktop/src/backend/token-service.ts", "plugins/ssh-terminal/src/backend/index.ts", "plugins/tailscale/src/backend/host-metrics-manager.ts", - "plugins/tailscale/src/backend/routes.ts", - "plugins/web-endpoint/src/backend/routes.ts" + "plugins/tailscale/src/backend/routes.ts" ], "plugin-to-plugin": [ "plugins/ai/src/backend/tools/executor.ts", diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 7013006e9..c9fe0af8f 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -13,7 +13,7 @@ import { createCurrentPluginSettingsRepository, } from "../repositories/factory.js"; import { validateParentHostId } from "./host-parent-validation.js"; -import { serializeWebUiConfig } from "./host-web-endpoints.js"; +import { applyPluginHostImportSettings } from "./host-plugin-settings.js"; import { isNonEmptyString, isValidPort, @@ -286,10 +286,6 @@ export function registerHostBulkRoutes( simpleUpdates.enableFileManager = updates.enableFileManager; if (typeof updates.enableDocker === "boolean") simpleUpdates.enableDocker = updates.enableDocker; - if (typeof updates.enableWebUi === "boolean") { - simpleUpdates.enableWebUi = updates.enableWebUi; - if (!updates.enableWebUi) simpleUpdates.webUiConfig = null; - } if (typeof updates.enableTmuxMonitor === "boolean") simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor; if (typeof updates.enableTerminalToolbar === "boolean") @@ -377,6 +373,39 @@ export function registerHostBulkRoutes( } } + // Web endpoint enable/disable lives in the plugin's own host-scope + // settings. Disabling clears webUiConfig, matching the old column + // write it replaces. + if (typeof updates.enableWebUi === "boolean") { + const pluginSettingsRepository = + createCurrentPluginSettingsRepository(); + for (const host of ownedHosts) { + try { + const scopeId = String(host.id); + await pluginSettingsRepository.set( + "web-endpoint", + "host", + scopeId, + "enableWebUi", + JSON.stringify(updates.enableWebUi), + ); + if (!updates.enableWebUi) { + await pluginSettingsRepository.set( + "web-endpoint", + "host", + scopeId, + "webUiConfig", + JSON.stringify(null), + ); + } + } catch { + errors.push( + `Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`, + ); + } + } + } + return res.json({ updated: ownedIds.length, failed: unauthorizedIds.length, @@ -737,7 +766,6 @@ export function registerHostBulkRoutes( enableTunnel: hostData.enableTunnel !== false, enableFileManager: hostData.enableFileManager !== false, enableDocker: hostData.enableDocker || false, - enableWebUi: hostData.enableWebUi || false, enableTmuxMonitor: hostData.enableTmuxMonitor || false, enableTerminalToolbar: hostData.enableTerminalToolbar !== false, enableAiAssistant: hostData.enableAiAssistant || false, @@ -762,9 +790,6 @@ export function registerHostBulkRoutes( dockerConfig: hostData.dockerConfig ? JSON.stringify(hostData.dockerConfig) : null, - webUiConfig: hostData.enableWebUi - ? serializeWebUiConfig(hostData.webUiConfig) - : null, terminalConfig: hostData.terminalConfig ? JSON.stringify(hostData.terminalConfig) : null, @@ -860,27 +885,14 @@ export function registerHostBulkRoutes( results.success++; } - if (hostData.enableProxmox || hostData.proxmoxConfig) { - const pluginSettingsRepository = - createCurrentPluginSettingsRepository(); - const scopeId = String(savedHostId); - await pluginSettingsRepository.set( - "proxmox", - "host", - scopeId, - "enableProxmox", - JSON.stringify(hostData.enableProxmox || false), - ); - if (hostData.proxmoxConfig) { - await pluginSettingsRepository.set( - "proxmox", - "host", - scopeId, - "proxmoxConfig", - JSON.stringify(hostData.proxmoxConfig), - ); - } - } + // Every enabled plugin that declares host-scope settings and + // registered a hostImportNormalizer validates and writes its own + // fields here, so this loop does not need to know which plugins + // exist. See host-plugin-settings.ts. + await applyPluginHostImportSettings( + savedHostId, + hostData as Record, + ); } catch (error) { results.failed++; results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); @@ -1038,7 +1050,6 @@ export function registerHostBulkRoutes( enableTunnel: true, enableFileManager: true, enableDocker: false, - enableWebUi: false, enableTmuxMonitor: false, enableTerminalToolbar: true, enableAiAssistant: false, @@ -1056,7 +1067,6 @@ export function registerHostBulkRoutes( quickActions: null, statsConfig: null, dockerConfig: null, - webUiConfig: null, terminalConfig: null, forceKeyboardInteractive: "false", notes: null, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index c7f72ab01..83d8d34cc 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -1,5 +1,4 @@ import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; -import { parseWebUiConfig } from "./host-web-endpoints.js"; export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -364,8 +363,6 @@ const CONNECT_LEVEL_FIELDS = new Set([ "enableTunnel", "enableFileManager", "enableDocker", - "enableWebUi", - "webUiConfig", "enableTmuxMonitor", "enableTerminalToolbar", "enableAiAssistant", @@ -467,7 +464,6 @@ export function transformHostResponse( enableTunnel: !!host.enableTunnel, enableFileManager: host.enableFileManager !== false, enableDocker: !!host.enableDocker, - enableWebUi: !!host.enableWebUi, enableTmuxMonitor: !!host.enableTmuxMonitor, enableTerminalToolbar: host.enableTerminalToolbar !== false, enableAiAssistant: !!host.enableAiAssistant, @@ -519,9 +515,6 @@ export function transformHostResponse( dockerConfig: host.dockerConfig ? JSON.parse(host.dockerConfig as string) : undefined, - // Guarded, unlike dockerConfig directly above: parseWebUiConfig never - // throws, so a half-written config cannot take out the whole host listing. - webUiConfig: parseWebUiConfig(host.webUiConfig), forceKeyboardInteractive: host.forceKeyboardInteractive === "true", useWarpgate: !!host.useWarpgate, socks5ProxyChain: host.socks5ProxyChain diff --git a/src/backend/database/routes/host-plugin-settings.ts b/src/backend/database/routes/host-plugin-settings.ts index a8ef6d6c9..b16e7f12a 100644 --- a/src/backend/database/routes/host-plugin-settings.ts +++ b/src/backend/database/routes/host-plugin-settings.ts @@ -15,12 +15,13 @@ import { createCurrentPluginSettingsRepository } from "../repositories/factory.j import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js"; import { declaredFields, resolveFieldValue } from "../../plugins/settings.js"; import { getPluginRuntime } from "../../plugins/index.js"; +import { consume } from "../../plugins/registry.js"; import { sshLogger } from "../../utils/logger.js"; export type HostPluginSettings = Record>; /** Enabled plugins that declare host-scope settings, with their manifests. */ -function hostSettingsPlugins(): PluginManifest[] { +export function hostSettingsPlugins(): PluginManifest[] { try { const { loader } = getPluginRuntime(); return loader @@ -147,3 +148,48 @@ export async function writeHostPluginSettings( await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value)); } } + +/** + * A plugin's own validator for the fields it accepts inline on a bulk host + * import row (host-bulk-routes.ts's Termix-JSON import path). Registered + * through ctx.registry.provide(".hostImportNormalizer", fn) - not + * part of the SDK's typed ctx surface, because only this one bulk-import + * path calls it and a full contract is speculative until a second caller + * needs it. Returns the fields to write (JSON-serializable, matching + * writeHostPluginSettings's input), or null to write nothing for this row. + */ +export type PluginHostImportNormalizer = ( + raw: Record, +) => Record | null; + +/** + * Runs every enabled plugin's registered import normalizer over one imported + * host row and writes whatever each one returns, so a plugin's host-scope + * settings are validated the same way on bulk import as they are anywhere + * else - instead of host-bulk-routes.ts hardcoding one plugin's shape. + * + * Best-effort per plugin: one plugin's normalizer throwing must not fail the + * whole import, so it is logged and skipped rather than propagated. + */ +export async function applyPluginHostImportSettings( + hostId: number, + raw: Record, +): Promise { + for (const manifest of hostSettingsPlugins()) { + const normalizer = consume( + `${manifest.id}.hostImportNormalizer`, + ); + if (!normalizer) continue; + try { + const values = normalizer(raw); + if (values) await writeHostPluginSettings(manifest.id, hostId, values); + } catch (error) { + sshLogger.warn("Plugin host import normalizer failed", { + operation: "host_import_plugin_settings", + pluginId: manifest.id, + hostId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 37a63d875..ac897e13c 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -72,10 +72,6 @@ import type { HostResolutionHostRecord, } from "../repositories/host-resolution-repository.js"; import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js"; -import { - parseWebUiConfig, - serializeWebUiConfig, -} from "./host-web-endpoints.js"; import { requiresPersonalHostAuthentication, resolveRecipientSharedHostAuthentication, @@ -202,7 +198,6 @@ router.post( enableFileManager, scpLegacy, enableDocker, - enableWebUi, enableProxmox, enableTmuxMonitor, enableTerminalToolbar, @@ -219,7 +214,6 @@ router.post( quickActions, statsConfig, dockerConfig, - webUiConfig, proxmoxConfig, enableProxmoxStats, proxmoxStatsConfig, @@ -348,7 +342,6 @@ router.post( enableFileManager: enableFileManager ? 1 : 0, scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, - enableWebUi: enableWebUi ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1, enableAiAssistant: enableAiAssistant ? 1 : 0, @@ -369,13 +362,6 @@ router.post( ? dockerConfig : JSON.stringify(dockerConfig) : null, - webUiConfig: enableWebUi - ? serializeWebUiConfig( - typeof webUiConfig === "string" - ? safeParseJson(webUiConfig) - : webUiConfig, - ) - : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig @@ -930,7 +916,6 @@ router.put( enableFileManager, scpLegacy, enableDocker, - enableWebUi, enableProxmox, enableTmuxMonitor, enableTerminalToolbar, @@ -947,7 +932,6 @@ router.put( quickActions, statsConfig, dockerConfig, - webUiConfig, proxmoxConfig, enableProxmoxStats, proxmoxStatsConfig, @@ -1079,7 +1063,6 @@ router.put( enableFileManager: enableFileManager ? 1 : 0, scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, - enableWebUi: enableWebUi ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1, enableAiAssistant: enableAiAssistant ? 1 : 0, @@ -1100,13 +1083,6 @@ router.put( ? dockerConfig : JSON.stringify(dockerConfig) : null, - webUiConfig: enableWebUi - ? serializeWebUiConfig( - typeof webUiConfig === "string" - ? safeParseJson(webUiConfig) - : webUiConfig, - ) - : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig @@ -2100,9 +2076,10 @@ router.get( } const resolvedHost = (await resolveHostCredentials(host, userId)) || host; - const proxmoxSettings = ( + const hostPluginSettings = ( await loadHostPluginSettings([Number(hostId)]) - ).get(Number(hostId))?.proxmox as + ).get(Number(hostId)); + const proxmoxSettings = hostPluginSettings?.proxmox as | { enableProxmox?: boolean; proxmoxConfig?: unknown; @@ -2110,6 +2087,8 @@ router.get( proxmoxStatsConfig?: unknown; } | undefined; + const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as + { enableWebUi?: boolean; webUiConfig?: unknown } | undefined; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; @@ -2177,7 +2156,7 @@ router.get( enableFileManager: resolvedHost.enableFileManager !== false, scpLegacy: !!resolvedHost.scpLegacy, enableDocker: !!resolvedHost.enableDocker, - enableWebUi: !!resolvedHost.enableWebUi, + enableWebUi: !!webEndpointSettings?.enableWebUi, enableProxmox: !!proxmoxSettings?.enableProxmox, enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, @@ -2202,7 +2181,7 @@ router.get( statsConfig: resolvedHost.statsConfig ? JSON.parse(resolvedHost.statsConfig as string) : null, - webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig), + webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] }, dockerConfig: resolvedHost.dockerConfig ? JSON.parse(resolvedHost.dockerConfig as string) : null, @@ -2282,7 +2261,7 @@ router.get( try { const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(userId); - const proxmoxSettingsByHost = await loadHostPluginSettings( + const pluginSettingsByHost = await loadHostPluginSettings( allHosts.map((h) => h.id as number), ); @@ -2293,9 +2272,11 @@ router.get( const resolvedHost = shareMode ? host : (await resolveHostCredentials(host, userId)) || host; - const proxmoxSettings = proxmoxSettingsByHost.get(host.id as number) - ?.proxmox as + const hostPluginSettings = pluginSettingsByHost.get(host.id as number); + const proxmoxSettings = hostPluginSettings?.proxmox as { enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined; + const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as + { enableWebUi?: boolean; webUiConfig?: unknown } | undefined; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; @@ -2344,7 +2325,7 @@ router.get( enableTunnel: !!resolvedHost.enableTunnel, enableFileManager: resolvedHost.enableFileManager !== false, enableDocker: !!resolvedHost.enableDocker, - enableWebUi: !!resolvedHost.enableWebUi, + enableWebUi: !!webEndpointSettings?.enableWebUi, enableProxmox: !!proxmoxSettings?.enableProxmox, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, enableTerminalToolbar: @@ -2371,7 +2352,9 @@ router.get( statsConfig: resolvedHost.statsConfig ? JSON.parse(resolvedHost.statsConfig as string) : null, - webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig), + webUiConfig: webEndpointSettings?.webUiConfig ?? { + endpoints: [], + }, dockerConfig: resolvedHost.dockerConfig ? JSON.parse(resolvedHost.dockerConfig as string) : null, @@ -3010,10 +2993,9 @@ registerHostNetworkRoutes(router, { export default router; /** - * A webUiConfig arriving as a JSON string (an import, or a client that - * stringified it) must still reach serializeWebUiConfig as an object. - * Malformed input becomes null, which serializes to a cleared column rather - * than throwing inside a host save. + * A config field arriving as a JSON string (an import, or a client that + * stringified it) must still reach storage as an object. Malformed input + * becomes null rather than throwing inside a host save. */ function safeParseJson(raw: string): unknown { try { diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 353fb0006..48d781b5b 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -42,6 +42,7 @@ import { PluginCapabilityError, type PluginContext, type PluginModule, + type PluginOpenIsolatedWindowRequest, } from "@termix/plugin-sdk/backend"; import type { PluginTableDefinition } from "@termix/plugin-sdk/db"; import * as syncRegistry from "./sync-registry.js"; @@ -299,6 +300,28 @@ export function createPluginContext( { action: "db_refs" }, ); + const desktopOpenIsolatedWindow = guarded( + manifest, + "desktop:window", + async (request: PluginOpenIsolatedWindowRequest) => { + const { isElectronIpcAvailable, requestFromElectronMain } = + await import("../utils/electron-ipc-bridge.js"); + if (!isElectronIpcAvailable()) { + throw new Error( + `Plugin ${pluginId} tried to open an isolated window outside the desktop app`, + ); + } + return requestFromElectronMain<{ success: true }>( + "open-isolated-window", + request, + ); + }, + { + action: "desktop_open_isolated_window", + details: () => "opened an isolated Electron window", + }, + ); + return { pluginId, manifest, @@ -613,6 +636,10 @@ export function createPluginContext( ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), + desktop: { + openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), + }, + /** * Background work acts as a named user. Always audited, because "this ran * as someone" is exactly the thing an operator needs to be able to see. diff --git a/src/backend/starter.ts b/src/backend/starter.ts index a2e02b117..cd758793c 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -357,6 +357,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { runTunnelsSettingsMigration } = await import("./utils/crypto-migration/tunnels-settings-migration.js"); await runTunnelsSettingsMigration(); + + const { runWebEndpointSettingsMigration } = + await import("./utils/crypto-migration/web-endpoint-settings-migration.js"); + await runWebEndpointSettingsMigration(); } catch (error) { systemLogger.warn("Plugin runtime failed to initialize", { operation: "plugin_init", diff --git a/src/backend/tests/database/routes/host-plugin-settings.test.ts b/src/backend/tests/database/routes/host-plugin-settings.test.ts index f04ae5741..4592536fd 100644 --- a/src/backend/tests/database/routes/host-plugin-settings.test.ts +++ b/src/backend/tests/database/routes/host-plugin-settings.test.ts @@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) => (row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""), ), ); +const setCalls: Array<[string, string, string, string, string]> = []; +const set = vi.fn( + async ( + pluginId: string, + scope: string, + scopeId: string, + key: string, + value: string, + ) => { + setCalls.push([pluginId, scope, scopeId, key, value]); + }, +); vi.mock("../../../utils/logger.js", () => { const logger = { @@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => { }); vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }), + createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }), })); vi.mock("../../../plugins/index.js", () => ({ @@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({ decryptSystemSecret: async (stored: string) => stored, })); +const registryProviders = new Map(); +vi.mock("../../../plugins/registry.js", () => ({ + consume: (key: string) => registryProviders.get(key), +})); + const { attachHostPluginSettings, loadHostPluginSettings, withHostPluginSettings, + writeHostPluginSettings, + applyPluginHostImportSettings, } = await import("../../../database/routes/host-plugin-settings.js"); function manifest(id: string, host: unknown): PluginManifest { @@ -80,7 +99,10 @@ const DOCKER = manifest("docker", { beforeEach(() => { rows.length = 0; loaded.length = 0; + setCalls.length = 0; + registryProviders.clear(); getAllForScopeIds.mockClear(); + set.mockClear(); }); describe("loadHostPluginSettings", () => { @@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => { expect(await withHostPluginSettings(host)).toBe(host); }); }); + +describe("writeHostPluginSettings", () => { + it("JSON-stringifies each value under the plugin's own namespace", async () => { + await writeHostPluginSettings("docker", 7, { + enableDocker: true, + socketPath: null, + }); + + expect(setCalls).toEqual([ + ["docker", "host", "7", "enableDocker", "true"], + ["docker", "host", "7", "socketPath", "null"], + ]); + }); + + it("skips a field whose value is undefined", async () => { + await writeHostPluginSettings("docker", 7, { + enableDocker: true, + socketPath: undefined, + }); + + expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]); + }); +}); + +describe("applyPluginHostImportSettings", () => { + it("runs every enabled plugin's registered normalizer and writes what it returns", async () => { + loaded.push({ id: "docker", manifest: DOCKER, state: "active" }); + loaded.push({ + id: "web-endpoint", + manifest: manifest("web-endpoint", { + enableKey: "enableWebUi", + enableLabelKey: "k", + fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }], + }), + state: "active", + }); + registryProviders.set("docker.hostImportNormalizer", () => ({ + enableDocker: true, + })); + registryProviders.set("web-endpoint.hostImportNormalizer", () => null); + + await applyPluginHostImportSettings(7, { enableDocker: true }); + + expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]); + }); + + it("skips a plugin with no registered normalizer", async () => { + loaded.push({ id: "docker", manifest: DOCKER, state: "active" }); + + await applyPluginHostImportSettings(7, {}); + + expect(setCalls).toEqual([]); + }); + + it("logs and continues past a normalizer that throws", async () => { + loaded.push({ id: "docker", manifest: DOCKER, state: "active" }); + loaded.push({ + id: "web-endpoint", + manifest: manifest("web-endpoint", { + enableKey: "enableWebUi", + enableLabelKey: "k", + fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }], + }), + state: "active", + }); + registryProviders.set("docker.hostImportNormalizer", () => { + throw new Error("boom"); + }); + registryProviders.set("web-endpoint.hostImportNormalizer", () => ({ + enableWebUi: true, + })); + + await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined(); + expect(setCalls).toEqual([ + ["web-endpoint", "host", "7", "enableWebUi", "true"], + ]); + }); +}); diff --git a/plugins/web-endpoint/tests/backend/web-endpoint-window.test.ts b/src/backend/tests/electron/isolated-window.test.ts similarity index 79% rename from plugins/web-endpoint/tests/backend/web-endpoint-window.test.ts rename to src/backend/tests/electron/isolated-window.test.ts index 98e8b1ab2..970bbf60c 100644 --- a/plugins/web-endpoint/tests/backend/web-endpoint-window.test.ts +++ b/src/backend/tests/electron/isolated-window.test.ts @@ -1,8 +1,8 @@ import { createRequire } from "node:module"; import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { createWebEndpointWindows } = createRequire(import.meta.url)( - "../../../../electron/web-endpoint-window.cjs", +const { createIsolatedWindows } = createRequire(import.meta.url)( + "../../../../electron/isolated-window.cjs", ); const created: FakeWindow[] = []; class FakeWindow extends EventEmitter { @@ -38,10 +38,7 @@ type TestSession = EventEmitter & { }; let sessions: TestSession[]; let fromPartition: ReturnType; -let manager: ReturnType; -function event() { - return { sender: main.webContents, senderFrame: main.webContents.mainFrame }; -} +let manager: ReturnType; beforeEach(() => { created.length = 0; sessions = []; @@ -59,16 +56,16 @@ beforeEach(() => { sessions.push(session); return session; }); - manager = createWebEndpointWindows({ + manager = createIsolatedWindows({ BrowserWindow: FakeWindow, session: { fromPartition }, getMainWindow: () => main, }); }); -describe("isolated endpoint windows", () => { +describe("isolated windows", () => { it("creates unique non-persistent sessions without preload or Node privileges", async () => { - await manager.open(event(), { url: "https://service.test" }); - await manager.open(event(), { url: "https://service.test" }); + await manager.open({ url: "https://service.test" }); + await manager.open({ url: "https://service.test" }); expect(fromPartition.mock.calls[0][0]).not.toBe( fromPartition.mock.calls[1][0], ); @@ -82,27 +79,33 @@ describe("isolated endpoint windows", () => { webSecurity: true, }); }); - it("rejects subframes, other windows and non-web destinations before creating a session", async () => { - await expect( - manager.open( - { ...event(), senderFrame: {} }, - { url: "https://service.test" }, - ), - ).rejects.toThrow(/main Termix/); - await expect( - manager.open({ ...event(), sender: {} }, { url: "https://service.test" }), - ).rejects.toThrow(/main Termix/); + it("uses a caller-supplied partition when given one", async () => { + await manager.open({ url: "https://service.test", partition: "fixed" }); + expect(fromPartition.mock.calls[0][0]).toBe("fixed"); + }); + it("rejects non-web destinations before creating a session", async () => { for (const url of [ "file:///etc/passwd", "javascript:alert(1)", "https://user:secret@service.test", ]) { - await expect(manager.open(event(), { url })).rejects.toThrow(); + await expect(manager.open({ url })).rejects.toThrow(); } expect(fromPartition).not.toHaveBeenCalled(); }); + it("refuses when there is no window to attach to", async () => { + manager = createIsolatedWindows({ + BrowserWindow: FakeWindow, + session: { fromPartition }, + getMainWindow: () => null, + }); + await expect(manager.open({ url: "https://service.test" })).rejects.toThrow( + /No window/, + ); + expect(fromPartition).not.toHaveBeenCalled(); + }); it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => { - await manager.open(event(), { url: "https://service.test" }); + await manager.open({ url: "https://service.test" }); const win = created[0], open = win.webContents.setWindowOpenHandler.mock.calls[0][0]; expect( @@ -121,7 +124,7 @@ describe("isolated endpoint windows", () => { expect(callback).toHaveBeenCalledWith({ cancel: true }); }); it("limits invalid certificates to the opted-in origin, including its port", async () => { - await manager.open(event(), { + await manager.open({ url: "https://service.test:8443", ignoreCert: true, }); @@ -153,7 +156,7 @@ describe("isolated endpoint windows", () => { ).toBe(false); }); it("closes login popups and clears session data when the endpoint closes", async () => { - await manager.open(event(), { url: "https://service.test" }); + await manager.open({ url: "https://service.test" }); const root = created[0], popup = new FakeWindow({}); root.webContents.emit("did-create-window", popup); diff --git a/src/backend/tests/plugins/ctx-desktop.test.ts b/src/backend/tests/plugins/ctx-desktop.test.ts new file mode 100644 index 000000000..54c3b5692 --- /dev/null +++ b/src/backend/tests/plugins/ctx-desktop.test.ts @@ -0,0 +1,123 @@ +/** + * ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and + * the call only reaches Electron's main process when the backend is actually + * running embedded in it. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); + +const bridge = vi.hoisted(() => ({ + available: false, + requestFromElectronMain: vi.fn(async () => ({ success: true as const })), +})); +vi.mock("../../utils/electron-ipc-bridge.js", () => ({ + isElectronIpcAvailable: () => bridge.available, + requestFromElectronMain: bridge.requestFromElectronMain, +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; + +function manifestFor(pluginId: string, capabilities: string[]): PluginManifest { + return { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities, + } as PluginManifest; +} + +function contextFor(pluginId: string, capabilities: string[]) { + const manifest = manifestFor(pluginId, capabilities); + const handle = createPluginHandle(pluginId, { activate: () => {} }); + return { ctx: createPluginContext(manifest, handle), handle }; +} + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + bridge.available = false; + bridge.requestFromElectronMain.mockClear(); +}); + +describe("ctx.desktop.openIsolatedWindow", () => { + it("refuses without desktop:window, before reaching Electron", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + + await expect( + ctx.desktop.openIsolatedWindow({ url: "https://example.test" }), + ).rejects.toThrow(/desktop:window/); + expect(bridge.requestFromElectronMain).not.toHaveBeenCalled(); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_desktop_open_isolated_window", + success: false, + }); + }); + + it("refuses when granted but not declared in the manifest", async () => { + grants.set("demo", ["desktop:window"]); + const { ctx } = contextFor("demo", []); + + await expect( + ctx.desktop.openIsolatedWindow({ url: "https://example.test" }), + ).rejects.toThrow(/desktop:window/); + }); + + it("refuses outside the desktop app even with the capability", async () => { + grants.set("demo", ["desktop:window"]); + bridge.available = false; + const { ctx } = contextFor("demo", ["desktop:window"]); + + await expect( + ctx.desktop.openIsolatedWindow({ url: "https://example.test" }), + ).rejects.toThrow(/desktop app/); + expect(bridge.requestFromElectronMain).not.toHaveBeenCalled(); + }); + + it("relays to Electron main and audits success", async () => { + grants.set("demo", ["desktop:window"]); + bridge.available = true; + const { ctx } = contextFor("demo", ["desktop:window"]); + + const request = { url: "https://example.test", title: "Example" }; + await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({ + success: true, + }); + + expect(bridge.requestFromElectronMain).toHaveBeenCalledWith( + "open-isolated-window", + request, + ); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_desktop_open_isolated_window", + success: true, + }); + }); +}); diff --git a/src/backend/tests/utils/electron-ipc-bridge.test.ts b/src/backend/tests/utils/electron-ipc-bridge.test.ts new file mode 100644 index 000000000..68bb94abc --- /dev/null +++ b/src/backend/tests/utils/electron-ipc-bridge.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + isElectronIpcAvailable, + requestFromElectronMain, +} from "../../utils/electron-ipc-bridge.js"; + +const originalEnv = process.env.ELECTRON_EMBEDDED; +const originalSend = process.send; + +beforeEach(() => { + process.env.ELECTRON_EMBEDDED = "true"; +}); + +afterEach(() => { + process.env.ELECTRON_EMBEDDED = originalEnv; + process.send = originalSend; +}); + +describe("isElectronIpcAvailable", () => { + it("is false outside the embedded desktop backend", () => { + delete process.env.ELECTRON_EMBEDDED; + process.send = (() => true) as typeof process.send; + expect(isElectronIpcAvailable()).toBe(false); + }); + + it("is false when there is no fork IPC channel", () => { + process.send = undefined; + expect(isElectronIpcAvailable()).toBe(false); + }); + + it("is true when embedded with a live channel", () => { + process.send = (() => true) as typeof process.send; + expect(isElectronIpcAvailable()).toBe(true); + }); +}); + +describe("requestFromElectronMain", () => { + it("refuses when Electron IPC is unavailable", async () => { + process.send = undefined; + await expect( + requestFromElectronMain("open-isolated-window", {}), + ).rejects.toThrow(/desktop app/); + }); + + // The module attaches its "message" listener to the real process.on only + // once, the first time requestFromElectronMain actually reaches that point + // (ensureListening's module-scoped flag mirrors how a real forked + // process's IPC channel is a single persistent thing it listens on for the + // process lifetime). vi.spyOn(process, "on") does not reliably intercept + // that call in this environment, so this replaces process.on directly for + // the one call the module makes and hands the captured listener a plain + // function this file can invoke to simulate a message from main - + // simpler and more direct than getting a spy to survive across a Node + // global. + let deliver: (msg: unknown) => void = () => {}; + + beforeAll(() => { + const originalOn = process.on.bind(process); + process.on = ((event: string, listener: (...args: unknown[]) => void) => { + if (event === "message") { + deliver = listener; + return process; + } + return originalOn(event, listener); + }) as typeof process.on; + }); + + let sent: unknown[] = []; + + beforeEach(() => { + sent = []; + process.send = ((msg: unknown) => { + sent.push(msg); + return true; + }) as typeof process.send; + }); + + it("resolves with main's response, matched by request id", async () => { + const pending = requestFromElectronMain<{ success: true }>( + "open-isolated-window", + { url: "https://example.test" }, + ); + const [message] = sent as Array<{ + type: string; + id: string; + channel: string; + payload: unknown; + }>; + expect(message).toMatchObject({ + type: "backend-request", + channel: "open-isolated-window", + payload: { url: "https://example.test" }, + }); + + deliver({ + type: "backend-response", + id: message.id, + ok: true, + result: { success: true }, + }); + await expect(pending).resolves.toEqual({ success: true }); + }); + + it("rejects with main's error", async () => { + const pending = requestFromElectronMain("open-isolated-window", {}); + const [message] = sent as Array<{ id: string }>; + + deliver({ + type: "backend-response", + id: message.id, + ok: false, + error: "refused", + }); + await expect(pending).rejects.toThrow(/refused/); + }); + + it("ignores a response for a different request id", async () => { + const pending = requestFromElectronMain("open-isolated-window", {}); + const [message] = sent as Array<{ id: string }>; + + deliver({ type: "backend-response", id: "other", ok: true, result: {} }); + deliver({ + type: "backend-response", + id: message.id, + ok: true, + result: { success: true }, + }); + await expect(pending).resolves.toEqual({ success: true }); + }); +}); diff --git a/src/backend/tests/utils/web-endpoint-settings-migration.test.ts b/src/backend/tests/utils/web-endpoint-settings-migration.test.ts new file mode 100644 index 000000000..898d735d3 --- /dev/null +++ b/src/backend/tests/utils/web-endpoint-settings-migration.test.ts @@ -0,0 +1,184 @@ +/** + * The web endpoint host-columns migration. + * + * An upgrade must be lossless: a host with web endpoints on and a saved + * config must keep both once the ssh_data columns are dropped. Running it + * twice must not duplicate or clobber what it moved. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface HostRow { + id: number; + enable_web_ui: boolean; + web_ui_config: string | null; +} + +interface SettingsRow { + pluginId: string; + scope: string; + scopeId: string | null; + key: string; + value: string | null; +} + +const hostRows: HostRow[] = []; +const settingsRows: SettingsRow[] = []; + +const find = ( + pluginId: string, + scope: string, + scopeId: string | null, + key: string, +) => + settingsRows.find( + (row) => + row.pluginId === pluginId && + row.scope === scope && + row.scopeId === scopeId && + row.key === key, + ); + +const pluginSettingsRepository = { + get: async ( + pluginId: string, + scope: string, + scopeId: string | null, + key: string, + ) => find(pluginId, scope, scopeId, key) ?? null, + set: async ( + pluginId: string, + scope: string, + scopeId: string | null, + key: string, + value: string | null, + ) => { + const existing = find(pluginId, scope, scopeId, key); + if (existing) { + existing.value = value; + return; + } + settingsRows.push({ pluginId, scope, scopeId, key, value }); + }, +}; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginSettingsRepository: () => pluginSettingsRepository, +})); + +vi.mock("../../database/db/index.js", () => ({ + getDb: () => ({ + all: async () => + hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null), + }), +})); + +const { runWebEndpointSettingsMigration } = + await import("../../utils/crypto-migration/web-endpoint-settings-migration.js"); + +function storedValue(hostId: number, key: string): unknown { + const row = settingsRows.find( + (entry) => entry.scopeId === String(hostId) && entry.key === key, + ); + return row?.value === null || row?.value === undefined + ? undefined + : JSON.parse(row.value); +} + +const endpoint = { + id: "e1", + label: "Proxmox", + scheme: "https", + port: 8006, + path: "/", + access: "direct", + render: "embedded", +}; + +beforeEach(() => { + hostRows.length = 0; + settingsRows.length = 0; +}); + +describe("runWebEndpointSettingsMigration", () => { + it("moves a host's switch and saved config into plugin settings", async () => { + hostRows.push({ + id: 1, + enable_web_ui: true, + web_ui_config: JSON.stringify({ endpoints: [endpoint] }), + }); + + const result = await runWebEndpointSettingsMigration(); + + expect(result.moved).toBe(1); + expect(storedValue(1, "enableWebUi")).toBe(true); + expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] }); + expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe( + true, + ); + expect(settingsRows.every((row) => row.scope === "host")).toBe(true); + }); + + it("keeps a disabled host's saved config and its disabled switch", async () => { + hostRows.push({ + id: 3, + enable_web_ui: false, + web_ui_config: JSON.stringify({ endpoints: [endpoint] }), + }); + + await runWebEndpointSettingsMigration(); + + expect(storedValue(3, "enableWebUi")).toBe(false); + expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] }); + }); + + it("stores an unreadable config as null rather than failing", async () => { + hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" }); + + const result = await runWebEndpointSettingsMigration(); + + expect(result.moved).toBe(1); + expect(storedValue(4, "webUiConfig")).toBeNull(); + }); + + it("does nothing on a fresh install", async () => { + const result = await runWebEndpointSettingsMigration(); + + expect(result.moved).toBe(0); + expect(settingsRows).toEqual([]); + }); + + it("is idempotent: a second run changes nothing", async () => { + hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null }); + + await runWebEndpointSettingsMigration(); + const afterFirst = JSON.parse(JSON.stringify(settingsRows)); + + const second = await runWebEndpointSettingsMigration(); + + expect(second.moved).toBe(0); + expect(second.skipped).toBe(1); + expect(settingsRows).toEqual(afterFirst); + }); + + it("does not overwrite a value the plugin already saved", async () => { + hostRows.push({ + id: 5, + enable_web_ui: true, + web_ui_config: JSON.stringify({ endpoints: [endpoint] }), + }); + await pluginSettingsRepository.set( + "web-endpoint", + "host", + "5", + "enableWebUi", + "false", + ); + + const result = await runWebEndpointSettingsMigration(); + + expect(result.skipped).toBe(1); + expect(storedValue(5, "enableWebUi")).toBe(false); + expect(storedValue(5, "webUiConfig")).toBeUndefined(); + }); +}); diff --git a/src/backend/utils/crypto-migration/web-endpoint-settings-migration.ts b/src/backend/utils/crypto-migration/web-endpoint-settings-migration.ts new file mode 100644 index 000000000..805c188f7 --- /dev/null +++ b/src/backend/utils/crypto-migration/web-endpoint-settings-migration.ts @@ -0,0 +1,99 @@ +/** + * Moves the two web endpoint host columns into the web-endpoint plugin's + * host-scope settings, before those columns are dropped from ssh_data. + * + * Idempotent: skips a host that already has a web-endpoint settings row, so + * it is safe to run on every boot. Lossless: reads straight off the + * still-present ssh_data columns, so it must run and finish before those + * columns are ever dropped from schema.ts. + */ + +import { sql } from "drizzle-orm"; +import { databaseLogger } from "../logger.js"; +import { getDb } from "../../database/db/index.js"; +import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js"; + +export interface WebEndpointSettingsMigrationResult { + moved: number; + skipped: number; +} + +interface LegacyWebEndpointRow { + id: number; + enable_web_ui: number | boolean | null; + web_ui_config: string | null; +} + +function parseConfig(raw: string | null): unknown { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +export async function runWebEndpointSettingsMigration(): Promise { + const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 }; + + try { + const drizzleDb = getDb(); + // Raw SQL, not the typed schema, so this keeps working once schema.ts + // stops declaring these columns. + const rows = await drizzleDb.all(sql` + SELECT id, enable_web_ui, web_ui_config + FROM ssh_data + WHERE enable_web_ui = true OR web_ui_config IS NOT NULL + `); + + if (rows.length === 0) return result; + + const pluginSettingsRepository = createCurrentPluginSettingsRepository(); + + for (const row of rows) { + const scopeId = String(row.id); + const existing = await pluginSettingsRepository.get( + "web-endpoint", + "host", + scopeId, + "enableWebUi", + ); + if (existing && existing.value !== null) { + result.skipped++; + continue; + } + + await pluginSettingsRepository.set( + "web-endpoint", + "host", + scopeId, + "enableWebUi", + JSON.stringify(!!row.enable_web_ui), + ); + await pluginSettingsRepository.set( + "web-endpoint", + "host", + scopeId, + "webUiConfig", + JSON.stringify(parseConfig(row.web_ui_config)), + ); + result.moved++; + } + + if (result.moved > 0) { + databaseLogger.info( + `Moved web endpoint settings for ${result.moved} host(s) into plugin settings`, + { operation: "web_endpoint_settings_migration", moved: result.moved }, + ); + } + } catch (error) { + // A failed migration must not stop the backend. Hosts simply keep + // whatever web endpoint settings they already had (none, on a fresh run). + databaseLogger.warn("Web endpoint settings migration failed", { + operation: "web_endpoint_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + } + + return result; +} diff --git a/src/backend/utils/electron-ipc-bridge.ts b/src/backend/utils/electron-ipc-bridge.ts new file mode 100644 index 000000000..916b566ec --- /dev/null +++ b/src/backend/utils/electron-ipc-bridge.ts @@ -0,0 +1,123 @@ +/** + * Request/response layer over the fork IPC channel between the embedded + * backend and Electron's main process (electron/main.cjs forks this backend + * with stdio: [..., "ipc"]). Main already sends a one-way "shutdown" message + * on this channel; this adds a matching request/response shape so the + * backend can ask main to do something only main can do - open a real + * BrowserWindow - and get a typed result back. + * + * Not available outside Electron: process.send is undefined for a plain + * `node` or Docker start, and ELECTRON_EMBEDDED distinguishes the embedded + * backend from a standalone one that happens to run under a process manager + * that also sets stdio to "ipc". + */ + +import crypto from "node:crypto"; +import { systemLogger } from "./logger.js"; + +const REQUEST_TIMEOUT_MS = 15_000; + +interface ElectronIpcRequestMessage { + type: "backend-request"; + id: string; + channel: string; + payload: unknown; +} + +interface ElectronIpcResponseMessage { + type: "backend-response"; + id: string; + ok: boolean; + result?: unknown; + error?: string; +} + +function isResponseMessage(msg: unknown): msg is ElectronIpcResponseMessage { + return ( + !!msg && + typeof msg === "object" && + (msg as { type?: unknown }).type === "backend-response" + ); +} + +export function isElectronIpcAvailable(): boolean { + return ( + process.env.ELECTRON_EMBEDDED === "true" && + typeof process.send === "function" + ); +} + +const pending = new Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } +>(); + +let listening = false; + +function ensureListening(): void { + if (listening) return; + listening = true; + process.on("message", (msg: unknown) => { + if (!isResponseMessage(msg)) return; + const waiter = pending.get(msg.id); + if (!waiter) return; + pending.delete(msg.id); + if (msg.ok) waiter.resolve(msg.result); + else waiter.reject(new Error(msg.error || "Electron IPC request failed")); + }); +} + +/** + * Sends a request to Electron's main process over the fork IPC channel and + * waits for its response. Rejects if main never answers (main.cjs is where + * a killed or hung renderer would surface, not here) or process.send is + * unavailable. + */ +export async function requestFromElectronMain( + channel: string, + payload: unknown, +): Promise { + if (!isElectronIpcAvailable()) { + throw new Error("Electron IPC is only available in the desktop app"); + } + ensureListening(); + + const id = crypto.randomUUID(); + const message: ElectronIpcRequestMessage = { + type: "backend-request", + id, + channel, + payload, + }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Electron IPC request "${channel}" timed out`)); + }, REQUEST_TIMEOUT_MS); + + pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + + try { + process.send?.(message); + } catch (error) { + clearTimeout(timer); + pending.delete(id); + systemLogger.warn("Failed to send Electron IPC request", { + operation: "electron_ipc_send_failed", + channel, + error: error instanceof Error ? error.message : String(error), + }); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 1dd9ec6c7..d0a75a0df 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -1,5 +1,5 @@ import type { GuacamoleConfig } from "./guacamole-config.js"; -import type { TerminalConfig, WebUiConfig } from "./index.js"; +import type { TerminalConfig } from "./index.js"; import type { StatsConfig } from "./stats-widgets.js"; import type { HostAuthOverrides } from "./auth-protocols.js"; @@ -108,7 +108,6 @@ export type Host = { runtime?: "docker" | "podman"; } | null; enableWebUi?: boolean; - webUiConfig?: WebUiConfig | null; enableProxmox: boolean; enableTmuxMonitor: boolean; enableTerminalToolbar: boolean; diff --git a/src/ui/lib/host-to-ssh-host.ts b/src/ui/lib/host-to-ssh-host.ts index 6b73264dc..ae5edf273 100644 --- a/src/ui/lib/host-to-ssh-host.ts +++ b/src/ui/lib/host-to-ssh-host.ts @@ -31,7 +31,8 @@ export function hostToSSHHost(h: Host): SSHHost { enableAiAssistant: h.enableAiAssistant ?? false, dockerConfig: h.dockerConfig ?? null, enableWebUi: h.enableWebUi ?? false, - webUiConfig: h.webUiConfig ?? { endpoints: [] }, + webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as + SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] }, showTerminalInSidebar: true, showFileManagerInSidebar: true, showTunnelInSidebar: true, diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 4ce1db8e7..cdfd240c7 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -195,8 +195,6 @@ export function createHostEditorForm( enableFileManager: host?.enableFileManager ?? false, scpLegacy: host?.scpLegacy ?? false, enableDocker: host?.enableDocker ?? false, - enableWebUi: host?.enableWebUi ?? false, - webUiConfig: host?.webUiConfig ?? { endpoints: [] }, dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const }, enableTmuxMonitor: host?.enableTmuxMonitor ?? false, enableTerminalToolbar: host?.enableTerminalToolbar ?? true, @@ -508,8 +506,6 @@ export function buildHostEditorPayload( scpLegacy: form.scpLegacy, enableDocker: form.enableDocker, dockerConfig: form.enableDocker ? form.dockerConfig : null, - enableWebUi: form.enableWebUi, - webUiConfig: form.enableWebUi ? form.webUiConfig : null, enableTmuxMonitor: form.enableTmuxMonitor, enableTerminalToolbar: form.enableTerminalToolbar, enableAiAssistant: form.enableAiAssistant, diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 0e4613de6..01b1febd8 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -98,7 +98,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { enableDocker: h.enableDocker ?? false, dockerConfig: h.dockerConfig ?? null, enableWebUi: h.enableWebUi ?? false, - webUiConfig: h.webUiConfig ?? { endpoints: [] }, enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false, enableProxmoxStats: (proxmoxSettings.enableProxmoxStats as boolean) ?? false,