mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -161,7 +161,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { randomUUID } = require("node:crypto");
|
const { randomUUID } = require("node:crypto");
|
||||||
|
|
||||||
function webUrl(value) {
|
function isolatedUrl(value) {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
if (
|
if (
|
||||||
!["http:", "https:"].includes(url.protocol) ||
|
!["http:", "https:"].includes(url.protocol) ||
|
||||||
@@ -8,27 +8,35 @@ function webUrl(value) {
|
|||||||
url.password
|
url.password
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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;
|
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();
|
const certificates = new WeakMap();
|
||||||
async function open(event, options = {}) {
|
async function open(options = {}) {
|
||||||
const main = getMainWindow();
|
const main = getMainWindow();
|
||||||
if (
|
if (!main) {
|
||||||
!main ||
|
throw new Error("No window to attach an isolated window to");
|
||||||
event.sender !== main.webContents ||
|
|
||||||
event.senderFrame !== main.webContents.mainFrame
|
|
||||||
) {
|
|
||||||
throw new Error("Only the main Termix window can open web endpoints");
|
|
||||||
}
|
}
|
||||||
const url = webUrl(options.url);
|
const url = isolatedUrl(options.url);
|
||||||
const isolated = session.fromPartition(`web-endpoint-${randomUUID()}`, {
|
const isolated = session.fromPartition(
|
||||||
cache: false,
|
options.partition || `isolated-window-${randomUUID()}`,
|
||||||
});
|
{ cache: false },
|
||||||
|
);
|
||||||
isolated.setPermissionRequestHandler((_contents, _permission, callback) =>
|
isolated.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||||
callback(false),
|
callback(false),
|
||||||
);
|
);
|
||||||
@@ -57,7 +65,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
|
|||||||
win.setMenu(null);
|
win.setMenu(null);
|
||||||
const canNavigate = (target) => {
|
const canNavigate = (target) => {
|
||||||
try {
|
try {
|
||||||
return target === "about:blank" || !!webUrl(target);
|
return target === "about:blank" || !!isolatedUrl(target);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -90,7 +98,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 800,
|
height: 800,
|
||||||
title: `Web endpoint — ${url.hostname}`,
|
title: options.title || url.hostname,
|
||||||
webPreferences: preferences,
|
webPreferences: preferences,
|
||||||
});
|
});
|
||||||
configure(win);
|
configure(win);
|
||||||
@@ -125,4 +133,4 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
|
|||||||
}
|
}
|
||||||
return { open, handleCertificateError };
|
return { open, handleCertificateError };
|
||||||
}
|
}
|
||||||
module.exports = { createWebEndpointWindows, webUrl };
|
module.exports = { createIsolatedWindows, isolatedUrl };
|
||||||
+40
-11
@@ -633,15 +633,45 @@ if (isInsecureModeEnabled()) {
|
|||||||
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||||
|
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
const { createWebEndpointWindows } = require("./web-endpoint-window.cjs");
|
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
||||||
const webEndpointWindows = createWebEndpointWindows({
|
const isolatedWindows = createIsolatedWindows({
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
session,
|
session,
|
||||||
getMainWindow: () => mainWindow,
|
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 backendProcess = null;
|
||||||
let backendStartFailed = false;
|
let backendStartFailed = false;
|
||||||
// Why the embedded backend died, once it has. Null while it is healthy
|
// Why the embedded backend died, once it has. Null while it is healthy
|
||||||
@@ -826,12 +856,7 @@ app.on(
|
|||||||
"certificate-error",
|
"certificate-error",
|
||||||
(event, _webContents, url, error, certificate, callback) => {
|
(event, _webContents, url, error, certificate, callback) => {
|
||||||
if (
|
if (
|
||||||
webEndpointWindows.handleCertificateError(
|
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
||||||
event,
|
|
||||||
_webContents,
|
|
||||||
url,
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (isWebEndpointCertificateAllowed(url)) {
|
if (isWebEndpointCertificateAllowed(url)) {
|
||||||
@@ -1155,6 +1180,10 @@ function startBackendServer() {
|
|||||||
logToFile("[backend:stderr]", chunk.trim());
|
logToFile("[backend:stderr]", chunk.trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
backendProcess.on("message", (msg) => {
|
||||||
|
void handleBackendRequest(msg);
|
||||||
|
});
|
||||||
|
|
||||||
backendProcess.on("exit", (code, signal) => {
|
backendProcess.on("exit", (code, signal) => {
|
||||||
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
||||||
if (!resolved && code !== 0) {
|
if (!resolved && code !== 0) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const ALLOWED_INVOKE_CHANNELS = new Set([
|
|||||||
"save-remote-sync-jwt",
|
"save-remote-sync-jwt",
|
||||||
"test-server-connection",
|
"test-server-connection",
|
||||||
"allow-invalid-certificate-for-origin",
|
"allow-invalid-certificate-for-origin",
|
||||||
"open-isolated-web-endpoint",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function invokeAllowed(channel, ...args) {
|
function invokeAllowed(channel, ...args) {
|
||||||
|
|||||||
@@ -511,6 +511,20 @@ to poll the plugin's state. A plugin can also listen for another plugin's
|
|||||||
`plugin.<id>.*` events without any capability: automations turns
|
`plugin.<id>.*` events without any capability: automations turns
|
||||||
`plugin.tunnels.tunnel_disconnected` into its `tunnel_disconnected` trigger.
|
`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("<pluginId>.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
|
||||||
|
`"<id>.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
|
### 10. Lifecycle
|
||||||
|
|
||||||
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
|
`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
|
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
|
||||||
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
||||||
`() => express.Router()`, so routes are served for real), `permissions`
|
`() => express.Router()`, so routes are served for real), `permissions`
|
||||||
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything),
|
(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
|
and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess`
|
||||||
plugin provided. `plugins/workspaces/tests/backend/helpers.ts` is the worked
|
serve - **B8** added this option, since it existed on `createFakeContext`
|
||||||
example.
|
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()`),
|
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
|
||||||
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
|
`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.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||||
| `ctx.notify.*` | `notify:send` | A6 |
|
| `ctx.notify.*` | `notify:send` | A6 |
|
||||||
| `ctx.auth.*` | `auth:provide` | **A8** |
|
| `ctx.auth.*` | `auth:provide` | **A8** |
|
||||||
|
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||||
| `ctx.fetch` | `network:outbound` | B |
|
| `ctx.fetch` | `network:outbound` | B |
|
||||||
|
|
||||||
`ctx.ssh` was pulled forward from B so plugin transports go through core's
|
`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
|
token): a plugin that creates a host picks an `authType` and, for
|
||||||
`"credential"`, a `credentialId` it does not need to see the contents of.
|
`"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
|
### The actor
|
||||||
|
|
||||||
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
|
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 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin code | **Error** | 0 | - |
|
| The shell importing plugin code | **Error** | 0 | - |
|
||||||
| A plugin frontend importing core through `@/` | Warning | 138 files | D1 |
|
| A plugin frontend importing core through `@/` | Warning | 135 files | D1 |
|
||||||
| A plugin importing core by relative path | Warning | 68 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 plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||||
|
|
||||||
A warning does not fail a build, so the counts are held by
|
A warning does not fail a build, so the counts are held by
|
||||||
|
|||||||
@@ -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
|
remote-origin plugin API client. `subscribeTunnelStatuses` already takes a
|
||||||
`fetchRemote` and merges with local statuses winning, so it only needs 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.
|
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("<id>.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.
|
||||||
@@ -964,6 +964,28 @@ export interface PluginAuth {
|
|||||||
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface PluginContext {
|
||||||
readonly pluginId: string;
|
readonly pluginId: string;
|
||||||
readonly manifest: PluginManifest;
|
readonly manifest: PluginManifest;
|
||||||
@@ -986,6 +1008,8 @@ export interface PluginContext {
|
|||||||
readonly ssh: PluginSsh;
|
readonly ssh: PluginSsh;
|
||||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||||
readonly auth: PluginAuth;
|
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
|
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export interface FakePluginContext {
|
|||||||
}>;
|
}>;
|
||||||
/** Everything registered through ctx.auth. */
|
/** Everything registered through ctx.auth. */
|
||||||
auth: FakeAuthRegistrations;
|
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. */
|
/** Changes the acting user, as core's request middleware would. */
|
||||||
setActor: (userId: string | undefined) => void;
|
setActor: (userId: string | undefined) => void;
|
||||||
/** Implementations provided through ctx.services.provide, by service name. */
|
/** Implementations provided through ctx.services.provide, by service name. */
|
||||||
@@ -179,12 +186,14 @@ export function createFakeContext(
|
|||||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||||
const hostShares: FakePluginContext["hostShares"] = [];
|
const hostShares: FakePluginContext["hostShares"] = [];
|
||||||
|
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||||
const hostsById = new Map<number, PluginHostSummary>(
|
const hostsById = new Map<number, PluginHostSummary>(
|
||||||
(options.hosts ?? []).map((h) => [h.id, h]),
|
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||||
);
|
);
|
||||||
const hostRecordsById = new Map<number, PluginHostRecord>();
|
const hostRecordsById = new Map<number, PluginHostRecord>();
|
||||||
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
|
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
|
||||||
const services = new Map<string, object>();
|
const services = new Map<string, object>();
|
||||||
|
const registryProviders = new Map<string, unknown>();
|
||||||
const auth: FakeAuthRegistrations = {
|
const auth: FakeAuthRegistrations = {
|
||||||
sshAuthProviders: [],
|
sshAuthProviders: [],
|
||||||
loginMethods: [],
|
loginMethods: [],
|
||||||
@@ -308,9 +317,17 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
|
|
||||||
registry: {
|
registry: {
|
||||||
provide: () => {},
|
provide: (key, value) => {
|
||||||
consume: () => undefined,
|
registryProviders.set(key, value);
|
||||||
revoke: () => false,
|
},
|
||||||
|
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: {
|
services: {
|
||||||
@@ -533,6 +550,13 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
desktop: {
|
||||||
|
openIsolatedWindow: async (request) => {
|
||||||
|
desktopWindows.push(request);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
asUser: async (userId, fn) => {
|
asUser: async (userId, fn) => {
|
||||||
const previous = actor;
|
const previous = actor;
|
||||||
actor = userId;
|
actor = userId;
|
||||||
@@ -561,6 +585,7 @@ export function createFakeContext(
|
|||||||
sshConnections,
|
sshConnections,
|
||||||
hostShares,
|
hostShares,
|
||||||
auth,
|
auth,
|
||||||
|
desktopWindows,
|
||||||
setActor: (userId) => {
|
setActor: (userId) => {
|
||||||
actor = userId;
|
actor = userId;
|
||||||
},
|
},
|
||||||
@@ -589,6 +614,8 @@ export interface MockContextOptions {
|
|||||||
router?: () => unknown;
|
router?: () => unknown;
|
||||||
/** Role permissions the acting user holds. See FakeContextOptions. */
|
/** Role permissions the acting user holds. See FakeContextOptions. */
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
|
||||||
|
hosts?: PluginHostSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockPluginContext extends FakePluginContext {
|
export interface MockPluginContext extends FakePluginContext {
|
||||||
@@ -622,6 +649,7 @@ export function createMockCtx(
|
|||||||
db: options.db,
|
db: options.db,
|
||||||
router: options.router,
|
router: options.router,
|
||||||
permissions: options.permissions,
|
permissions: options.permissions,
|
||||||
|
hosts: options.hosts,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
...options.manifest,
|
...options.manifest,
|
||||||
@@ -819,6 +847,13 @@ export function createMockCtx(
|
|||||||
return ctx.auth.removeEnrollment(userId, factorId);
|
return ctx.auth.removeEnrollment(userId, factorId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
desktop: {
|
||||||
|
openIsolatedWindow: async (request) => {
|
||||||
|
require("desktop:window");
|
||||||
|
return ctx.desktop.openIsolatedWindow(request);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...base, ctx: gatedCtx, checked };
|
return { ...base, ctx: gatedCtx, checked };
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
"termix": ">=2.9.0",
|
"termix": ">=2.9.0",
|
||||||
"api": "1"
|
"api": "1"
|
||||||
},
|
},
|
||||||
"capabilities": ["hosts:read", "network:serve", "ui:surface"],
|
"capabilities": [
|
||||||
|
"hosts:read",
|
||||||
|
"network:serve",
|
||||||
|
"ui:surface",
|
||||||
|
"desktop:window"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tunnels": "^1.0.0"
|
"tunnels": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -33,6 +38,20 @@
|
|||||||
"openFrom": ["host-context-menu"]
|
"openFrom": ["host-context-menu"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"settings": {
|
||||||
|
"host": {
|
||||||
|
"enableKey": "enableWebUi",
|
||||||
|
"enableLabelKey": "hosts.enableWebUi",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "webUiConfig",
|
||||||
|
"type": "json",
|
||||||
|
"labelKey": "hosts.webUiEndpointsSection",
|
||||||
|
"requires": "enableWebUi"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
"key": "enableWebUi",
|
"key": "enableWebUi",
|
||||||
"labelKey": "hosts.tabWebUi",
|
"labelKey": "hosts.tabWebUi",
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (raw.enableWebUi === undefined && raw.webUiConfig === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enableWebUi: !!raw.enableWebUi,
|
||||||
|
webUiConfig: raw.webUiConfig ? serializeWebUiConfig(raw.webUiConfig) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
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) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startWebEndpointService(ctx.http.router(), ctx);
|
const router = ctx.http.router<Router>();
|
||||||
ctx.disposables.add(() => stopWebEndpointService());
|
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");
|
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {}
|
||||||
stopWebEndpointService();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import express, { type Router } from "express";
|
import express, { type Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
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. */
|
/** Matches the spec's ten minutes. */
|
||||||
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
@@ -31,83 +34,76 @@ export function webEndpointTunnelName(
|
|||||||
return `web:${hostId}:${endpointId}`;
|
return `web:${hostId}:${endpointId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current: PluginContext | null = null;
|
async function loadEndpoint(
|
||||||
|
ctx: PluginContext,
|
||||||
export async function handleWebEndpointOpen(
|
hostId: number,
|
||||||
req: express.Request,
|
endpointId: string,
|
||||||
res: express.Response,
|
): Promise<
|
||||||
): Promise<express.Response | void> {
|
{ error: { status: number; message: string } } | { endpoint: WebEndpoint }
|
||||||
const ctx = current;
|
> {
|
||||||
const userId = ctx?.currentActor();
|
const host = await ctx.hosts.get(hostId);
|
||||||
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);
|
|
||||||
if (!host) {
|
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
|
// A bulk update that only flips enableWebUi off can leave stale endpoints
|
||||||
// the config while the feature is off. The UI reads as off in that state,
|
// in webUiConfig. The UI reads as off in that state, so a listed endpoint
|
||||||
// so the endpoint being listed is not on its own a licence to open a tunnel.
|
// is not on its own a licence to open a tunnel.
|
||||||
if (!host.enableWebUi) {
|
const enabled = await ctx.settings.getHost<boolean>(hostId, "enableWebUi");
|
||||||
return res
|
if (!enabled) {
|
||||||
.status(400)
|
return {
|
||||||
.json({ error: "Web endpoints are not enabled for this host" });
|
error: {
|
||||||
|
status: 400,
|
||||||
|
message: "Web endpoints are not enabled for this host",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-normalized rather than trusted: the stored value predates any later
|
// 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.
|
// tightening of the rules, and this is the value a forward or a window is
|
||||||
const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find(
|
// built from.
|
||||||
|
const rawConfig = await ctx.settings.getHost(hostId, "webUiConfig");
|
||||||
|
const endpoint = parseWebUiConfig(rawConfig).endpoints.find(
|
||||||
(candidate) => candidate.id === endpointId,
|
(candidate) => candidate.id === endpointId,
|
||||||
);
|
);
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
return res.status(400).json({ error: "Web endpoint not found" });
|
return { error: { status: 400, message: "Web endpoint not found" } };
|
||||||
}
|
|
||||||
if (endpoint.access !== "tunnel") {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "This endpoint does not use a tunnel" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
let tunnels: TunnelsAccess;
|
||||||
try {
|
try {
|
||||||
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
|
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
|
||||||
} catch {
|
} catch {
|
||||||
return res
|
throw Object.assign(new Error("The tunnels plugin is not available"), {
|
||||||
.status(503)
|
status: 503,
|
||||||
.json({ error: "The tunnels plugin is not available" });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await tunnels.forward(
|
return await tunnels.forward(
|
||||||
hostId,
|
hostId,
|
||||||
{
|
{
|
||||||
targetHost: "127.0.0.1",
|
targetHost: "127.0.0.1",
|
||||||
targetPort: endpoint.port,
|
targetPort: endpoint.port,
|
||||||
// Loopback unless the endpoint asks otherwise. A non-loopback bind
|
// Loopback unless the endpoint asks otherwise. A non-loopback bind
|
||||||
// publishes the target's web UI to anyone who can reach the port, with
|
// publishes the target's web UI to anyone who can reach the port,
|
||||||
// no authentication in front of it, and is only ever an explicit
|
// with no authentication in front of it, and is only ever an
|
||||||
// per-endpoint choice.
|
// explicit per-endpoint choice.
|
||||||
bindHost: endpoint.bindHost || "127.0.0.1",
|
bindHost: endpoint.bindHost || "127.0.0.1",
|
||||||
// A fixed port when the endpoint names one, since a container can only
|
// A fixed port when the endpoint names one, since a container can
|
||||||
// publish ports it knows in advance. Otherwise the kernel picks.
|
// only publish ports it knows in advance. Otherwise the kernel picks.
|
||||||
bindPort: endpoint.localPort ?? undefined,
|
bindPort: endpoint.localPort ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,33 +111,141 @@ export async function handleWebEndpointOpen(
|
|||||||
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
|
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return res.status(200).json({ port: handle.bindPort });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error);
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
ctx.log.error(
|
ctx.log.error(
|
||||||
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
|
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
|
||||||
error instanceof Error ? error : undefined,
|
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;
|
function endpointUrl(
|
||||||
|
hostAddress: string,
|
||||||
export function startWebEndpointService(
|
endpoint: WebEndpoint,
|
||||||
mountOn: Router,
|
localPort?: number,
|
||||||
ctx: PluginContext,
|
): string {
|
||||||
): void {
|
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
|
||||||
current = ctx;
|
if (endpoint.access === "tunnel") {
|
||||||
if (!started) {
|
// Electron's isolated window always dials the backend's own loopback,
|
||||||
router.post("/open", handleWebEndpointOpen);
|
// exactly like the renderer's own currentTunnelHost(true) does.
|
||||||
started = true;
|
return `${endpoint.scheme}://127.0.0.1:${localPort}${path}`;
|
||||||
}
|
}
|
||||||
mountOn.use(router);
|
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopWebEndpointService(): void {
|
export function createWebEndpointRoutes(ctx: PluginContext): Router {
|
||||||
current = null;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|
||||||
import { isElectron } from "@/lib/electron";
|
|
||||||
import {
|
import {
|
||||||
webEndpointRefusalReason,
|
useTranslation,
|
||||||
type WebEndpointRefusalReason,
|
type HostEditorSectionProps,
|
||||||
} from "./web-endpoint-url";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { Button } from "@/components/button";
|
|
||||||
import { Input } from "@/components/input";
|
|
||||||
import { Checkbox } from "@/components/checkbox";
|
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FakeSwitch,
|
||||||
|
Input,
|
||||||
|
SectionCard,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/select";
|
SettingRow,
|
||||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
} from "@termix/plugin-sdk/ui";
|
||||||
|
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
import {
|
import {
|
||||||
MAX_WEB_ENDPOINTS,
|
MAX_WEB_ENDPOINTS,
|
||||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
||||||
type WebEndpoint,
|
type WebEndpoint,
|
||||||
type WebUiConfig,
|
} from "../shared/web-endpoint-config";
|
||||||
} from "@/types/index";
|
import {
|
||||||
|
webEndpointRefusalReason,
|
||||||
|
type WebEndpointRefusalReason,
|
||||||
|
} from "./web-endpoint-url";
|
||||||
import {
|
import {
|
||||||
MAX_WEB_ENDPOINT_PORT,
|
MAX_WEB_ENDPOINT_PORT,
|
||||||
MIN_WEB_ENDPOINT_PORT,
|
MIN_WEB_ENDPOINT_PORT,
|
||||||
@@ -30,11 +36,12 @@ import {
|
|||||||
webEndpointRowError,
|
webEndpointRowError,
|
||||||
} from "./web-endpoint-validation";
|
} from "./web-endpoint-validation";
|
||||||
|
|
||||||
|
type PluginSettingsForm = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
||||||
* deployment -- a first-class target for the direct+external path -- would
|
* deployment -- a first-class target for the direct+external path -- would
|
||||||
* throw on "Add endpoint". Matches the guarded form already used in
|
* throw on "Add endpoint".
|
||||||
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
|
|
||||||
*/
|
*/
|
||||||
function endpointId(): string {
|
function endpointId(): string {
|
||||||
return typeof crypto.randomUUID === "function"
|
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({
|
export function HostEditorWebUiSection({
|
||||||
enableWebUi,
|
form,
|
||||||
webUiConfig,
|
updateForm,
|
||||||
tunnelAvailable,
|
protocols,
|
||||||
setField,
|
}: HostEditorSectionProps) {
|
||||||
}: {
|
|
||||||
enableWebUi: boolean;
|
|
||||||
webUiConfig: WebUiConfig;
|
|
||||||
tunnelAvailable: boolean;
|
|
||||||
setField: (field: string, value: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
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<string, unknown>;
|
||||||
|
const enableWebUi = settings.enableWebUi === true;
|
||||||
|
const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as {
|
||||||
|
endpoints: WebEndpoint[];
|
||||||
|
};
|
||||||
|
const endpoints = webUiConfig.endpoints ?? [];
|
||||||
|
|
||||||
|
const setSettings = (patch: Record<string, unknown>) =>
|
||||||
|
updateForm((current) => {
|
||||||
|
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
pluginSettings: {
|
||||||
|
...all,
|
||||||
|
"web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const commit = (next: WebEndpoint[]) =>
|
const commit = (next: WebEndpoint[]) =>
|
||||||
setField("webUiConfig", { endpoints: next });
|
setSettings({ webUiConfig: { endpoints: next } });
|
||||||
|
|
||||||
const update = (index: number, patch: Partial<WebEndpoint>) =>
|
const update = (index: number, patch: Partial<WebEndpoint>) =>
|
||||||
commit(
|
commit(
|
||||||
@@ -135,7 +194,7 @@ export function HostEditorWebUiSection({
|
|||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
checked={enableWebUi}
|
checked={enableWebUi}
|
||||||
onChange={(v: boolean) => setField("enableWebUi", v)}
|
onChange={(v: boolean) => setSettings({ enableWebUi: v })}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
||||||
|
|||||||
@@ -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 = <K extends keyof HostEditorForm>(
|
|
||||||
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 (
|
|
||||||
<HostEditorWebUiSection
|
|
||||||
enableWebUi={form.enableWebUi}
|
|
||||||
webUiConfig={form.webUiConfig}
|
|
||||||
tunnelAvailable={protocols.enableSsh && originIsLocal}
|
|
||||||
setField={setField as (field: string, value: unknown) => void}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ import { copyToClipboard } from "@/lib/clipboard";
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
|
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||||
|
|
||||||
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
|
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
|
||||||
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
|
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
|
||||||
@@ -42,7 +43,10 @@ export function WebEndpointTab({
|
|||||||
endpointId?: string;
|
endpointId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
|
const webUiConfig = (
|
||||||
|
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||||
|
)?.["web-endpoint"]?.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||||
|
const endpoint = (webUiConfig?.endpoints ?? []).find(
|
||||||
(candidate) => candidate.id === endpointId,
|
(candidate) => candidate.id === endpointId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import type {
|
|||||||
TermixApp,
|
TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import type { Host } from "@/types/ui-types";
|
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 { WebEndpointTab } from "./WebEndpointTab";
|
||||||
import { HostWebUiTab } from "./HostWebUiTab";
|
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||||
import {
|
import {
|
||||||
openWebEndpointExternally,
|
openWebEndpointExternally,
|
||||||
setWebEndpointApi,
|
setWebEndpointApi,
|
||||||
@@ -19,10 +19,26 @@ import {
|
|||||||
|
|
||||||
const TAB_TYPE = "web-endpoint";
|
const TAB_TYPE = "web-endpoint";
|
||||||
|
|
||||||
|
function webEndpointSettings(host: PluginHostRecord): {
|
||||||
|
enableWebUi: boolean;
|
||||||
|
webUiConfig: { endpoints: WebEndpoint[] };
|
||||||
|
} {
|
||||||
|
const settings = (
|
||||||
|
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||||
|
)?.["web-endpoint"];
|
||||||
|
return {
|
||||||
|
enableWebUi: settings?.enableWebUi === true,
|
||||||
|
webUiConfig: (settings?.webUiConfig as
|
||||||
|
{ endpoints: WebEndpoint[] } | undefined) ?? {
|
||||||
|
endpoints: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
|
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
|
||||||
if (!host.enableWebUi) return [];
|
const { enableWebUi, webUiConfig } = webEndpointSettings(host);
|
||||||
const config = host.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
if (!enableWebUi) return [];
|
||||||
return config?.endpoints ?? [];
|
return webUiConfig.endpoints ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEndpoint(
|
function openEndpoint(
|
||||||
@@ -31,8 +47,8 @@ function openEndpoint(
|
|||||||
shell: ShellApi,
|
shell: ShellApi,
|
||||||
): void {
|
): void {
|
||||||
if (endpoint.render === "external") {
|
if (endpoint.render === "external") {
|
||||||
// No tab at all: the real browser opens it. On the desktop the main
|
// No tab at all: the real browser opens it. On the desktop the plugin
|
||||||
// process routes it to shell.openExternal.
|
// asks the backend to open it in an isolated window.
|
||||||
openWebEndpointExternally(
|
openWebEndpointExternally(
|
||||||
host as unknown as { id: string; ip: string },
|
host as unknown as { id: string; ip: string },
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -63,16 +79,8 @@ function EndpointTab({ host, tab }: TabProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
|
function WebUiSection(props: HostEditorSectionProps) {
|
||||||
return (
|
return <HostEditorWebUiSection {...props} />;
|
||||||
<HostWebUiTab
|
|
||||||
form={form}
|
|
||||||
setField={setField as Parameters<typeof HostWebUiTab>[0]["setField"]}
|
|
||||||
protocols={
|
|
||||||
protocols as unknown as Parameters<typeof HostWebUiTab>[0]["protocols"]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||||
import { handleApiError } from "@/main-axios";
|
import { handleApiError } from "@/main-axios";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import type { WebEndpoint } from "@/types/index";
|
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||||
|
|
||||||
let pluginApi: PluginApiClient | null = null;
|
let pluginApi: PluginApiClient | null = null;
|
||||||
|
|
||||||
@@ -109,35 +108,43 @@ export function requireNumericHostId(id: string): number {
|
|||||||
return numericId;
|
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(
|
export async function openWebEndpointExternally(
|
||||||
host: { id: string; ip: string },
|
host: { id: string; ip: string },
|
||||||
endpoint: WebEndpoint,
|
endpoint: WebEndpoint,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isElectron() || !window.electronAPI?.invoke) {
|
if (!isElectron()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
|
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const localPort =
|
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
|
||||||
endpoint.access === "tunnel"
|
try {
|
||||||
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
|
await pluginApi.post("/open-window", {
|
||||||
: undefined;
|
hostId: requireNumericHostId(host.id),
|
||||||
const url = resolveWebEndpointUrl({
|
endpointId: endpoint.id,
|
||||||
hostAddress: host.ip,
|
ignoreCert: endpoint.ignoreCert === true,
|
||||||
endpoint,
|
});
|
||||||
localPort,
|
} catch (error) {
|
||||||
tunnelHost: currentTunnelHost(true) ?? undefined,
|
if (axios.isAxiosError(error)) {
|
||||||
});
|
const data = error.response?.data as
|
||||||
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
|
{ error?: unknown; message?: unknown } | undefined;
|
||||||
url,
|
const backendMessage = data?.error ?? data?.message;
|
||||||
ignoreCert: endpoint.ignoreCert === true,
|
if (typeof backendMessage === "string" && backendMessage) {
|
||||||
});
|
throw new WebEndpointTunnelError(
|
||||||
if (
|
backendMessage,
|
||||||
!result ||
|
error.response?.status,
|
||||||
typeof result !== "object" ||
|
);
|
||||||
!("success" in result) ||
|
}
|
||||||
result.success !== true
|
}
|
||||||
)
|
return handleApiError(error, "open isolated web endpoint");
|
||||||
throw new Error("Failed to open isolated web endpoint");
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
* Builds the URL a web endpoint opens at.
|
||||||
|
|||||||
@@ -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.
|
* Editor-side validation for web endpoint rows.
|
||||||
*
|
*
|
||||||
* The authority on what a stored endpoint may look like is
|
* The authority on what a stored endpoint may look like is
|
||||||
* `normalizeWebEndpoints` in
|
* `normalizeWebEndpoints` in ../shared/web-endpoint-config.ts, and it DROPS
|
||||||
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
|
* any row it refuses rather than reporting it -- so without a check here the
|
||||||
* refuses rather than reporting it -- so without a check here the user adds a
|
* user adds a row, saves, sees no error, and finds the endpoint gone on
|
||||||
* row, saves, sees no error, and finds the endpoint gone on reload.
|
* reload.
|
||||||
*
|
*
|
||||||
* This deliberately does NOT restate that module's full rule set, and does not
|
* This deliberately does NOT restate that module's full rule set: it covers
|
||||||
* import it: the renderer does not import backend route modules (the same
|
* only the conditions a user can reach by typing, with per-field errors a
|
||||||
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
|
* drop/keep normalizer cannot give. tests/backend/web-endpoint-config.test.ts
|
||||||
* validator). It covers only the conditions a user can reach by typing.
|
* covers the normalizer; this file's own tests cover the same inputs here.
|
||||||
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
|
|
||||||
* over the same samples so the two cannot drift apart silently.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MIN_WEB_ENDPOINT_PORT = 1;
|
export const MIN_WEB_ENDPOINT_PORT = 1;
|
||||||
|
|||||||
+64
-12
@@ -1,10 +1,63 @@
|
|||||||
import {
|
/**
|
||||||
MAX_WEB_ENDPOINTS,
|
* The plugin's own copy of what used to be core's host-web-endpoints.ts,
|
||||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
* now that webUiConfig lives in this plugin's host-scope settings instead of
|
||||||
} from "../../../types/index.js";
|
* an ssh_data column. Same rules, same shape.
|
||||||
import type { WebEndpoint, WebUiConfig } from "../../../types/index.js";
|
*
|
||||||
|
* 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 SCHEMES = new Set(["http", "https"]);
|
||||||
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
|
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
|
* 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
|
* bad row must not make a host unsaveable or unlistable. The editor is
|
||||||
* responsible for telling the user before that happens
|
* 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[] {
|
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
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
|
* 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
|
* half-written config cannot take out the whole host listing.
|
||||||
* dockerConfig's bare JSON.parse does today.
|
|
||||||
*/
|
*/
|
||||||
export function parseWebUiConfig(raw: unknown): WebUiConfig {
|
export function parseWebUiConfig(raw: unknown): WebUiConfig {
|
||||||
if (raw === undefined || raw === null) return { endpoints: [] };
|
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. */
|
/** Null when nothing survives normalization, so the setting is cleared. */
|
||||||
export function serializeWebUiConfig(config: unknown): string | null {
|
export function serializeWebUiConfig(config: unknown): WebUiConfig | null {
|
||||||
const endpoints = normalizeWebEndpoints(
|
const endpoints = normalizeWebEndpoints(
|
||||||
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
|
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
|
||||||
);
|
);
|
||||||
if (endpoints.length === 0) return null;
|
if (endpoints.length === 0) return null;
|
||||||
return JSON.stringify({ endpoints });
|
return { endpoints };
|
||||||
}
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +1,160 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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());
|
const manifest = manifestJson as unknown as PluginManifest;
|
||||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
|
||||||
resolveHostById,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const forward = vi.fn();
|
const forward = vi.fn();
|
||||||
|
const openIsolatedWindow = vi.fn();
|
||||||
|
|
||||||
function response() {
|
function endpoint(overrides: Record<string, unknown> = {}) {
|
||||||
const res = {
|
return {
|
||||||
statusCode: 0,
|
id: "e1",
|
||||||
body: undefined as unknown,
|
label: "Proxmox",
|
||||||
status(code: number) {
|
scheme: "http",
|
||||||
this.statusCode = code;
|
port: 8006,
|
||||||
return this;
|
path: "/",
|
||||||
},
|
access: "tunnel",
|
||||||
json(payload: unknown) {
|
render: "embedded",
|
||||||
this.body = payload;
|
...overrides,
|
||||||
return this;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return res as unknown as express.Response & {
|
|
||||||
statusCode: number;
|
|
||||||
body: { port?: number; error?: string };
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function request(body: unknown) {
|
|
||||||
return { body } as unknown as express.Request;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: "e1",
|
|
||||||
label: "Proxmox",
|
|
||||||
scheme: "http",
|
|
||||||
port: 8006,
|
|
||||||
path: "/",
|
|
||||||
access: "tunnel",
|
|
||||||
render: "embedded",
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
function host(overrides: Record<string, unknown> = {}) {
|
function host(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: 7,
|
id: 7,
|
||||||
userId: "u1",
|
userId: "u1",
|
||||||
|
name: "nas",
|
||||||
ip: "10.0.0.5",
|
ip: "10.0.0.5",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "root",
|
username: "root",
|
||||||
|
tags: null,
|
||||||
|
folder: null,
|
||||||
authType: "password",
|
authType: "password",
|
||||||
enableWebUi: true,
|
|
||||||
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Activates the routes against a fake ctx, with or without the tunnels service. */
|
let mock: MockPluginContext;
|
||||||
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
|
let server: http.Server;
|
||||||
const fake = createFakeContext({
|
let baseUrl: string;
|
||||||
|
|
||||||
|
async function start(
|
||||||
|
options: {
|
||||||
|
hostOverrides?: Record<string, unknown>;
|
||||||
|
noHost?: boolean;
|
||||||
|
enableWebUi?: boolean;
|
||||||
|
endpoints?: unknown[];
|
||||||
|
capabilities?: string[];
|
||||||
|
tunnelsAvailable?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
mock = createMockCtx({
|
||||||
|
manifest,
|
||||||
pluginId: "web-endpoint",
|
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) {
|
const hostId = (options.hostOverrides?.id as number) ?? 7;
|
||||||
fake.ctx.services.provide("tunnels.access", { forward });
|
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");
|
if (options.tunnelsAvailable !== false) {
|
||||||
routes.startWebEndpointService(express.Router(), fake.ctx);
|
mock.ctx.services.provide("tunnels.access", { forward });
|
||||||
return routes;
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(createWebEndpointRoutes(mock.ctx));
|
||||||
|
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
forward.mockReset();
|
forward.mockReset();
|
||||||
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
|
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
|
||||||
resolveHostById.mockReset();
|
openIsolatedWindow.mockReset();
|
||||||
resolveHostById.mockResolvedValue(host());
|
openIsolatedWindow.mockResolvedValue({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
vi.resetModules();
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /open", () => {
|
describe("POST /open", () => {
|
||||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||||
resolveHostById.mockResolvedValue(null);
|
await start({ noHost: true });
|
||||||
const { handleWebEndpointOpen } = await load();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
const res = response();
|
method: "POST",
|
||||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
expect(res.statusCode).toBe(403);
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
expect(forward).not.toHaveBeenCalled();
|
expect(forward).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
||||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
await start({ enableWebUi: false });
|
||||||
const { handleWebEndpointOpen } = await load();
|
await mock.ctx.settings.setHost(7, "webUiConfig", {
|
||||||
const res = response();
|
endpoints: [endpoint()],
|
||||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
});
|
||||||
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
expect(res.statusCode).toBe(400);
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
expect(forward).not.toHaveBeenCalled();
|
expect(forward).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses an endpoint id that matches nothing", async () => {
|
it("refuses an endpoint id that matches nothing", async () => {
|
||||||
const { handleWebEndpointOpen } = await load();
|
await start();
|
||||||
const res = response();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
await handleWebEndpointOpen(
|
method: "POST",
|
||||||
request({ hostId: 7, endpointId: "nope" }),
|
headers: { "content-type": "application/json" },
|
||||||
res,
|
body: JSON.stringify({ hostId: 7, endpointId: "nope" }),
|
||||||
);
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the port the tunnels service bound", async () => {
|
it("returns the port the tunnels service bound", async () => {
|
||||||
const { handleWebEndpointOpen } = await load();
|
await start();
|
||||||
const res = response();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
expect(res.statusCode).toBe(200);
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
expect(res.body.port).toBe(41234);
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).port).toBe(41234);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a loopback forward under the reserved web name", async () => {
|
it("asks for a loopback forward under the reserved web name", async () => {
|
||||||
const { handleWebEndpointOpen } = await load();
|
await start();
|
||||||
await handleWebEndpointOpen(
|
await fetch(`${baseUrl}/open`, {
|
||||||
request({ hostId: 7, endpointId: "e1" }),
|
method: "POST",
|
||||||
response(),
|
headers: { "content-type": "application/json" },
|
||||||
);
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
|
});
|
||||||
expect(forward).toHaveBeenCalledWith(
|
expect(forward).toHaveBeenCalledWith(
|
||||||
7,
|
7,
|
||||||
{
|
{
|
||||||
@@ -143,85 +168,137 @@ describe("POST /open", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes the endpoint's own bind address and fixed port through", async () => {
|
it("passes the endpoint's own bind address and fixed port through", async () => {
|
||||||
resolveHostById.mockResolvedValue(
|
await start({
|
||||||
host({
|
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||||
webUiConfig: JSON.stringify({
|
});
|
||||||
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 { handleWebEndpointOpen } = await load();
|
});
|
||||||
await handleWebEndpointOpen(
|
|
||||||
request({ hostId: 7, endpointId: "e1" }),
|
|
||||||
response(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const target = forward.mock.calls[0][1];
|
const target = forward.mock.calls[0][1];
|
||||||
expect(target.bindHost).toBe("0.0.0.0");
|
expect(target.bindHost).toBe("0.0.0.0");
|
||||||
expect(target.bindPort).toBe(38080);
|
expect(target.bindPort).toBe(38080);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the service's reason as a 502 instead of a dead 200", async () => {
|
it("reports the service's reason as a 502 instead of a dead 200", async () => {
|
||||||
|
await start();
|
||||||
forward.mockRejectedValue(
|
forward.mockRejectedValue(
|
||||||
new Error("Channel open failure: connect failed"),
|
new Error("Channel open failure: connect failed"),
|
||||||
);
|
);
|
||||||
const { handleWebEndpointOpen } = await load();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
const res = response();
|
method: "POST",
|
||||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
expect(res.statusCode).toBe(502);
|
});
|
||||||
expect(String(res.body.error)).toMatch(/connect failed/);
|
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 () => {
|
it("answers 503 while the tunnels plugin is not available", async () => {
|
||||||
const { handleWebEndpointOpen } = await load({ tunnels: false });
|
await start({ tunnelsAvailable: false });
|
||||||
const res = response();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
expect(res.statusCode).toBe(503);
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(503);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a malformed request before touching the database", async () => {
|
it("rejects a malformed request before touching settings", async () => {
|
||||||
const { handleWebEndpointOpen } = await load();
|
await start();
|
||||||
|
const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost");
|
||||||
for (const body of [
|
for (const body of [
|
||||||
{},
|
{},
|
||||||
{ hostId: "7", endpointId: "e1" },
|
{ hostId: "7", endpointId: "e1" },
|
||||||
{ hostId: 0, endpointId: "e1" },
|
{ hostId: 0, endpointId: "e1" },
|
||||||
]) {
|
]) {
|
||||||
const res = response();
|
const res = await fetch(`${baseUrl}/open`, {
|
||||||
await handleWebEndpointOpen(request(body), res);
|
method: "POST",
|
||||||
expect(res.statusCode).toBe(400);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
describe("POST /open-window", () => {
|
||||||
* The client path and the server path have to agree. Core mounts every
|
it("refuses without desktop:window", async () => {
|
||||||
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
|
await start({
|
||||||
* and the only half this plugin owns is "/open".
|
capabilities: ["hosts:read", "network:serve", "ui:surface"],
|
||||||
*/
|
});
|
||||||
describe("route registration", () => {
|
const res = await fetch(`${baseUrl}/open-window`, {
|
||||||
it("registers /open on the router core hands it", async () => {
|
method: "POST",
|
||||||
const routes = await load();
|
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 = (
|
const paths = (
|
||||||
routes.router as unknown as {
|
routes as unknown as {
|
||||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||||
}
|
}
|
||||||
).stack
|
).stack
|
||||||
.filter((layer) => layer.route?.methods.post)
|
.filter((layer) => layer.route?.methods.post)
|
||||||
.map((layer) => layer.route?.path);
|
.map((layer) => layer.route?.path);
|
||||||
|
expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"]));
|
||||||
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()");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+3
-5
@@ -4,7 +4,7 @@ import {
|
|||||||
normalizeWebEndpoints,
|
normalizeWebEndpoints,
|
||||||
parseWebUiConfig,
|
parseWebUiConfig,
|
||||||
serializeWebUiConfig,
|
serializeWebUiConfig,
|
||||||
} from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
} from "../../src/shared/web-endpoint-config.js";
|
||||||
|
|
||||||
function valid(overrides: Record<string, unknown> = {}) {
|
function valid(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -249,8 +249,7 @@ describe("parseWebUiConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty endpoints for malformed JSON instead of throwing", () => {
|
it("returns empty endpoints for malformed JSON instead of throwing", () => {
|
||||||
// A half-written config must not take out the whole host listing, which
|
// A half-written config must not take out the whole host listing.
|
||||||
// is what dockerConfig's bare JSON.parse does today.
|
|
||||||
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
|
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,8 +262,7 @@ describe("parseWebUiConfig", () => {
|
|||||||
describe("serializeWebUiConfig", () => {
|
describe("serializeWebUiConfig", () => {
|
||||||
it("round-trips through normalization", () => {
|
it("round-trips through normalization", () => {
|
||||||
expect(
|
expect(
|
||||||
parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] }))
|
serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id,
|
||||||
.endpoints[0].id,
|
|
||||||
).toBe("e1");
|
).toBe("e1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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<string, unknown>) =>
|
|
||||||
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { HostEditorWebUiSection } from "../../src/frontend/HostEditorWebUiSection";
|
|
||||||
|
|
||||||
function endpoint(overrides: Partial<WebEndpoint> = {}): 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(
|
|
||||||
<HostEditorWebUiSection
|
|
||||||
enableWebUi={enableWebUi}
|
|
||||||
webUiConfig={config}
|
|
||||||
tunnelAvailable={tunnelAvailable}
|
|
||||||
setField={setField}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} 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";
|
import type { Host } from "@/types/ui-types";
|
||||||
|
|
||||||
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
||||||
@@ -59,7 +59,7 @@ function host(
|
|||||||
return {
|
return {
|
||||||
id: "7",
|
id: "7",
|
||||||
ip: "192.168.1.10",
|
ip: "192.168.1.10",
|
||||||
webUiConfig: { endpoints },
|
pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } },
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as Host;
|
} as unknown as Host;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
renderWithApp,
|
renderWithApp,
|
||||||
type RenderedPluginApp,
|
type RenderedPluginApp,
|
||||||
} from "@termix/plugin-sdk/testing";
|
} from "@termix/plugin-sdk/testing";
|
||||||
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
|
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import type { WebEndpoint } from "@/types";
|
|
||||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
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 * as plugin from "../../src/frontend/index";
|
||||||
import manifestJson from "../../manifest.json";
|
import manifestJson from "../../manifest.json";
|
||||||
|
import locales from "../../locales/en.json";
|
||||||
|
|
||||||
const manifest = manifestJson as unknown as PluginManifest;
|
const manifest = manifestJson as unknown as PluginManifest;
|
||||||
|
|
||||||
@@ -25,13 +27,19 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function host(overrides: Partial<Host> = {}): Host {
|
function host(
|
||||||
|
endpoints: WebEndpoint[] | undefined,
|
||||||
|
overrides: Partial<Host> = {},
|
||||||
|
): Host {
|
||||||
return {
|
return {
|
||||||
id: "7",
|
id: "7",
|
||||||
ip: "10.0.0.5",
|
ip: "10.0.0.5",
|
||||||
name: "nas",
|
name: "nas",
|
||||||
enableSsh: false,
|
enableSsh: false,
|
||||||
enableWebUi: false,
|
pluginSettings:
|
||||||
|
endpoints === undefined
|
||||||
|
? {}
|
||||||
|
: { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } },
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as Host;
|
} 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 () => {
|
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
|
||||||
expect(
|
const target = host([endpoint()], {
|
||||||
await webAction(
|
pluginSettings: {
|
||||||
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
|
"web-endpoint": {
|
||||||
),
|
enableWebUi: false,
|
||||||
).toBeUndefined();
|
webUiConfig: { endpoints: [endpoint()] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await webAction(target)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers no entry when enabled but no endpoints exist", async () => {
|
it("offers no entry when enabled but no endpoints exist", async () => {
|
||||||
expect(
|
expect(await webAction(host([]))).toBeUndefined();
|
||||||
await webAction(
|
});
|
||||||
host({ enableWebUi: true, webUiConfig: { endpoints: [] } }),
|
|
||||||
),
|
it("offers no entry when the host has no web-endpoint settings at all", async () => {
|
||||||
).toBeUndefined();
|
expect(await webAction(host(undefined))).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appears without SSH", async () => {
|
it("appears without SSH", async () => {
|
||||||
const action = await webAction(
|
const action = await webAction(host([endpoint()], { enableSsh: false }));
|
||||||
host({
|
|
||||||
enableSsh: false,
|
|
||||||
enableWebUi: true,
|
|
||||||
webUiConfig: { endpoints: [endpoint()] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(action).toBeDefined();
|
expect(action).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels the entry with the endpoint when there is only one", async () => {
|
it("labels the entry with the endpoint when there is only one", async () => {
|
||||||
const target = host({
|
const target = host([endpoint({ label: "Proxmox" })]);
|
||||||
enableWebUi: true,
|
|
||||||
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
|
|
||||||
});
|
|
||||||
const action = await webAction(target);
|
const action = await webAction(target);
|
||||||
expect(action?.label?.(target)).toBe("Proxmox");
|
expect(action?.label?.(target)).toBe("Proxmox");
|
||||||
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
|
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) =>
|
const endpoints = Array.from({ length: 16 }, (_, i) =>
|
||||||
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
||||||
);
|
);
|
||||||
const target = host({ enableWebUi: true, webUiConfig: { endpoints } });
|
const target = host(endpoints);
|
||||||
const action = await webAction(target);
|
const action = await webAction(target);
|
||||||
expect(action?.label?.(target)).toBeUndefined();
|
expect(action?.label?.(target)).toBeUndefined();
|
||||||
expect(action?.items?.(target)).toHaveLength(16);
|
expect(action?.items?.(target)).toHaveLength(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens an embedded endpoint as a tab carrying its id", async () => {
|
it("opens an embedded endpoint as a tab carrying its id", async () => {
|
||||||
const target = host({
|
const target = host([endpoint()]);
|
||||||
enableWebUi: true,
|
|
||||||
webUiConfig: { endpoints: [endpoint()] },
|
|
||||||
});
|
|
||||||
const action = await webAction(target);
|
const action = await webAction(target);
|
||||||
const calls: unknown[][] = [];
|
const calls: unknown[][] = [];
|
||||||
const shell = {
|
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<string, unknown> = {
|
||||||
|
name: "nas",
|
||||||
|
pluginSettings: { "web-endpoint": { enableWebUi: false } },
|
||||||
|
};
|
||||||
|
const updateForm = vi.fn(
|
||||||
|
(
|
||||||
|
patch: (current: Record<string, unknown>) => Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
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<string, unknown>)[
|
||||||
|
"web-endpoint"
|
||||||
|
] as { enableWebUi: boolean };
|
||||||
|
expect(settings.enableWebUi).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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));
|
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||||
|
|
||||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
const pluginPost = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("@/main-axios", () => ({
|
vi.mock("@/main-axios", () => ({
|
||||||
handleApiError: (error: unknown) => {
|
handleApiError: (error: unknown) => {
|
||||||
throw new Error(`generic: ${String(error)}`);
|
throw new Error(`generic: ${String(error)}`);
|
||||||
@@ -27,32 +27,17 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
|||||||
|
|
||||||
const host = { id: "7", ip: "192.168.1.10" };
|
const host = { id: "7", ip: "192.168.1.10" };
|
||||||
|
|
||||||
let pageHostname = "localhost";
|
|
||||||
const realLocation = window.location;
|
|
||||||
const windowOpen = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { setWebEndpointApi } =
|
const { setWebEndpointApi } =
|
||||||
await import("../../src/frontend/web-endpoint-api");
|
await import("../../src/frontend/web-endpoint-api");
|
||||||
setWebEndpointApi({ post: tunnelPost } as never);
|
setWebEndpointApi({ post: pluginPost } as never);
|
||||||
pageHostname = "localhost";
|
|
||||||
isElectron.mockReturnValue(false);
|
isElectron.mockReturnValue(false);
|
||||||
tunnelPost.mockReset();
|
pluginPost.mockReset();
|
||||||
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
|
pluginPost.mockResolvedValue({ data: { port: 41234 } });
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
|
||||||
});
|
|
||||||
vi.stubGlobal("open", windowOpen);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
value: realLocation,
|
|
||||||
});
|
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
windowOpen.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("openWebEndpointTunnel", () => {
|
describe("openWebEndpointTunnel", () => {
|
||||||
@@ -63,7 +48,7 @@ describe("openWebEndpointTunnel", () => {
|
|||||||
await import("../../src/frontend/web-endpoint-api");
|
await import("../../src/frontend/web-endpoint-api");
|
||||||
await openWebEndpointTunnel(7, "e1");
|
await openWebEndpointTunnel(7, "e1");
|
||||||
|
|
||||||
expect(tunnelPost).toHaveBeenCalledWith("/open", {
|
expect(pluginPost).toHaveBeenCalledWith("/open", {
|
||||||
hostId: 7,
|
hostId: 7,
|
||||||
endpointId: "e1",
|
endpointId: "e1",
|
||||||
});
|
});
|
||||||
@@ -86,7 +71,7 @@ describe("openWebEndpointTunnel", () => {
|
|||||||
data: { error: "Timed out reaching the endpoint port" },
|
data: { error: "Timed out reaching the endpoint port" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tunnelPost.mockRejectedValue(axiosError);
|
pluginPost.mockRejectedValue(axiosError);
|
||||||
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
||||||
await import("../../src/frontend/web-endpoint-api");
|
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 () => {
|
it("falls back to the shared handler when the body carries no string reason", async () => {
|
||||||
// A body shaped { error: <object> } would otherwise render as
|
// A body shaped { error: <object> } would otherwise render as
|
||||||
// "[object Object]" to the user.
|
// "[object Object]" to the user.
|
||||||
tunnelPost.mockRejectedValue(
|
pluginPost.mockRejectedValue(
|
||||||
Object.assign(new Error("boom"), {
|
Object.assign(new Error("boom"), {
|
||||||
isAxiosError: true,
|
isAxiosError: true,
|
||||||
response: { status: 500, data: { error: { nested: 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 () => {
|
it("treats a missing port as a failure rather than returning undefined", async () => {
|
||||||
tunnelPost.mockResolvedValue({ data: {} });
|
pluginPost.mockResolvedValue({ data: {} });
|
||||||
const { openWebEndpointTunnel } =
|
const { openWebEndpointTunnel } =
|
||||||
await import("../../src/frontend/web-endpoint-api");
|
await import("../../src/frontend/web-endpoint-api");
|
||||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
|
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
|
* The backend now resolves and validates the target URL itself (host address,
|
||||||
* browser either way, so a tunnel URL on the host string serving Termix hands
|
* tunnel port, loopback/session-cookie checks) before ever calling
|
||||||
* the tunnelled service this session exactly as an embedded frame would.
|
* 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", () => {
|
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 } =
|
const { openWebEndpointExternally } =
|
||||||
await import("../../src/frontend/web-endpoint-api");
|
await import("../../src/frontend/web-endpoint-api");
|
||||||
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
|
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
|
||||||
/desktop app/,
|
/desktop app/,
|
||||||
);
|
);
|
||||||
expect(windowOpen).not.toHaveBeenCalled();
|
expect(pluginPost).not.toHaveBeenCalled();
|
||||||
expect(tunnelPost).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);
|
isElectron.mockReturnValue(true);
|
||||||
const invoke = vi.fn().mockResolvedValue({ success: true });
|
pluginPost.mockResolvedValue({ data: { success: true } });
|
||||||
Object.defineProperty(window, "electronAPI", {
|
|
||||||
configurable: true,
|
|
||||||
value: { invoke },
|
|
||||||
});
|
|
||||||
const { openWebEndpointExternally } =
|
const { openWebEndpointExternally } =
|
||||||
await import("../../src/frontend/web-endpoint-api");
|
await import("../../src/frontend/web-endpoint-api");
|
||||||
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
|
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
|
||||||
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
|
expect(pluginPost).toHaveBeenCalledWith("/open-window", {
|
||||||
url: "https://127.0.0.1:41234/",
|
hostId: 7,
|
||||||
|
endpointId: "e1",
|
||||||
ignoreCert: true,
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
sharesCookieSiteWithPage,
|
sharesCookieSiteWithPage,
|
||||||
webEndpointRefusalReason,
|
webEndpointRefusalReason,
|
||||||
} from "../../src/frontend/web-endpoint-url";
|
} 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> = {}): WebEndpoint {
|
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
webEndpointErrorKey,
|
webEndpointErrorKey,
|
||||||
webEndpointRowError,
|
webEndpointRowError,
|
||||||
} from "../../src/frontend/web-endpoint-validation";
|
} from "../../src/frontend/web-endpoint-validation";
|
||||||
import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config";
|
||||||
import type { WebEndpoint } from "@/types/index";
|
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||||
|
|
||||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -133,12 +133,9 @@
|
|||||||
"plugins/tailscale/src/frontend/index.tsx",
|
"plugins/tailscale/src/frontend/index.tsx",
|
||||||
"plugins/tailscale/src/frontend/tailscale-api.ts",
|
"plugins/tailscale/src/frontend/tailscale-api.ts",
|
||||||
"plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx",
|
"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/WebEndpointTab.tsx",
|
||||||
"plugins/web-endpoint/src/frontend/index.tsx",
|
"plugins/web-endpoint/src/frontend/index.tsx",
|
||||||
"plugins/web-endpoint/src/frontend/web-endpoint-api.ts",
|
"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"
|
|
||||||
],
|
],
|
||||||
"plugin-to-core": [
|
"plugin-to-core": [
|
||||||
"plugins/ai/src/backend/egress.ts",
|
"plugins/ai/src/backend/egress.ts",
|
||||||
@@ -207,8 +204,7 @@
|
|||||||
"plugins/remote-desktop/src/backend/token-service.ts",
|
"plugins/remote-desktop/src/backend/token-service.ts",
|
||||||
"plugins/ssh-terminal/src/backend/index.ts",
|
"plugins/ssh-terminal/src/backend/index.ts",
|
||||||
"plugins/tailscale/src/backend/host-metrics-manager.ts",
|
"plugins/tailscale/src/backend/host-metrics-manager.ts",
|
||||||
"plugins/tailscale/src/backend/routes.ts",
|
"plugins/tailscale/src/backend/routes.ts"
|
||||||
"plugins/web-endpoint/src/backend/routes.ts"
|
|
||||||
],
|
],
|
||||||
"plugin-to-plugin": [
|
"plugin-to-plugin": [
|
||||||
"plugins/ai/src/backend/tools/executor.ts",
|
"plugins/ai/src/backend/tools/executor.ts",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
createCurrentPluginSettingsRepository,
|
createCurrentPluginSettingsRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import { serializeWebUiConfig } from "./host-web-endpoints.js";
|
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
|
||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
@@ -286,10 +286,6 @@ export function registerHostBulkRoutes(
|
|||||||
simpleUpdates.enableFileManager = updates.enableFileManager;
|
simpleUpdates.enableFileManager = updates.enableFileManager;
|
||||||
if (typeof updates.enableDocker === "boolean")
|
if (typeof updates.enableDocker === "boolean")
|
||||||
simpleUpdates.enableDocker = updates.enableDocker;
|
simpleUpdates.enableDocker = updates.enableDocker;
|
||||||
if (typeof updates.enableWebUi === "boolean") {
|
|
||||||
simpleUpdates.enableWebUi = updates.enableWebUi;
|
|
||||||
if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;
|
|
||||||
}
|
|
||||||
if (typeof updates.enableTmuxMonitor === "boolean")
|
if (typeof updates.enableTmuxMonitor === "boolean")
|
||||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||||
if (typeof updates.enableTerminalToolbar === "boolean")
|
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({
|
return res.json({
|
||||||
updated: ownedIds.length,
|
updated: ownedIds.length,
|
||||||
failed: unauthorizedIds.length,
|
failed: unauthorizedIds.length,
|
||||||
@@ -737,7 +766,6 @@ export function registerHostBulkRoutes(
|
|||||||
enableTunnel: hostData.enableTunnel !== false,
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
enableFileManager: hostData.enableFileManager !== false,
|
enableFileManager: hostData.enableFileManager !== false,
|
||||||
enableDocker: hostData.enableDocker || false,
|
enableDocker: hostData.enableDocker || false,
|
||||||
enableWebUi: hostData.enableWebUi || false,
|
|
||||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||||
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
||||||
enableAiAssistant: hostData.enableAiAssistant || false,
|
enableAiAssistant: hostData.enableAiAssistant || false,
|
||||||
@@ -762,9 +790,6 @@ export function registerHostBulkRoutes(
|
|||||||
dockerConfig: hostData.dockerConfig
|
dockerConfig: hostData.dockerConfig
|
||||||
? JSON.stringify(hostData.dockerConfig)
|
? JSON.stringify(hostData.dockerConfig)
|
||||||
: null,
|
: null,
|
||||||
webUiConfig: hostData.enableWebUi
|
|
||||||
? serializeWebUiConfig(hostData.webUiConfig)
|
|
||||||
: null,
|
|
||||||
terminalConfig: hostData.terminalConfig
|
terminalConfig: hostData.terminalConfig
|
||||||
? JSON.stringify(hostData.terminalConfig)
|
? JSON.stringify(hostData.terminalConfig)
|
||||||
: null,
|
: null,
|
||||||
@@ -860,27 +885,14 @@ export function registerHostBulkRoutes(
|
|||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostData.enableProxmox || hostData.proxmoxConfig) {
|
// Every enabled plugin that declares host-scope settings and
|
||||||
const pluginSettingsRepository =
|
// registered a hostImportNormalizer validates and writes its own
|
||||||
createCurrentPluginSettingsRepository();
|
// fields here, so this loop does not need to know which plugins
|
||||||
const scopeId = String(savedHostId);
|
// exist. See host-plugin-settings.ts.
|
||||||
await pluginSettingsRepository.set(
|
await applyPluginHostImportSettings(
|
||||||
"proxmox",
|
savedHostId,
|
||||||
"host",
|
hostData as Record<string, unknown>,
|
||||||
scopeId,
|
);
|
||||||
"enableProxmox",
|
|
||||||
JSON.stringify(hostData.enableProxmox || false),
|
|
||||||
);
|
|
||||||
if (hostData.proxmoxConfig) {
|
|
||||||
await pluginSettingsRepository.set(
|
|
||||||
"proxmox",
|
|
||||||
"host",
|
|
||||||
scopeId,
|
|
||||||
"proxmoxConfig",
|
|
||||||
JSON.stringify(hostData.proxmoxConfig),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||||
@@ -1038,7 +1050,6 @@ export function registerHostBulkRoutes(
|
|||||||
enableTunnel: true,
|
enableTunnel: true,
|
||||||
enableFileManager: true,
|
enableFileManager: true,
|
||||||
enableDocker: false,
|
enableDocker: false,
|
||||||
enableWebUi: false,
|
|
||||||
enableTmuxMonitor: false,
|
enableTmuxMonitor: false,
|
||||||
enableTerminalToolbar: true,
|
enableTerminalToolbar: true,
|
||||||
enableAiAssistant: false,
|
enableAiAssistant: false,
|
||||||
@@ -1056,7 +1067,6 @@ export function registerHostBulkRoutes(
|
|||||||
quickActions: null,
|
quickActions: null,
|
||||||
statsConfig: null,
|
statsConfig: null,
|
||||||
dockerConfig: null,
|
dockerConfig: null,
|
||||||
webUiConfig: null,
|
|
||||||
terminalConfig: null,
|
terminalConfig: null,
|
||||||
forceKeyboardInteractive: "false",
|
forceKeyboardInteractive: "false",
|
||||||
notes: null,
|
notes: null,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||||
import { parseWebUiConfig } from "./host-web-endpoints.js";
|
|
||||||
|
|
||||||
export function isNonEmptyString(value: unknown): value is string {
|
export function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
@@ -364,8 +363,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
|||||||
"enableTunnel",
|
"enableTunnel",
|
||||||
"enableFileManager",
|
"enableFileManager",
|
||||||
"enableDocker",
|
"enableDocker",
|
||||||
"enableWebUi",
|
|
||||||
"webUiConfig",
|
|
||||||
"enableTmuxMonitor",
|
"enableTmuxMonitor",
|
||||||
"enableTerminalToolbar",
|
"enableTerminalToolbar",
|
||||||
"enableAiAssistant",
|
"enableAiAssistant",
|
||||||
@@ -467,7 +464,6 @@ export function transformHostResponse(
|
|||||||
enableTunnel: !!host.enableTunnel,
|
enableTunnel: !!host.enableTunnel,
|
||||||
enableFileManager: host.enableFileManager !== false,
|
enableFileManager: host.enableFileManager !== false,
|
||||||
enableDocker: !!host.enableDocker,
|
enableDocker: !!host.enableDocker,
|
||||||
enableWebUi: !!host.enableWebUi,
|
|
||||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||||
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
||||||
enableAiAssistant: !!host.enableAiAssistant,
|
enableAiAssistant: !!host.enableAiAssistant,
|
||||||
@@ -519,9 +515,6 @@ export function transformHostResponse(
|
|||||||
dockerConfig: host.dockerConfig
|
dockerConfig: host.dockerConfig
|
||||||
? JSON.parse(host.dockerConfig as string)
|
? JSON.parse(host.dockerConfig as string)
|
||||||
: undefined,
|
: 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",
|
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||||
useWarpgate: !!host.useWarpgate,
|
useWarpgate: !!host.useWarpgate,
|
||||||
socks5ProxyChain: host.socks5ProxyChain
|
socks5ProxyChain: host.socks5ProxyChain
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import { createCurrentPluginSettingsRepository } from "../repositories/factory.j
|
|||||||
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||||
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import { consume } from "../../plugins/registry.js";
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||||
function hostSettingsPlugins(): PluginManifest[] {
|
export function hostSettingsPlugins(): PluginManifest[] {
|
||||||
try {
|
try {
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
return loader
|
return loader
|
||||||
@@ -147,3 +148,48 @@ export async function writeHostPluginSettings(
|
|||||||
await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value));
|
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("<pluginId>.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<string, unknown>,
|
||||||
|
) => Record<string, unknown> | 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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const manifest of hostSettingsPlugins()) {
|
||||||
|
const normalizer = consume<PluginHostImportNormalizer>(
|
||||||
|
`${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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,10 +72,6 @@ import type {
|
|||||||
HostResolutionHostRecord,
|
HostResolutionHostRecord,
|
||||||
} from "../repositories/host-resolution-repository.js";
|
} from "../repositories/host-resolution-repository.js";
|
||||||
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
|
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
|
||||||
import {
|
|
||||||
parseWebUiConfig,
|
|
||||||
serializeWebUiConfig,
|
|
||||||
} from "./host-web-endpoints.js";
|
|
||||||
import {
|
import {
|
||||||
requiresPersonalHostAuthentication,
|
requiresPersonalHostAuthentication,
|
||||||
resolveRecipientSharedHostAuthentication,
|
resolveRecipientSharedHostAuthentication,
|
||||||
@@ -202,7 +198,6 @@ router.post(
|
|||||||
enableFileManager,
|
enableFileManager,
|
||||||
scpLegacy,
|
scpLegacy,
|
||||||
enableDocker,
|
enableDocker,
|
||||||
enableWebUi,
|
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
enableTerminalToolbar,
|
enableTerminalToolbar,
|
||||||
@@ -219,7 +214,6 @@ router.post(
|
|||||||
quickActions,
|
quickActions,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
dockerConfig,
|
dockerConfig,
|
||||||
webUiConfig,
|
|
||||||
proxmoxConfig,
|
proxmoxConfig,
|
||||||
enableProxmoxStats,
|
enableProxmoxStats,
|
||||||
proxmoxStatsConfig,
|
proxmoxStatsConfig,
|
||||||
@@ -348,7 +342,6 @@ router.post(
|
|||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
scpLegacy: scpLegacy ? 1 : 0,
|
scpLegacy: scpLegacy ? 1 : 0,
|
||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableWebUi: enableWebUi ? 1 : 0,
|
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||||
@@ -369,13 +362,6 @@ router.post(
|
|||||||
? dockerConfig
|
? dockerConfig
|
||||||
: JSON.stringify(dockerConfig)
|
: JSON.stringify(dockerConfig)
|
||||||
: null,
|
: null,
|
||||||
webUiConfig: enableWebUi
|
|
||||||
? serializeWebUiConfig(
|
|
||||||
typeof webUiConfig === "string"
|
|
||||||
? safeParseJson(webUiConfig)
|
|
||||||
: webUiConfig,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
terminalConfig: terminalConfig
|
terminalConfig: terminalConfig
|
||||||
? typeof terminalConfig === "string"
|
? typeof terminalConfig === "string"
|
||||||
? terminalConfig
|
? terminalConfig
|
||||||
@@ -930,7 +916,6 @@ router.put(
|
|||||||
enableFileManager,
|
enableFileManager,
|
||||||
scpLegacy,
|
scpLegacy,
|
||||||
enableDocker,
|
enableDocker,
|
||||||
enableWebUi,
|
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
enableTerminalToolbar,
|
enableTerminalToolbar,
|
||||||
@@ -947,7 +932,6 @@ router.put(
|
|||||||
quickActions,
|
quickActions,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
dockerConfig,
|
dockerConfig,
|
||||||
webUiConfig,
|
|
||||||
proxmoxConfig,
|
proxmoxConfig,
|
||||||
enableProxmoxStats,
|
enableProxmoxStats,
|
||||||
proxmoxStatsConfig,
|
proxmoxStatsConfig,
|
||||||
@@ -1079,7 +1063,6 @@ router.put(
|
|||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
scpLegacy: scpLegacy ? 1 : 0,
|
scpLegacy: scpLegacy ? 1 : 0,
|
||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableWebUi: enableWebUi ? 1 : 0,
|
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||||
@@ -1100,13 +1083,6 @@ router.put(
|
|||||||
? dockerConfig
|
? dockerConfig
|
||||||
: JSON.stringify(dockerConfig)
|
: JSON.stringify(dockerConfig)
|
||||||
: null,
|
: null,
|
||||||
webUiConfig: enableWebUi
|
|
||||||
? serializeWebUiConfig(
|
|
||||||
typeof webUiConfig === "string"
|
|
||||||
? safeParseJson(webUiConfig)
|
|
||||||
: webUiConfig,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
terminalConfig: terminalConfig
|
terminalConfig: terminalConfig
|
||||||
? typeof terminalConfig === "string"
|
? typeof terminalConfig === "string"
|
||||||
? terminalConfig
|
? terminalConfig
|
||||||
@@ -2100,9 +2076,10 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
||||||
const proxmoxSettings = (
|
const hostPluginSettings = (
|
||||||
await loadHostPluginSettings([Number(hostId)])
|
await loadHostPluginSettings([Number(hostId)])
|
||||||
).get(Number(hostId))?.proxmox as
|
).get(Number(hostId));
|
||||||
|
const proxmoxSettings = hostPluginSettings?.proxmox as
|
||||||
| {
|
| {
|
||||||
enableProxmox?: boolean;
|
enableProxmox?: boolean;
|
||||||
proxmoxConfig?: unknown;
|
proxmoxConfig?: unknown;
|
||||||
@@ -2110,6 +2087,8 @@ router.get(
|
|||||||
proxmoxStatsConfig?: unknown;
|
proxmoxStatsConfig?: unknown;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
|
||||||
|
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
|
||||||
|
|
||||||
const exportedConnectionType =
|
const exportedConnectionType =
|
||||||
(resolvedHost.connectionType as string) || "ssh";
|
(resolvedHost.connectionType as string) || "ssh";
|
||||||
@@ -2177,7 +2156,7 @@ router.get(
|
|||||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||||
scpLegacy: !!resolvedHost.scpLegacy,
|
scpLegacy: !!resolvedHost.scpLegacy,
|
||||||
enableDocker: !!resolvedHost.enableDocker,
|
enableDocker: !!resolvedHost.enableDocker,
|
||||||
enableWebUi: !!resolvedHost.enableWebUi,
|
enableWebUi: !!webEndpointSettings?.enableWebUi,
|
||||||
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
||||||
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
|
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
|
||||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||||
@@ -2202,7 +2181,7 @@ router.get(
|
|||||||
statsConfig: resolvedHost.statsConfig
|
statsConfig: resolvedHost.statsConfig
|
||||||
? JSON.parse(resolvedHost.statsConfig as string)
|
? JSON.parse(resolvedHost.statsConfig as string)
|
||||||
: null,
|
: null,
|
||||||
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
|
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
|
||||||
dockerConfig: resolvedHost.dockerConfig
|
dockerConfig: resolvedHost.dockerConfig
|
||||||
? JSON.parse(resolvedHost.dockerConfig as string)
|
? JSON.parse(resolvedHost.dockerConfig as string)
|
||||||
: null,
|
: null,
|
||||||
@@ -2282,7 +2261,7 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const allHosts =
|
const allHosts =
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||||
const proxmoxSettingsByHost = await loadHostPluginSettings(
|
const pluginSettingsByHost = await loadHostPluginSettings(
|
||||||
allHosts.map((h) => h.id as number),
|
allHosts.map((h) => h.id as number),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2293,9 +2272,11 @@ router.get(
|
|||||||
const resolvedHost = shareMode
|
const resolvedHost = shareMode
|
||||||
? host
|
? host
|
||||||
: (await resolveHostCredentials(host, userId)) || host;
|
: (await resolveHostCredentials(host, userId)) || host;
|
||||||
const proxmoxSettings = proxmoxSettingsByHost.get(host.id as number)
|
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
|
||||||
?.proxmox as
|
const proxmoxSettings = hostPluginSettings?.proxmox as
|
||||||
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
|
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
|
||||||
|
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
|
||||||
|
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
|
||||||
|
|
||||||
const exportedConnectionType =
|
const exportedConnectionType =
|
||||||
(resolvedHost.connectionType as string) || "ssh";
|
(resolvedHost.connectionType as string) || "ssh";
|
||||||
@@ -2344,7 +2325,7 @@ router.get(
|
|||||||
enableTunnel: !!resolvedHost.enableTunnel,
|
enableTunnel: !!resolvedHost.enableTunnel,
|
||||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||||
enableDocker: !!resolvedHost.enableDocker,
|
enableDocker: !!resolvedHost.enableDocker,
|
||||||
enableWebUi: !!resolvedHost.enableWebUi,
|
enableWebUi: !!webEndpointSettings?.enableWebUi,
|
||||||
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
enableProxmox: !!proxmoxSettings?.enableProxmox,
|
||||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||||
enableTerminalToolbar:
|
enableTerminalToolbar:
|
||||||
@@ -2371,7 +2352,9 @@ router.get(
|
|||||||
statsConfig: resolvedHost.statsConfig
|
statsConfig: resolvedHost.statsConfig
|
||||||
? JSON.parse(resolvedHost.statsConfig as string)
|
? JSON.parse(resolvedHost.statsConfig as string)
|
||||||
: null,
|
: null,
|
||||||
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
|
webUiConfig: webEndpointSettings?.webUiConfig ?? {
|
||||||
|
endpoints: [],
|
||||||
|
},
|
||||||
dockerConfig: resolvedHost.dockerConfig
|
dockerConfig: resolvedHost.dockerConfig
|
||||||
? JSON.parse(resolvedHost.dockerConfig as string)
|
? JSON.parse(resolvedHost.dockerConfig as string)
|
||||||
: null,
|
: null,
|
||||||
@@ -3010,10 +2993,9 @@ registerHostNetworkRoutes(router, {
|
|||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A webUiConfig arriving as a JSON string (an import, or a client that
|
* A config field arriving as a JSON string (an import, or a client that
|
||||||
* stringified it) must still reach serializeWebUiConfig as an object.
|
* stringified it) must still reach storage as an object. Malformed input
|
||||||
* Malformed input becomes null, which serializes to a cleared column rather
|
* becomes null rather than throwing inside a host save.
|
||||||
* than throwing inside a host save.
|
|
||||||
*/
|
*/
|
||||||
function safeParseJson(raw: string): unknown {
|
function safeParseJson(raw: string): unknown {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
PluginCapabilityError,
|
PluginCapabilityError,
|
||||||
type PluginContext,
|
type PluginContext,
|
||||||
type PluginModule,
|
type PluginModule,
|
||||||
|
type PluginOpenIsolatedWindowRequest,
|
||||||
} from "@termix/plugin-sdk/backend";
|
} from "@termix/plugin-sdk/backend";
|
||||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||||
import * as syncRegistry from "./sync-registry.js";
|
import * as syncRegistry from "./sync-registry.js";
|
||||||
@@ -299,6 +300,28 @@ export function createPluginContext(
|
|||||||
{ action: "db_refs" },
|
{ 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 {
|
return {
|
||||||
pluginId,
|
pluginId,
|
||||||
manifest,
|
manifest,
|
||||||
@@ -613,6 +636,10 @@ export function createPluginContext(
|
|||||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||||
auth: createPluginAuth({ 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
|
* 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.
|
* as someone" is exactly the thing an operator needs to be able to see.
|
||||||
|
|||||||
@@ -357,6 +357,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
const { runTunnelsSettingsMigration } =
|
const { runTunnelsSettingsMigration } =
|
||||||
await import("./utils/crypto-migration/tunnels-settings-migration.js");
|
await import("./utils/crypto-migration/tunnels-settings-migration.js");
|
||||||
await runTunnelsSettingsMigration();
|
await runTunnelsSettingsMigration();
|
||||||
|
|
||||||
|
const { runWebEndpointSettingsMigration } =
|
||||||
|
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||||
|
await runWebEndpointSettingsMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||||
operation: "plugin_init",
|
operation: "plugin_init",
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
|||||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
(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", () => {
|
vi.mock("../../../utils/logger.js", () => {
|
||||||
const logger = {
|
const logger = {
|
||||||
@@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
|
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../plugins/index.js", () => ({
|
vi.mock("../../../plugins/index.js", () => ({
|
||||||
@@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
|||||||
decryptSystemSecret: async (stored: string) => stored,
|
decryptSystemSecret: async (stored: string) => stored,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const registryProviders = new Map<string, unknown>();
|
||||||
|
vi.mock("../../../plugins/registry.js", () => ({
|
||||||
|
consume: (key: string) => registryProviders.get(key),
|
||||||
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attachHostPluginSettings,
|
attachHostPluginSettings,
|
||||||
loadHostPluginSettings,
|
loadHostPluginSettings,
|
||||||
withHostPluginSettings,
|
withHostPluginSettings,
|
||||||
|
writeHostPluginSettings,
|
||||||
|
applyPluginHostImportSettings,
|
||||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||||
|
|
||||||
function manifest(id: string, host: unknown): PluginManifest {
|
function manifest(id: string, host: unknown): PluginManifest {
|
||||||
@@ -80,7 +99,10 @@ const DOCKER = manifest("docker", {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rows.length = 0;
|
rows.length = 0;
|
||||||
loaded.length = 0;
|
loaded.length = 0;
|
||||||
|
setCalls.length = 0;
|
||||||
|
registryProviders.clear();
|
||||||
getAllForScopeIds.mockClear();
|
getAllForScopeIds.mockClear();
|
||||||
|
set.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadHostPluginSettings", () => {
|
describe("loadHostPluginSettings", () => {
|
||||||
@@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => {
|
|||||||
expect(await withHostPluginSettings(host)).toBe(host);
|
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"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-24
@@ -1,8 +1,8 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
const { createWebEndpointWindows } = createRequire(import.meta.url)(
|
const { createIsolatedWindows } = createRequire(import.meta.url)(
|
||||||
"../../../../electron/web-endpoint-window.cjs",
|
"../../../../electron/isolated-window.cjs",
|
||||||
);
|
);
|
||||||
const created: FakeWindow[] = [];
|
const created: FakeWindow[] = [];
|
||||||
class FakeWindow extends EventEmitter {
|
class FakeWindow extends EventEmitter {
|
||||||
@@ -38,10 +38,7 @@ type TestSession = EventEmitter & {
|
|||||||
};
|
};
|
||||||
let sessions: TestSession[];
|
let sessions: TestSession[];
|
||||||
let fromPartition: ReturnType<typeof vi.fn>;
|
let fromPartition: ReturnType<typeof vi.fn>;
|
||||||
let manager: ReturnType<typeof createWebEndpointWindows>;
|
let manager: ReturnType<typeof createIsolatedWindows>;
|
||||||
function event() {
|
|
||||||
return { sender: main.webContents, senderFrame: main.webContents.mainFrame };
|
|
||||||
}
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
created.length = 0;
|
created.length = 0;
|
||||||
sessions = [];
|
sessions = [];
|
||||||
@@ -59,16 +56,16 @@ beforeEach(() => {
|
|||||||
sessions.push(session);
|
sessions.push(session);
|
||||||
return session;
|
return session;
|
||||||
});
|
});
|
||||||
manager = createWebEndpointWindows({
|
manager = createIsolatedWindows({
|
||||||
BrowserWindow: FakeWindow,
|
BrowserWindow: FakeWindow,
|
||||||
session: { fromPartition },
|
session: { fromPartition },
|
||||||
getMainWindow: () => main,
|
getMainWindow: () => main,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("isolated endpoint windows", () => {
|
describe("isolated windows", () => {
|
||||||
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
||||||
await manager.open(event(), { url: "https://service.test" });
|
await manager.open({ url: "https://service.test" });
|
||||||
await manager.open(event(), { url: "https://service.test" });
|
await manager.open({ url: "https://service.test" });
|
||||||
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
||||||
fromPartition.mock.calls[1][0],
|
fromPartition.mock.calls[1][0],
|
||||||
);
|
);
|
||||||
@@ -82,27 +79,33 @@ describe("isolated endpoint windows", () => {
|
|||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("rejects subframes, other windows and non-web destinations before creating a session", async () => {
|
it("uses a caller-supplied partition when given one", async () => {
|
||||||
await expect(
|
await manager.open({ url: "https://service.test", partition: "fixed" });
|
||||||
manager.open(
|
expect(fromPartition.mock.calls[0][0]).toBe("fixed");
|
||||||
{ ...event(), senderFrame: {} },
|
});
|
||||||
{ url: "https://service.test" },
|
it("rejects non-web destinations before creating a session", async () => {
|
||||||
),
|
|
||||||
).rejects.toThrow(/main Termix/);
|
|
||||||
await expect(
|
|
||||||
manager.open({ ...event(), sender: {} }, { url: "https://service.test" }),
|
|
||||||
).rejects.toThrow(/main Termix/);
|
|
||||||
for (const url of [
|
for (const url of [
|
||||||
"file:///etc/passwd",
|
"file:///etc/passwd",
|
||||||
"javascript:alert(1)",
|
"javascript:alert(1)",
|
||||||
"https://user:secret@service.test",
|
"https://user:secret@service.test",
|
||||||
]) {
|
]) {
|
||||||
await expect(manager.open(event(), { url })).rejects.toThrow();
|
await expect(manager.open({ url })).rejects.toThrow();
|
||||||
}
|
}
|
||||||
expect(fromPartition).not.toHaveBeenCalled();
|
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 () => {
|
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],
|
const win = created[0],
|
||||||
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||||
expect(
|
expect(
|
||||||
@@ -121,7 +124,7 @@ describe("isolated endpoint windows", () => {
|
|||||||
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
||||||
});
|
});
|
||||||
it("limits invalid certificates to the opted-in origin, including its port", async () => {
|
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",
|
url: "https://service.test:8443",
|
||||||
ignoreCert: true,
|
ignoreCert: true,
|
||||||
});
|
});
|
||||||
@@ -153,7 +156,7 @@ describe("isolated endpoint windows", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
it("closes login popups and clears session data when the endpoint closes", async () => {
|
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],
|
const root = created[0],
|
||||||
popup = new FakeWindow({});
|
popup = new FakeWindow({});
|
||||||
root.webContents.emit("did-create-window", popup);
|
root.webContents.emit("did-create-window", popup);
|
||||||
@@ -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<string, string[]>();
|
||||||
|
const auditEntries: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<WebEndpointSettingsMigrationResult> {
|
||||||
|
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<LegacyWebEndpointRow>(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;
|
||||||
|
}
|
||||||
@@ -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<T = unknown>(
|
||||||
|
channel: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>((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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GuacamoleConfig } from "./guacamole-config.js";
|
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 { StatsConfig } from "./stats-widgets.js";
|
||||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||||
|
|
||||||
@@ -108,7 +108,6 @@ export type Host = {
|
|||||||
runtime?: "docker" | "podman";
|
runtime?: "docker" | "podman";
|
||||||
} | null;
|
} | null;
|
||||||
enableWebUi?: boolean;
|
enableWebUi?: boolean;
|
||||||
webUiConfig?: WebUiConfig | null;
|
|
||||||
enableProxmox: boolean;
|
enableProxmox: boolean;
|
||||||
enableTmuxMonitor: boolean;
|
enableTmuxMonitor: boolean;
|
||||||
enableTerminalToolbar: boolean;
|
enableTerminalToolbar: boolean;
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export function hostToSSHHost(h: Host): SSHHost {
|
|||||||
enableAiAssistant: h.enableAiAssistant ?? false,
|
enableAiAssistant: h.enableAiAssistant ?? false,
|
||||||
dockerConfig: h.dockerConfig ?? null,
|
dockerConfig: h.dockerConfig ?? null,
|
||||||
enableWebUi: h.enableWebUi ?? false,
|
enableWebUi: h.enableWebUi ?? false,
|
||||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as
|
||||||
|
SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] },
|
||||||
showTerminalInSidebar: true,
|
showTerminalInSidebar: true,
|
||||||
showFileManagerInSidebar: true,
|
showFileManagerInSidebar: true,
|
||||||
showTunnelInSidebar: true,
|
showTunnelInSidebar: true,
|
||||||
|
|||||||
@@ -195,8 +195,6 @@ export function createHostEditorForm(
|
|||||||
enableFileManager: host?.enableFileManager ?? false,
|
enableFileManager: host?.enableFileManager ?? false,
|
||||||
scpLegacy: host?.scpLegacy ?? false,
|
scpLegacy: host?.scpLegacy ?? false,
|
||||||
enableDocker: host?.enableDocker ?? false,
|
enableDocker: host?.enableDocker ?? false,
|
||||||
enableWebUi: host?.enableWebUi ?? false,
|
|
||||||
webUiConfig: host?.webUiConfig ?? { endpoints: [] },
|
|
||||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||||
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
|
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
|
||||||
@@ -508,8 +506,6 @@ export function buildHostEditorPayload(
|
|||||||
scpLegacy: form.scpLegacy,
|
scpLegacy: form.scpLegacy,
|
||||||
enableDocker: form.enableDocker,
|
enableDocker: form.enableDocker,
|
||||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||||
enableWebUi: form.enableWebUi,
|
|
||||||
webUiConfig: form.enableWebUi ? form.webUiConfig : null,
|
|
||||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||||
enableTerminalToolbar: form.enableTerminalToolbar,
|
enableTerminalToolbar: form.enableTerminalToolbar,
|
||||||
enableAiAssistant: form.enableAiAssistant,
|
enableAiAssistant: form.enableAiAssistant,
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
enableDocker: h.enableDocker ?? false,
|
enableDocker: h.enableDocker ?? false,
|
||||||
dockerConfig: h.dockerConfig ?? null,
|
dockerConfig: h.dockerConfig ?? null,
|
||||||
enableWebUi: h.enableWebUi ?? false,
|
enableWebUi: h.enableWebUi ?? false,
|
||||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
|
||||||
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
|
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
|
||||||
enableProxmoxStats:
|
enableProxmoxStats:
|
||||||
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
|
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
|
||||||
|
|||||||
Reference in new issue
Block a user